openvibers/openvibe.live Commits
Arena v2: speech-driven beefs, the community board, and the ears
Changes
@@@ -1,94 +1,92 @@--# Arena — streamer vs streamer++# Arena — beefs, the board, and the ears--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.++The **Arena** tab (`/arena`) is where the platform's culture gets weaponised into entertainment. Nobody clicks "fight". Everything runs off what streamers *say* on mic (the continuous audio transcription) and what viewers *do* in chat:--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).++- **Beefs** — a streamer says another fighter's name while talking shit → a beef opens by itself, the target goes on the clock, silence is a forfeit.++- **The board** — topics, debates, phrase challenges and bounties that come from chat (`!topic`, `!bounty`) and from the **pulse** (the AI reads global chat, transcripts and the AI timeline every 30 min and writes what the community is on about). Streamers click a topic, talk on it, clear its angles → XP → **Trash Level**.++- **Fighters** — the roster is every streamer active in the last 45 days, rated on 7 stats (HYPE, GRIND, CHAT, LOYALTY, CLUTCH, VIBE, **MIC**) as percentiles across the roster, plus an AI persona and portrait. Recent Trash Level XP and beef wins add a *mouth bonus* (≤ +12) to POWER.--## What each streamer gets++With AI off everything still works: template headlines, fallback angles, keyword judges.--| Piece | Source | Cost |--|---|---|---|--| **Ratings** — HYPE, GRIND, CHAT, LOYALTY, CLUTCH, VIBE, **MIC** (40–99) and overall **POWER** | `streams` + `stream_analytics` (last 90 days), `follows`, `transactions` (tips). **MIC** comes from the continuous audio transcription (`stream_timeline_events`): share of stream time spent talking, words per minute, hype words per hour, laughs per hour (sound events). Each rating is the streamer's *percentile* on that metric across the active roster (streamed in the last 45 days), so a 99 means "best on the site", never an absolute number. Streamers without transcript data tie at the MIC floor. | none |--| **Quotes** — "things they actually said", each with a ▶ that jumps to the VOD at that second, plus a walkout line, mic style and a one-line voice verdict | Transcript lines (25–220 chars, last 90 days) pre-scored for hype/punctuation, then the AI picks the most quotable and harmless ones. Heuristic pick when AI is off. Cached 24 h. Needs ≥ 20 lines. | ~1 LLM call / streamer / day |--| **Persona** — fighter name, title, class, element, signature move, special, weakness, taunt, lore, catchphrase, entrance music, a quip per stat | AI (`summary` role) from the streamer's AI overview, stream memories, chat-AI notes, recent VOD titles/overviews and the numbers. Cached 24 h. | ~1 LLM call / streamer / day |--| **Portrait** — stylised character-select art | AI image model (OpenAI-compatible `/images/generations`). The prompt is built from the persona, category, profile colour and a *scene* description of the latest thumbnail (setting, objects, mood — the vision model is told never to describe faces or identity). It is deliberately **not a likeness** of the person. Cached 7 days. | 1 image / streamer / week, only when enabled |--| **Record** — W–L | `arena_battles` | none |++## Speech policy--## Battles++The platform does not censor language. Offensive, provocative, taboo words — slurs included — are allowed, are never filtered, and are never a reason for the judge to score low. The only lines that don't count (`server/arena/arena-service.js` `isBannedText`, plus the judge's `flagged`) are **behaviour**: direct threats of violence (`kys`, "I'll kill you"), sexual content about minors, and doxxing (addresses, phone numbers). Those lines are dropped from quotes, feeds and scoring; nothing else is.--`/arena/battle/<a>/<b>` — the same pair on the same day always gets the same five rounds (Hype Check, Chat War, Endurance, Clutch Time, **Mic Drop**), simulated from the ratings with a seeded random so the underdog sometimes lands an upset. The **crowd vote** is the fifth round: one vote per user (per IP when anonymous), changeable until midnight UTC, and it breaks 2–2 ties (Power breaks a tie with no votes). The announcer commentary is AI (`chat` role, cached per battle; it gets both walkout lines) with a templated fallback. Every round line expands to show the ratings and rolls behind it; **Tale of the tape** compares the raw numbers; the all-time head-to-head record is shown above the commentary.++## Beefs (`server/arena/beef.js`)--**Main event** — one featured battle per day, seeded from the top 8 of the ladder, pinned to the top of `/arena` with its own vote buttons.++| Rule | Value |++|---|---|++| Opens when | the listener judges ≥ 20 words in the 45 s after a fighter's name was said as *aimed at them* (roast, callout, disrespect, brag over them) |++| Clock | the other side must answer on their own stream within **15 min if live**, **24 h if offline** (an offline clock is tightened to 15 min the moment they go live) |++| Answer | any judged hit from the side on the clock; every hit flips the clock to the other side |++| Forfeit | clock runs out → the silent side loses |++| Hard end | 24 h after opening → higher **total** wins (`total = Σ hit quality (0–10, judged) + crowd hype (max 10)`), equal = draw |++| Upset | winner ranked ≥ 4 places below the loser (+20 XP) |++| Rematch | same pair again → flagged, with the rivalry's record and **receipts** (best lines from earlier beefs) |++| XP | +5 open · +quality per hit (×2 while a bounty is on the target) · +40 win |++| Crowd | `!hype` / Hype button: one per person per side · `!side <name>`: pick who wins → **clout** when right |--Live streamers are paired by neighbouring Power on the leaderboard ("Live battles"), with their live thumbnails, refreshed every 30 s.++Headlines (open + result) are AI-written (`chat` role) with templates as fallback; the announcer's one-liner per hit comes from the judge. Streaks, upsets and the head-to-head history are shown on every beef card.--## API++## The board (`server/arena/board.js`)--| Method | Path | Notes |++| Kind | Comes from | Resolves ||---|---|---|--| GET | `/api/arena/fighters` | leaderboard |--| GET | `/api/arena/fighters/:username` | full card incl. `voice`, `quotes`, `recent_battles`, `rivalry`; generates a stale persona/quotes synchronously and kicks off a portrait in the background (`image_pending`) |--| GET | `/api/arena/fighters/:username/stat/:stat` | drill-down: per-stream series (last 14 streams), rank on that stat, top 3 of the ladder |--| GET | `/api/arena/main-event` | today's featured battle |--| POST | `/api/arena/fighters/:username/refresh` | admin — regenerate persona (+ portrait unless `{ "image": false }`) |--| GET | `/api/arena/battle/:a/:b` | today's battle (created on first view); `?generate=0` skips AI |--| POST | `/api/arena/battle/:a/:b/vote` | `{ "side": "a" \| "b" }` |--| 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/`.++| **topic** | chat (`!topic`), the page, the pulse | archived after 36 h idle; streamers clear its 3 AI-cut **angles** (brag / roast / bit) by talking; all 3 → *conquered* |++| **debate** (A vs B) | the pulse (arguments exploding in chat / transcripts) | after 24 h: `talk×2 + chat picks` per side; MVP +30 XP; right picks earn clout |++| **phrase** ("say X on stream") | the pulse (memes going round) | 6 h; no model needed — 15 XP first time it comes out of your mouth, 5 after |++| **bounty** (on a fighter) | chat (`!bounty <user>`), the page, the pulse | 6 h; every beef hit on the target pays double; top collector +40 XP |--## Interaction++**Heat** = judged hits×3 + hype + side picks + fighters talking×4 in the last hour; ≥ 12 is **HOT** and the board sorts by it. The **pulse** (`refreshPulse`, every 30 min, `summary` role) reads: the global chat AI summary, `chat_timeline_events`, recent chat, last-hour transcripts, stream memories, streamer overviews, VOD overviews, open beefs and the ladder — and posts up to 6 new typed events with tabloid headlines plus a one-line "pulse" sentence shown at the top of `/arena`. Templates seed the board when AI is off.--- Leaderboard rows expand in place (radar, quips, signature move, fight/profile buttons); every stat on a profile opens a sparkline of the last 14 streams with the roster's top 3; the portrait opens a lightbox that explains how it was painted (and shows the prompt).--- 🔊 buttons read taunts, quotes and the announcer's call aloud with the browser's own speech synthesis (no server cost); ▶ on a quote jumps to the VOD at that moment so you hear the real thing.--- Live matchups show the **hot mic** — the last line the transcription heard on each live stream.++**Trash Level** = 1 + XP/50. XP: judged topic chunk (quality×0.8) · angle cleared +25 · topic conquered +60 · chat hype +2 · beef (above). The Trash Level ladder and the viewer **clout** ladder (right side picks, accuracy, streak) sit on `/arena`.++## The ears (`server/arena/listener.js`)--## Trash Talk (`/arena/talk`)++Every 15 s, for every roster stream that is live **and** has a transcript line in the last 30 min:--The part streamers and viewers play themselves.++1. New `stream_timeline_events` speech lines are read. Every line is checked against open phrase challenges.++2. A fighter's name (username, display name, fighter name; `@name`; underscores/dots spoken as spaces; longest match first, never yourself) opens a **mention buffer**; lines in the following 45 s join it. At ≥ 20 words the **beef judge** runs (`aimed_at_target`, `quality`, `best_line` verbatim, `about`, `announcer`, `flagged`) → `beef.recordHit`.++3. Other lines pool for the **topic judge** against the streamer's active board topic (≥ 20 words, ≥ 30 s between calls): `angle_idx`, `quality`, `progress_gain` (0–60), `best_line`, `about`.++4. One judge call per stream per tick; keyword heuristics when AI is off.--- **Topic** — every 6 h the Arena posts a topic (AI-written from the main event / top of the ladder; templated when AI is off), e.g. *"Explain why your chat is the smartest chat on this site."*--- **Entering** — a streamer on the roster enters once per topic, either from the **live mic** (press *I'm about to cook*, say it on stream, *Drop the mic* — the entry is what the transcription heard in that window, linked to the VOD second) or by **typing** (≤ 280 chars). Live mic needs the stream to be live with the timeline transcription running.--- **Judge** — the AI scores **Spice, Wit, On-topic, Delivery** (0–10 each) with a verdict, a judge's note and a stamp (COOKED ≥ 42 · SOLID ≥ 30 · MID ≥ 18 · FLOP). Slurs/hate/threats are caught by the hard filter before the model, and the model can flag cruelty: a flagged entry scores 0 and its text is hidden from everyone but the author. Heuristic scoring when AI is off.--- **Crowd** — viewers add the fifth score: `!hype` in the streamer's chat or the Hype button, one per person, `Crowd = min(10, hypers / 3)`, open for 24 h.--- **Power** — total (/50) × 12 → a Trash Talk bonus on POWER (max +12), decaying linearly over 7 days; shown on the profile and included in the leaderboard order.--- **Hall of Trash** — the ten best lines of the last 30 days.++`/arena/live/<username>` shows exactly what the ears hear for a fighter: hot mic lines, the last judgements, active topic progress, open beefs and any bounty on them (auto-refreshes; a level-up flashes).++## Pages--### Live sessions (`/arena/talk/<username>`)++`/arena` — pulse · on the mic now · open + settled beefs · the board (start a topic, pick debate sides, hype, "Talk on this") · Trash Level ladder · clout ladder · power ladder (rows expand) · rules.++`/arena/beef/<id>` — tale of the tape, tug-of-war, live clock, ringside feed (every judged hit with ▶ to the VOD second and 🔊), receipts from earlier beefs, hype/side buttons.++`/arena/topic/<id>` — angles, who's talking and their per-angle progress, best lines (▶), debate sides, bounty target, join/leave.++`/arena/<username>` — stats drill-down, voice + quotes, Trash Level card, rivalries with receipts, beef history.--The continuous version: a streamer who is live with the timeline transcription running presses **Start live session** on `/arena/talk`. From then on (`server/arena/talk-session.js`, ticker every 15 s):++## Chat commands (`server/arena/arena-chat.js`)--- New transcript lines are buffered against the **current topic**; once ≥ 25 new words have arrived and ≥ 30 s passed since the last call, the judge scores the chunk: *is this trash talk on the topic?*, Spice/Wit/On-topic/Delivery, a `progress_gain` (0–60 — how much of the topic that chunk covered), the best verbatim line, and a ≤ 10-word "what they talked about" tag.--- **Progress** fills the topic bar; at 100 the topic is **cleared** → it becomes a regular Trash Talk entry (POWER bonus, Hall of Trash) and the **next topic is generated**, shaped by what was already cleared. Talk enough → the topic changes. Streamers can skip a topic or end the session.--- **XP / Trash Level**: each judged chunk adds XP = quality × (trash talk ? 1 : 0.15); a level every 40 XP. Viewers add +2 XP and +4 % progress with `!hype` (one per person per topic).--- The public console shows the live topic + bar, the latest judgement ("that was trash talk" / "not trash talk yet"), the best line so far (▶ VOD), the **hot mic** feed, "what they talked about" tags, topics cleared/skipped, and a level-up flash. Live sessions are listed on `/arena/talk` under "Talking trash right now".--- Guard rails: one live session per streamer; ends when the stream ends, after 20 idle minutes, at 3 h, or at 300 judge calls; the slur filter voids chunks before the model sees them; lines shown on the console are filtered too.++`!topic <text>` (1 per 10 min) · `!bounty <user>` (1 per hour) · `!hype` (the streamer's newest open beef, else their active topic) · `!side <name|a|b>` · `!beef` · `!board` · `!arena [user]`. One command per person every 4 s; replies are private system lines, milestones go to the room.--API: `GET /api/arena/talk/sessions` · `GET /api/arena/talk/session/:user` · `POST /api/arena/talk/session/start|stop|skip` (auth) · `POST /api/arena/talk/session/:user/hype`.++## API (`/api/arena`)--Chat commands (`server/arena/arena-chat.js`, hooked into `ChatServer.handleBangCommand`): `!hype`, `!talk`, `!arena [user]`, `!vote a|b` (main event, signed-in), `!fight <user>`. Rate-limited to one per person every 4 s; replies are private system lines, hype milestones and call-outs are announced to the room.----API: `GET /api/arena/talk` (board: topic, entries, hall, my entry, mic availability) · `POST /api/arena/talk/mic/start` · `GET /api/arena/talk/mic/feed` · `POST /api/arena/talk/submit {mode:'mic'|'text', text?}` · `POST /api/arena/talk/:id/hype`.++| Method | Path | Notes |++|---|---|---|++| GET | `/status` · `/fighters` · `/fighters/:user` · `/fighters/:user/stat/:stat` · `/live` | roster, card (+ `beefs`, `rivalries`, `level`, `active_topic`), drill-down, live fighters (hot mic, active topic, open beefs) |++| POST | `/fighters/:user/refresh` | admin — regenerate persona (+ portrait) |++| GET | `/console/:user` | the ears: listener state, hot mic, active topic + my progress, open beefs, bounty |++| GET | `/board` | `open` (by heat), `resolved`, `pulse`, `levels`, `clout` |++| POST | `/board/topics {text}` · `/board/bounty {username}` | auth |++| GET | `/board/topics/:id` | detail incl. per-member progress and best lines |++| POST | `/board/topics/:id/join` · `/leave` (auth, roster) · `/side {side}` · `/hype {user_id}` | |++| POST | `/pulse/refresh` | admin — force a pulse read |++| GET | `/beefs` · `/beefs/:id` | |++| POST | `/beefs/:id/hype {side}` · `/beefs/:id/side {side}` | one per person; anonymous by hashed IP |++| GET | `/levels` · `/clout` | ladders |++++## Settings--## Guard rails++| Key | Default | Effect |++|---|---|---|++| `arena_enabled` | `true` | `false` → API 404s |++| `ai_image_enabled` / `ai_image_model` / `ai_image_quality` / `ai_image_cost_usd` | `false` / `gpt-image-1` / `low` / `0.011` | portraits |++| `arena_vote_salt` | `JWT_SECRET` | salt for anonymous voter hashing |--- 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.++Background job (`server/arena/arena-job.js`): personas/portraits every 20 min (bounded, budget-aware) · listener every 15 s · clocks + expiries every 60 s · pulse every 30 min.--Tests: `node test/arena.test.js` (ratings, seeded battles, votes, roster on a temp DB — no AI needed).++Tests: `node test/arena.test.js` (roster, ratings, filter, quotes) · `node test/arena-beef-board.test.js` (beefs, board, listener tick, chat commands, API) — both on a temp DB, no AI needed.@@@ -13946,62 +13946,8 @@ label.staff-inline-toggle.aiv-field { flex-direction:row; align-items:center; }.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; }--}--/* ── Arena: expandable rows, stat drill-down, voice, main event, tape ── */.arena-row { cursor: pointer; }.arena-row .arena-chevron { color: var(--text-muted); transition: transform 0.15s; margin-left: 6px; }@@@ -14065,46 +14011,6 @@ label.staff-inline-toggle.aiv-field { flex-direction:row; align-items:center; }.arena-lightbox-close { position: absolute; top: 16px; right: 20px; background: none; border: 0; color: #fff; font-size: 1.8rem; cursor: pointer; }@media (max-width: 720px) { .arena-lightbox-inner { grid-template-columns: 1fr; } }--/* main event */--.arena-main-event { margin: 4px 0 24px; padding: 16px 18px; border-radius: 16px; border: 1px solid var(--border); background: linear-gradient(120deg, color-mix(in srgb, var(--accent) 16%, var(--bg-card)), var(--bg-card) 60%, color-mix(in srgb, #ec4899 14%, var(--bg-card))); }--.arena-main-event-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; margin-bottom: 10px; }--.arena-main-event-head h2 { margin: 0; font-size: 1.1rem; display: flex; align-items: center; gap: 8px; }--.arena-main-event-head h2 i { color: #f5c542; }--.arena-main-event-body { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 14px; }--.arena-me-side { display: flex; align-items: center; gap: 12px; min-width: 0; text-decoration: none; color: var(--text-primary); }--.arena-me-side.is-b { flex-direction: row-reverse; text-align: right; }--.arena-me-side strong { display: block; font-size: 1.05rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }--.arena-me-side span { font-size: 0.78rem; color: var(--text-secondary); }--.arena-me-center { display: flex; flex-direction: column; align-items: center; gap: 6px; }--.arena-me-score { font-size: 1.6rem; font-weight: 900; font-variant-numeric: tabular-nums; }--.arena-me-vote { display: flex; gap: 6px; }--.arena-me-vote button { padding: 6px 10px; border-radius: 8px; border: 1px solid var(--border); background: var(--bg-card); color: var(--text-primary); font-size: 0.8rem; cursor: pointer; }--.arena-me-vote button.is-picked { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 20%, var(--bg-card)); }--.arena-me-tally { font-size: 0.72rem; color: var(--text-muted); }--.arena-me-intro { font-size: 0.88rem; color: var(--text-secondary); margin: 10px 0 0; font-style: italic; }----/* battle extras */--.arena-history { font-size: 0.8rem; color: var(--text-muted); }--.arena-walkout { margin: 8px 0 0; font-size: 0.82rem; color: var(--text-secondary); display: flex; align-items: center; gap: 6px; justify-content: center; flex-wrap: wrap; }--.arena-walkout q { font-style: italic; }--.arena-line.is-expandable { cursor: pointer; }--.arena-line-detail { display: none; margin-top: 6px; font-size: 0.78rem; color: var(--text-muted); }--.arena-line.is-open .arena-line-detail { display: block; }--.arena-tape { width: 100%; }--.arena-tape summary { cursor: pointer; font-weight: 600; font-size: 0.9rem; padding: 6px 0; color: var(--text-secondary); }--.arena-tape table { width: 100%; border-collapse: collapse; font-size: 0.82rem; font-variant-numeric: tabular-nums; }--.arena-tape td, .arena-tape th { padding: 5px 6px; border-bottom: 1px solid var(--border); text-align: center; }--.arena-tape th { color: var(--text-muted); font-weight: 500; font-size: 0.72rem; }--.arena-tape td.is-better { color: var(--accent-light); font-weight: 700; }--.arena-tape td.arena-tape-label { text-align: center; color: var(--text-muted); }--@media (max-width: 900px) {-- .arena-row.is-open .arena-row-expand { grid-template-columns: 1fr; }-- .arena-main-event-body { grid-template-columns: 1fr; }-- .arena-me-side.is-b { flex-direction: row; text-align: left; }--}--#arena-stat-detail { grid-column: 1 / -1; }--#arena-stat-detail:empty { display: none; }--/* ── AI timeline transcript: speech lines vs. sound strips ── */.ai-tl-tr-bar { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; margin: 0 0 8px; padding-bottom: 8px; border-bottom: 1px solid var(--border, rgba(255,255,255,0.07)); }.ai-tl-tr-filter { display: inline-flex; gap: 4px; }@@@ -14184,182 +14090,273 @@ label.staff-inline-toggle.aiv-field { flex-direction:row; align-items:center; }}@media (prefers-reduced-motion: reduce) { .hero-chart-svg .bar { animation: none; } .hero-chart, .hero-chart-overlay { animation: none; } }--/* ── Arena: Trash Talk ── */--.arena-talk-card { margin: 4px 0 24px; padding: 16px 18px; border-radius: 16px; border: 1px solid var(--border); background: linear-gradient(120deg, color-mix(in srgb, #f97316 14%, var(--bg-card)), var(--bg-card) 65%); }--.arena-talk-card-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; }--.arena-talk-card-head h2 { margin: 0; font-size: 1.1rem; display: flex; align-items: center; gap: 8px; }--.arena-talk-card-head h2 i { color: #f97316; }--.arena-talk-card-head h2 small { color: var(--text-muted); font-weight: 400; font-size: 0.8rem; }--.arena-talk-topic { font-size: 1.15rem; font-weight: 600; margin: 10px 0 8px; line-height: 1.35; }--.arena-talk-card-foot { display: flex; justify-content: space-between; gap: 10px; flex-wrap: wrap; font-size: 0.85rem; color: var(--text-secondary); }--.arena-talk-card-foot code, .arena-talk-how code, .arena-talk-gate code, .arena-talk-hype code { background: var(--bg-tertiary); padding: 1px 5px; border-radius: 4px; font-size: 0.85em; }--.arena-stamp { display: inline-block; padding: 2px 8px; border-radius: 6px; font-size: 0.7rem; font-weight: 900; letter-spacing: 0.08em; border: 1.5px solid currentColor; transform: rotate(-3deg); }--.arena-stamp--cooked { color: #f97316; } .arena-stamp--solid { color: #22c55e; } .arena-stamp--mid { color: #eab308; } .arena-stamp--flop { color: #94a3b8; }--.arena-stamp--big { font-size: 1.3rem; padding: 6px 14px; animation: arena-stamp-in 0.5s cubic-bezier(0.2, 1.4, 0.4, 1) both; animation-delay: 1.3s; }@keyframes arena-stamp-in { from { transform: scale(2.2) rotate(-12deg); opacity: 0; } to { transform: scale(1) rotate(-3deg); opacity: 1; } }--.arena-talk-hero { display: grid; grid-template-columns: 1.3fr 1fr; gap: 20px; margin-bottom: 20px; }--.arena-talk-hero-main { padding: 22px; border-radius: 18px; border: 1px solid var(--border); background: radial-gradient(80% 60% at 0% 0%, color-mix(in srgb, #f97316 20%, transparent), transparent), var(--bg-card); }--.arena-talk-kicker { font-size: 0.75rem; letter-spacing: 0.08em; text-transform: uppercase; color: #f97316; font-weight: 700; margin-bottom: 8px; }--.arena-talk-kicker span { color: var(--text-muted); text-transform: none; letter-spacing: 0; }--.arena-talk-topic-big { margin: 0; font-size: clamp(1.4rem, 3vw, 2rem); line-height: 1.25; }--.arena-talk-hint { color: var(--text-secondary); margin: 12px 0 0; }--.arena-talk-hint i { color: #eab308; }--.arena-talk-how { padding: 16px 18px; border-radius: 18px; border: 1px solid var(--border); background: var(--bg-card); font-size: 0.88rem; }--.arena-talk-how h3 { margin: 0 0 8px; font-size: 1rem; }--.arena-talk-how ol { margin: 0 0 8px; padding-left: 20px; display: flex; flex-direction: column; gap: 6px; color: var(--text-secondary); }--.arena-talk-how b { color: var(--text-primary); }--.arena-talk-panel { margin-bottom: 24px; padding: 18px; border-radius: 18px; border: 2px solid color-mix(in srgb, #f97316 45%, var(--border)); background: var(--bg-card); }--.arena-talk-panel-head { display: flex; justify-content: space-between; align-items: center; gap: 10px; margin-bottom: 12px; }--.arena-talk-panel-head h2 { margin: 0; font-size: 1.1rem; display: flex; align-items: center; gap: 8px; } .arena-talk-panel-head h2 i { color: #f97316; }--.arena-talk-modes { display: flex; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; }--.arena-talk-mode { padding: 8px 14px; border-radius: 10px; border: 1px solid var(--border); background: var(--bg-tertiary); color: var(--text-secondary); cursor: pointer; font-weight: 600; }--.arena-talk-mode.active { border-color: #f97316; color: var(--text-primary); background: color-mix(in srgb, #f97316 14%, var(--bg-tertiary)); }--.arena-talk-mode:disabled { opacity: 0.5; cursor: not-allowed; }--.arena-talk-mode small { font-weight: 400; color: var(--text-muted); }--.arena-talk-panel textarea { width: 100%; min-height: 90px; padding: 12px; border-radius: 12px; border: 1px solid var(--border); background: var(--bg-input); color: var(--text-primary); font: inherit; resize: vertical; }--.arena-talk-actions { display: flex; justify-content: space-between; align-items: center; gap: 10px; margin-top: 10px; flex-wrap: wrap; }.arena-mic-status { display: flex; align-items: center; gap: 8px; font-weight: 600; color: var(--text-secondary); }.arena-mic-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--text-muted); }.arena-mic-status.is-live { color: #ef4444; } .arena-mic-status.is-live .arena-mic-dot { background: #ef4444; box-shadow: 0 0 0 4px rgba(239, 68, 68, 0.25); animation: arena-pulse 1.2s infinite; }.arena-mic-feed { margin-top: 10px; min-height: 90px; max-height: 220px; overflow-y: auto; padding: 10px 12px; border-radius: 12px; background: var(--bg-secondary); border: 1px dashed var(--border); font-size: 0.9rem; }--.arena-mic-line { display: flex; gap: 10px; padding: 3px 0; animation: arena-in 0.3s; } .arena-mic-line span { color: var(--text-muted); font-variant-numeric: tabular-nums; font-size: 0.78rem; min-width: 38px; }--.arena-talk-gate { padding: 14px 16px; border-radius: 12px; background: var(--bg-tertiary); border: 1px solid var(--border); margin-bottom: 20px; color: var(--text-secondary); }--.arena-talk-gate.is-done { border-color: #22c55e; } .arena-talk-gate.is-error { border-color: var(--danger); color: var(--danger); }--.arena-talk-list { display: flex; flex-direction: column; gap: 10px; }--.arena-talk-entry { padding: 14px 16px; border-radius: 14px; background: var(--bg-card); border: 1px solid var(--border); transition: transform 0.1s; }--.arena-talk-entry.is-mine { border-color: color-mix(in srgb, #f97316 50%, var(--border)); }--.arena-talk-entry.is-flagged { opacity: 0.7; }--.arena-talk-entry.is-hit { animation: arena-hit 0.5s; }--.arena-talk-entry-head { display: flex; justify-content: space-between; align-items: center; gap: 10px; flex-wrap: wrap; }--.arena-talk-who { display: flex; align-items: center; gap: 10px; color: var(--text-primary); text-decoration: none; min-width: 0; }--.arena-talk-who span { display: flex; flex-direction: column; min-width: 0; } .arena-talk-who small { color: var(--text-muted); font-size: 0.75rem; } .arena-talk-who em { font-style: italic; }--.arena-talk-total { display: flex; align-items: center; gap: 8px; } .arena-talk-total b { font-size: 1.5rem; color: var(--accent-light); } .arena-talk-total small { color: var(--text-muted); }--.arena-talk-text { margin: 10px 0; padding: 10px 14px; border-left: 3px solid #f97316; background: var(--bg-tertiary); border-radius: 0 10px 10px 0; font-size: 1rem; line-height: 1.5; }--.arena-talk-hear { margin-left: 8px; font-size: 0.8rem; color: var(--accent-light); text-decoration: none; }--.arena-talk-void { color: var(--text-muted); font-style: italic; }--.arena-talk-verdict { margin: 6px 0; font-weight: 600; } .arena-talk-verdict i { color: var(--accent); margin-right: 6px; }--.arena-talk-note { margin: 4px 0; font-size: 0.85rem; color: var(--text-secondary); font-style: italic; }--.arena-talk-scores { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 6px 16px; margin: 8px 0; }--.arena-talk-score { display: grid; grid-template-columns: 86px 1fr 24px; align-items: center; gap: 8px; font-size: 0.8rem; }--.arena-talk-score-label { color: var(--text-secondary); } .arena-talk-score-label i { width: 14px; color: #f97316; }--.arena-talk-score-track { height: 8px; border-radius: 4px; background: var(--bg-tertiary); overflow: hidden; }--.arena-talk-score-fill { display: block; height: 100%; background: linear-gradient(90deg, #f97316, #fbbf24); transform-origin: left; }--.arena-talk-scores.is-animated .arena-talk-score-fill { animation: arena-fill 0.7s cubic-bezier(0.2, 0.8, 0.3, 1) both; animation-delay: calc(0.15s + var(--i, 0) * 0.22s); }@keyframes arena-fill { from { transform: scaleX(0); } to { transform: scaleX(1); } }--.arena-talk-score b { text-align: right; font-variant-numeric: tabular-nums; }--.arena-talk-hype { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-top: 8px; font-size: 0.82rem; color: var(--text-secondary); }--.arena-talk-reveal { padding: 16px; border-radius: 14px; border: 1px solid #f97316; background: radial-gradient(70% 50% at 50% 0%, color-mix(in srgb, #f97316 18%, transparent), transparent), var(--bg-card); animation: arena-in 0.3s; }--.arena-talk-reveal.is-flagged { border-color: var(--danger); }--.arena-talk-reveal-head { display: flex; align-items: center; justify-content: center; gap: 18px; margin-bottom: 8px; }--.arena-talk-reveal-total b { font-size: 3rem; font-weight: 900; color: var(--accent-light); font-variant-numeric: tabular-nums; } .arena-talk-reveal-total small { color: var(--text-muted); font-size: 1rem; }.arena-talk-bonus { font-size: 0.85rem; font-weight: 700; color: #f97316; }--@media (max-width: 900px) { .arena-talk-hero { grid-template-columns: 1fr; } .arena-talk-score { grid-template-columns: 74px 1fr 22px; } }----/* ── Arena: phone layout pass ── */--@media (max-width: 900px) {-- .arena-row { grid-template-columns: 28px 56px minmax(0, 1fr) auto; gap: 10px; padding: 10px 12px; }-- .arena-row .arena-portrait-sm { width: 56px; height: 56px; }-- .arena-row-main strong { font-size: 0.95rem; white-space: normal; line-height: 1.2; }-- .arena-row-sub { white-space: normal; line-height: 1.3; }-- .arena-rank { font-size: 1rem; }-- .arena-row-stats { flex-direction: column; gap: 2px; align-items: flex-end; }-- .arena-power b { font-size: 1.3rem; }-- .arena-record { font-size: 0.75rem; }-- .arena-hero { padding: 16px 0 10px; } .arena-hero h1 { font-size: 1.5rem; }-- .arena-board-head { flex-direction: column; align-items: stretch; }-- .arena-board-head input { min-width: 0; width: 100%; }-- .arena-live-match { grid-template-columns: 1fr; gap: 8px; }-- .arena-live-b { flex-direction: row; text-align: left; }-- .arena-live-vs { justify-self: center; width: 44px; height: 44px; font-size: 0.8rem; }-- .arena-live-thumb { width: 80px; height: 45px; }-- .arena-main-event-body { gap: 10px; }-- .arena-me-side.is-b { flex-direction: row; text-align: left; }-- .arena-talk-card-foot { flex-direction: column; gap: 6px; }-- .arena-profile { padding: 14px; gap: 16px; }-- .arena-profile-portrait { flex-direction: column; }-- .arena-portrait-lg { max-width: 220px; }-- .arena-profile-name h1 { font-size: 1.5rem; }-- .arena-quips { grid-template-columns: 1fr; }-- .arena-numbers { grid-template-columns: repeat(2, 1fr); }-- .arena-side { padding: 12px; }-- .arena-portrait-md { max-width: 180px; }-- .arena-vs { font-size: 2.2rem; }-- .arena-battle-actions .btn { font-size: 0.85rem; padding: 8px 12px; }-- .arena-talk-hero-main { padding: 16px; }-- .arena-talk-scores { grid-template-columns: 1fr; }-- .arena-talk-entry-head { align-items: flex-start; }-- .arena-quote { grid-template-columns: 1fr; }-- .arena-voice-meters { grid-template-columns: repeat(2, 1fr); }--}--@media (max-width: 480px) {-- .arena-row { grid-template-columns: 22px 48px minmax(0, 1fr) auto; gap: 8px; padding: 8px 10px; }-- .arena-row .arena-portrait-sm { width: 48px; height: 48px; }-- .arena-row-main strong { font-size: 0.9rem; }-- .arena-row-sub { font-size: 0.74rem; }-- .arena-row-sub .arena-live-pill { margin-left: 4px; }-- .arena-numbers { grid-template-columns: repeat(2, 1fr); }-- .arena-hero-actions { width: 100%; }-- .arena-hero-actions .btn { width: 100%; justify-content: center; }--}----/* ── Arena: live trash-talk sessions ── */--.arena-sessions-live { margin: 0 0 20px; }--.arena-sessions-live h2 { font-size: 1.05rem; margin: 0 0 10px; display: flex; align-items: center; gap: 8px; }--.arena-session-strip { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 10px; }--.arena-session-chip { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 12px; background: var(--bg-card); border: 1px solid color-mix(in srgb, #ef4444 40%, var(--border)); color: var(--text-primary); text-decoration: none; }--.arena-session-chip .arena-live-dot { flex-shrink: 0; }--.arena-session-chip span { display: flex; flex-direction: column; min-width: 0; }--.arena-session-chip strong { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }--.arena-session-chip small { color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }--.arena-session-chip .arena-session-lvl { margin-left: auto; font-weight: 800; color: #f97316; white-space: nowrap; }--.arena-session { display: grid; grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr); gap: 16px; margin-bottom: 24px; }--.arena-session-main, .arena-session-side { display: flex; flex-direction: column; gap: 12px; min-width: 0; }--.arena-session-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; padding: 12px 16px; border-radius: 14px; background: var(--bg-card); border: 1px solid var(--border); }--.arena-session-who { display: flex; align-items: center; gap: 10px; min-width: 0; }--.arena-session-who strong { font-size: 1.05rem; }--.arena-session-status { display: inline-flex; align-items: center; gap: 6px; font-size: 0.75rem; font-weight: 800; letter-spacing: 0.06em; text-transform: uppercase; color: #ef4444; }--.arena-session-status.is-ended { color: var(--text-muted); }--.arena-session-level { display: flex; align-items: center; gap: 12px; }--.arena-session-level b { font-size: 1.6rem; color: #f97316; line-height: 1; }--.arena-session-level small { display: block; font-size: 0.62rem; letter-spacing: 0.1em; color: var(--text-muted); }.arena-xp { width: 140px; }.arena-xp-track { height: 8px; border-radius: 4px; background: var(--bg-tertiary); overflow: hidden; }.arena-xp-fill { display: block; height: 100%; background: linear-gradient(90deg, #f97316, #fbbf24); transition: width 0.6s cubic-bezier(0.2, 0.8, 0.3, 1); }.arena-xp-label { font-size: 0.7rem; color: var(--text-muted); margin-top: 3px; text-align: right; }--.arena-session-topic { padding: 18px 20px; border-radius: 16px; border: 2px solid color-mix(in srgb, #f97316 55%, var(--border)); background: radial-gradient(80% 60% at 0% 0%, color-mix(in srgb, #f97316 18%, transparent), transparent), var(--bg-card); position: relative; overflow: hidden; }--.arena-session-topic.is-cleared-flash { animation: arena-cleared 0.9s ease-out; }@keyframes arena-cleared { 0% { box-shadow: 0 0 0 0 rgba(249, 115, 22, 0.6); } 100% { box-shadow: 0 0 0 30px rgba(249, 115, 22, 0); } }--.arena-session-topic-kicker { font-size: 0.72rem; letter-spacing: 0.1em; text-transform: uppercase; color: #f97316; font-weight: 800; margin-bottom: 6px; display: flex; justify-content: space-between; gap: 8px; }--.arena-session-topic-kicker span { color: var(--text-muted); letter-spacing: 0; text-transform: none; font-weight: 500; }--.arena-session-topic h2 { margin: 0 0 8px; font-size: clamp(1.15rem, 2.4vw, 1.6rem); line-height: 1.3; }--.arena-session-hint { color: var(--text-secondary); font-size: 0.9rem; margin: 0 0 12px; }.arena-progress { height: 14px; border-radius: 7px; background: var(--bg-tertiary); overflow: hidden; position: relative; }.arena-progress-fill { display: block; height: 100%; background: linear-gradient(90deg, #f97316, #fbbf24 70%, #22c55e); transition: width 0.8s cubic-bezier(0.2, 0.8, 0.3, 1); box-shadow: 0 0 12px rgba(249, 115, 22, 0.5); }.arena-progress-label { display: flex; justify-content: space-between; font-size: 0.75rem; color: var(--text-muted); margin-top: 5px; }--.arena-session-controls { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px; }--.arena-session-verdict { margin-top: 10px; font-size: 0.85rem; color: var(--text-secondary); display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }--.arena-session-verdict .is-hit { color: #22c55e; font-weight: 700; } .arena-session-verdict .is-miss { color: var(--text-muted); font-weight: 700; }--.arena-session-feed { padding: 12px 14px; border-radius: 14px; background: var(--bg-secondary); border: 1px dashed var(--border); min-height: 160px; max-height: 320px; overflow-y: auto; }--.arena-session-feed-head { display: flex; align-items: center; justify-content: space-between; font-size: 0.75rem; color: var(--text-muted); margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.06em; }--.arena-session-line { display: grid; grid-template-columns: 42px 1fr auto; gap: 8px; padding: 4px 0; font-size: 0.92rem; animation: arena-in 0.3s; align-items: baseline; }--.arena-session-line span:first-child { color: var(--text-muted); font-variant-numeric: tabular-nums; font-size: 0.75rem; }--.arena-session-line a { color: var(--text-muted); font-size: 0.75rem; text-decoration: none; } .arena-session-line a:hover { color: var(--accent-light); }--.arena-session-line.is-new { color: var(--text-primary); }--.arena-session-card { padding: 12px 14px; border-radius: 14px; background: var(--bg-card); border: 1px solid var(--border); }--.arena-session-card h3 { margin: 0 0 8px; font-size: 0.95rem; display: flex; align-items: center; gap: 8px; } .arena-session-card h3 i { color: #f97316; }--.arena-about { display: flex; flex-wrap: wrap; gap: 6px; }--.arena-about span { font-size: 0.75rem; padding: 3px 9px; border-radius: 999px; background: var(--bg-tertiary); border: 1px solid var(--border); color: var(--text-secondary); animation: arena-in 0.3s; }--.arena-about span.is-hit { border-color: color-mix(in srgb, #f97316 60%, var(--border)); color: var(--text-primary); }--.arena-cleared-list { display: flex; flex-direction: column; gap: 8px; }--.arena-cleared { padding: 10px 12px; border-radius: 10px; background: var(--bg-tertiary); border-left: 3px solid #22c55e; font-size: 0.85rem; }--.arena-cleared.is-skipped { border-left-color: var(--text-muted); opacity: 0.75; }--.arena-cleared b { display: block; margin-bottom: 2px; }--.arena-cleared q { color: var(--text-secondary); font-style: italic; }--.arena-cleared small { color: var(--text-muted); display: block; margin-top: 2px; }--.arena-session-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }--.arena-session-stat { padding: 8px 10px; border-radius: 10px; background: var(--bg-tertiary); text-align: center; }--.arena-session-stat b { display: block; font-size: 1.1rem; } .arena-session-stat span { font-size: 0.65rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; }.arena-levelup { position: fixed; left: 50%; top: 22%; transform: translateX(-50%); z-index: 3500; padding: 14px 26px; border-radius: 16px; background: linear-gradient(135deg, #f97316, #fbbf24); color: #111; font-weight: 900; font-size: 1.4rem; letter-spacing: 0.06em; box-shadow: 0 20px 60px rgba(249, 115, 22, 0.45); animation: arena-levelup 2.2s ease-out forwards; pointer-events: none; }@keyframes arena-levelup { 0% { transform: translate(-50%, 20px) scale(0.6); opacity: 0; } 15% { transform: translate(-50%, 0) scale(1.08); opacity: 1; } 80% { opacity: 1; } 100% { transform: translate(-50%, -30px) scale(1); opacity: 0; } }--@media (max-width: 900px) { .arena-session { grid-template-columns: 1fr; } .arena-session-stats { grid-template-columns: repeat(3, 1fr); } .arena-xp { width: 110px; } }--@media (max-width: 480px) { .arena-hero-actions { flex-direction: column; align-items: stretch; } }++++/* ── Arena v2: pulse, on the mic, beefs, the board, ladders, consoles ── */++.arena-lede { margin: 0; max-width: 720px; color: var(--text-secondary); font-size: 0.95rem; line-height: 1.5; }++.arena-hero-actions .arena-note { display: inline-flex; align-items: center; gap: 6px; }++.arena-pulse { margin: 4px 0 22px; }++.arena-pulse-inner { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; padding: 12px 16px; border-radius: 14px; border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--border)); background: linear-gradient(90deg, color-mix(in srgb, var(--accent) 18%, var(--bg-card)), var(--bg-card)); }++.arena-pulse-kicker { display: inline-flex; align-items: center; gap: 6px; font-weight: 800; letter-spacing: 0.08em; font-size: 0.72rem; text-transform: uppercase; color: var(--accent-light); }++.arena-pulse-text { flex: 1; min-width: 240px; font-size: 1.02rem; font-weight: 600; }++.arena-pulse-meta { font-size: 0.75rem; color: var(--text-muted); }++.arena-tag { display: inline-flex; align-items: center; gap: 5px; padding: 3px 8px; border-radius: 999px; font-size: 0.72rem; font-weight: 700; background: var(--bg-tertiary); color: var(--text-secondary); text-decoration: none; white-space: nowrap; }++a.arena-tag:hover { color: var(--accent-light); }++.arena-tag-hot { background: rgba(239, 68, 68, 0.16); color: #f87171; }++.arena-tag-gold { background: rgba(245, 197, 66, 0.16); color: #f5c542; }++.arena-tag-dim { opacity: 0.7; font-weight: 600; }++.arena-lvl { display: inline-flex; align-items: center; padding: 1px 7px; border-radius: 6px; font-size: 0.68rem; font-weight: 800; letter-spacing: 0.04em; background: linear-gradient(90deg, #f97316, #fbbf24); color: #1a1006; }++.arena-lvl-big { font-size: 0.85rem; padding: 4px 10px; }++.arena-chip { display: inline-flex; align-items: center; gap: 8px; color: var(--text-primary); text-decoration: none; min-width: 0; }++.arena-chip > span { display: flex; flex-direction: column; min-width: 0; line-height: 1.2; }++.arena-chip strong { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }++.arena-chip small { color: var(--text-muted); font-size: 0.74rem; display: flex; gap: 4px; align-items: center; flex-wrap: wrap; }++.arena-play { display: inline-flex; align-items: center; gap: 4px; color: var(--accent-light); text-decoration: none; font-size: 0.8rem; }++.arena-play:hover { text-decoration: underline; }++.arena-live-dot-sm { width: 7px; height: 7px; display: inline-block; margin-right: 4px; }++++/* on the mic now */++.arena-mic-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 12px; }++.arena-mic-card { display: grid; grid-template-columns: 112px 1fr; gap: 12px; padding: 10px; border-radius: 14px; background: var(--bg-card); border: 1px solid rgba(231, 76, 60, 0.45); }++.arena-mic-card.is-quiet { border-color: var(--border); }++.arena-mic-thumb { display: block; }++.arena-mic-thumb .arena-live-thumb { width: 112px; height: 63px; }++.arena-mic-body { display: flex; flex-direction: column; gap: 6px; min-width: 0; }++.arena-mic-head { display: flex; flex-direction: column; gap: 2px; }++.arena-mic-head a { color: var(--text-primary); text-decoration: none; }++.arena-mic-meta { font-size: 0.75rem; color: var(--text-secondary); display: flex; gap: 4px; align-items: center; flex-wrap: wrap; }++.arena-mic-line { font-size: 0.88rem; line-height: 1.35; quotes: "“" "”"; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }++.arena-mic-line-empty { color: var(--text-muted); font-style: italic; }++.arena-mic-tags { display: flex; gap: 6px; flex-wrap: wrap; }++++/* beefs */++.arena-beefs { margin: 8px 0 28px; }++.arena-beefs h2, .arena-board-head h2, .arena-ladder h2, .arena-live h2 { display: flex; align-items: center; gap: 10px; font-size: 1.15rem; margin: 0 0 12px; }++.arena-beefs h2 small, .arena-board-head h2 small, .arena-ladder h2 small { color: var(--text-muted); font-weight: 400; font-size: 0.82rem; }++.arena-beef-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); gap: 12px; }++.arena-beef { display: flex; flex-direction: column; gap: 10px; padding: 14px; border-radius: 16px; background: var(--bg-card); border: 1px solid var(--border); }++.arena-beef.is-open { border-color: rgba(239, 68, 68, 0.45); box-shadow: 0 0 0 1px rgba(239, 68, 68, 0.12), 0 10px 30px rgba(0, 0, 0, 0.25); }++.arena-beef.is-done { opacity: 0.85; }++.arena-beef-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 10px; flex-wrap: wrap; }++.arena-beef-head a { color: var(--text-primary); text-decoration: none; }++.arena-beef-headline { font-weight: 800; font-size: 1.02rem; line-height: 1.3; }++.arena-beef-tags { display: flex; gap: 6px; flex-wrap: wrap; }++.arena-beef-sides { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 10px; }++.arena-beef-side { display: flex; align-items: center; gap: 10px; color: var(--text-primary); text-decoration: none; min-width: 0; }++.arena-beef-side > span { display: flex; flex-direction: column; min-width: 0; }++.arena-beef-side strong { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }++.arena-beef-side small { color: var(--text-muted); font-size: 0.74rem; }++.arena-beef-side-b { flex-direction: row-reverse; text-align: right; }++.arena-beef-vs { font-weight: 900; font-size: 0.8rem; letter-spacing: 0.1em; color: var(--text-muted); }++.arena-tug { display: flex; height: 22px; border-radius: 11px; overflow: hidden; background: var(--bg-tertiary); font-size: 0.7rem; font-weight: 800; }++.arena-tug span { display: flex; align-items: center; padding: 0 8px; color: #fff; transition: width 0.6s cubic-bezier(0.2, 0.8, 0.2, 1); min-width: 0; overflow: hidden; }++.arena-tug-a { background: linear-gradient(90deg, color-mix(in srgb, var(--fc) 80%, #000), var(--fc)); justify-content: flex-start; }++.arena-tug-b { background: linear-gradient(90deg, var(--fc), color-mix(in srgb, var(--fc) 80%, #000)); justify-content: flex-end; }++.arena-tug-big { height: 34px; font-size: 0.9rem; }++.arena-beef-foot { display: flex; justify-content: space-between; align-items: center; gap: 10px; flex-wrap: wrap; }++.arena-beef-actions { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }++.arena-clock { display: inline-flex; align-items: center; gap: 6px; font-size: 0.8rem; color: var(--text-secondary); font-variant-numeric: tabular-nums; }++.arena-clock b { color: var(--text-primary); }++.arena-clock.is-urgent { color: #f87171; } .arena-clock.is-urgent b { color: #f87171; animation: arena-blink 1s steps(2) infinite; }++@keyframes arena-blink { 50% { opacity: 0.35; } }++.arena-sides-tally { font-size: 0.78rem; color: var(--text-muted); display: inline-flex; gap: 5px; align-items: center; }++.arena-beef-last { display: block; font-size: 0.85rem; color: var(--text-secondary); quotes: "“" "”"; border-left: 3px solid var(--border-light); padding-left: 10px; }++.arena-beef-empty { display: flex; align-items: center; gap: 14px; padding: 18px; border-radius: 14px; border: 1px dashed var(--border); color: var(--text-secondary); }++.arena-beef-empty i { font-size: 1.6rem; color: var(--accent); }++.arena-beef-empty p { margin: 0; }++.arena-done { margin-top: 12px; }++.arena-done summary { cursor: pointer; color: var(--text-secondary); font-weight: 600; margin-bottom: 10px; }++.arena-done summary small { color: var(--text-muted); font-weight: 400; }++.btn.is-hyped { color: #f87171; border-color: rgba(239, 68, 68, 0.5); }++++/* the board */++.arena-board { margin: 8px 0 28px; }++.arena-topic-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 12px; }++.arena-topic { display: flex; flex-direction: column; gap: 8px; padding: 14px; border-radius: 16px; background: var(--bg-card); border: 1px solid var(--border); position: relative; }++.arena-topic.is-hot { border-color: rgba(249, 115, 22, 0.55); box-shadow: 0 0 0 1px rgba(249, 115, 22, 0.12); }++.arena-topic-debate { border-left: 4px solid #60a5fa; }++.arena-topic-phrase { border-left: 4px solid #a78bfa; }++.arena-topic-bounty { border-left: 4px solid #f5c542; }++.arena-topic-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 0.75rem; color: var(--text-muted); }++.arena-kind { display: inline-flex; align-items: center; gap: 5px; font-weight: 800; letter-spacing: 0.06em; text-transform: uppercase; font-size: 0.68rem; color: var(--accent-light); }++.arena-heat { display: inline-flex; align-items: center; gap: 5px; font-weight: 700; color: var(--text-secondary); }++.arena-heat.is-hot { color: #f97316; }++.arena-heat-bar { width: 46px; height: 5px; border-radius: 3px; background: var(--bg-tertiary); overflow: hidden; display: inline-block; }++.arena-heat-bar span { display: block; height: 100%; background: linear-gradient(90deg, #f97316, #fbbf24); }++.arena-topic-by { margin-left: auto; }++.arena-topic-headline { font-weight: 800; font-size: 0.82rem; letter-spacing: 0.02em; color: #fbbf24; text-transform: uppercase; }++.arena-topic-link { color: var(--text-primary); text-decoration: none; }++.arena-topic-link:hover .arena-topic-text { color: var(--accent-light); }++.arena-topic-text { font-size: 1.05rem; font-weight: 700; line-height: 1.35; }++.arena-topic-text b { font-weight: 900; }++.arena-debate-title em { font-style: italic; color: var(--text-muted); font-weight: 600; padding: 0 4px; }++.arena-topic-hint { margin: 0; font-size: 0.85rem; color: var(--text-secondary); }++.arena-topic-foot { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: auto; }++.arena-topic-who { display: flex; align-items: center; gap: 4px; }++.arena-topic-who a { display: inline-flex; } .arena-topic-who a.is-dim { opacity: 0.55; }++.arena-topic-stats { font-size: 0.75rem; color: var(--text-muted); }++.arena-topic-actions { margin-left: auto; display: flex; gap: 6px; flex-wrap: wrap; }++.arena-debate { display: grid; grid-template-columns: 1fr auto 1fr; gap: 8px; align-items: center; }++.arena-debate .btn { white-space: normal; }++.arena-debate-tally { display: flex; flex-direction: column; align-items: center; gap: 3px; min-width: 90px; }++.arena-debate-bar { width: 90px; height: 6px; border-radius: 3px; background: #e74c3c; overflow: hidden; display: block; }++.arena-debate-bar span { display: block; height: 100%; background: #60a5fa; }++.arena-debate-nums { font-size: 0.72rem; color: var(--text-muted); }++.arena-debate-big { padding: 14px; border-radius: 14px; background: var(--bg-card); border: 1px solid var(--border); }++.arena-debate-big .arena-note { grid-column: 1 / -1; margin: 6px 0 0; }++.arena-resolved { display: flex; flex-direction: column; gap: 6px; }++.arena-resolved-row { display: flex; justify-content: space-between; gap: 10px; padding: 8px 12px; border-radius: 10px; background: var(--bg-card); border: 1px solid var(--border); font-size: 0.88rem; flex-wrap: wrap; }++.arena-resolved-row a { color: var(--text-primary); text-decoration: none; }++.arena-composer { display: flex; flex-direction: column; gap: 10px; max-width: 560px; }++.arena-composer textarea { width: 100%; padding: 10px 12px; border-radius: 10px; border: 1px solid var(--border); background: var(--bg-input); color: var(--text-primary); font: inherit; resize: vertical; }++.arena-composer-actions { display: flex; justify-content: flex-end; }++++/* ladders */++.arena-ladders { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin: 0 0 28px; }++.arena-ladder { padding: 14px; border-radius: 16px; background: var(--bg-card); border: 1px solid var(--border); }++.arena-mini-list { display: flex; flex-direction: column; gap: 6px; }++.arena-mini-row { display: grid; grid-template-columns: 28px 1fr auto; align-items: center; gap: 10px; padding: 6px 8px; border-radius: 10px; background: var(--bg-tertiary); }++.arena-mini-row .arena-rank { font-size: 1rem; }++.arena-rules { margin: 8px 0 30px; padding: 16px 18px; border-radius: 16px; background: var(--bg-card); border: 1px solid var(--border); }++.arena-rules h3 { margin: 0 0 8px; font-size: 1rem; }++.arena-rules ul, .arena-rules-list { margin: 0; padding-left: 18px; font-size: 0.86rem; color: var(--text-secondary); line-height: 1.5; }++.arena-rules code { font-size: 0.8em; background: var(--bg-tertiary); padding: 1px 5px; border-radius: 4px; }++++/* beef page */++.arena-beef-page { display: flex; flex-direction: column; gap: 16px; }++.arena-beef-hero h1 { margin: 6px 0; font-size: 1.7rem; line-height: 1.2; display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }++.arena-beef-opener { margin: 0; color: var(--text-secondary); font-size: 0.92rem; }++.arena-beef-opener q { quotes: "“" "”"; }++.arena-beef-tape { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }++.arena-beef-tape-side { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 16px; border-radius: 16px; background: var(--bg-card); border: 1px solid var(--border); text-align: center; }++.arena-beef-tape-side .arena-portrait-md { max-width: 180px; }++.arena-beef-tape-side h2 { margin: 0; font-size: 1.3rem; }++.arena-beef-tape-meta { font-size: 0.8rem; color: var(--text-secondary); display: flex; gap: 4px; align-items: center; }++.arena-beef-tape-nums { display: flex; gap: 14px; }++.arena-beef-tape-nums span { display: flex; flex-direction: column; align-items: center; line-height: 1.1; }++.arena-beef-tape-nums b { font-size: 1.3rem; } .arena-beef-tape-nums small { font-size: 0.65rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.06em; }++.arena-beef-tape-actions { display: flex; gap: 6px; flex-wrap: wrap; justify-content: center; }++.arena-beef-status { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; font-size: 0.85rem; }++.arena-beef-cols { display: grid; grid-template-columns: 1.4fr 1fr; gap: 16px; }++.arena-feed, .arena-beef-aside section, .arena-console-aside section, .arena-console-main { padding: 14px; border-radius: 16px; background: var(--bg-card); border: 1px solid var(--border); }++.arena-beef-aside, .arena-console-aside { display: flex; flex-direction: column; gap: 12px; }++.arena-feed h3, .arena-beef-aside h3, .arena-console-aside h3, .arena-console-main h3, .arena-angles h3, .arena-members h3, .arena-best-lines h3 { margin: 0 0 10px; font-size: 0.98rem; display: flex; align-items: center; gap: 8px; }++.arena-feed h3 small, .arena-beef-aside h3 small, .arena-console-main h3 small, .arena-angles h3 small, .arena-members h3 small, .arena-best-lines h3 small { color: var(--text-muted); font-weight: 400; font-size: 0.78rem; }++.arena-feed-line { display: flex; gap: 10px; padding: 8px 0; border-top: 1px solid var(--border); }++.arena-feed-line:first-of-type { border-top: 0; }++.arena-feed-line.is-b { flex-direction: row-reverse; text-align: right; }++.arena-feed-line.is-system { justify-content: center; text-align: center; color: var(--text-muted); font-size: 0.82rem; }++.arena-feed-body { display: flex; flex-direction: column; gap: 3px; min-width: 0; }++.arena-feed-body q { font-size: 0.95rem; quotes: "“" "”"; }++.arena-feed-body small { color: var(--text-muted); font-size: 0.74rem; display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }++.arena-feed-line.is-b .arena-feed-body small { justify-content: flex-end; }++.arena-feed-announcer { font-weight: 700; color: #fbbf24; font-size: 0.85rem; }++.arena-receipt { padding: 8px 0; border-top: 1px solid var(--border); }++.arena-receipt q { quotes: "“" "”"; font-size: 0.9rem; }++.arena-receipt small { display: block; color: var(--text-muted); font-size: 0.74rem; margin-top: 2px; }++.arena-receipt small a { color: var(--accent-light); }++++/* topic page */++.arena-topic-page { display: flex; flex-direction: column; gap: 12px; padding: 18px; border-radius: 18px; background: var(--bg-card); border: 1px solid var(--border); }++.arena-topic-page h1.arena-topic-text { margin: 0; font-size: 1.6rem; }++.arena-topic-result { padding: 10px 14px; border-radius: 12px; background: rgba(245, 197, 66, 0.12); color: #f5c542; font-weight: 700; }++.arena-bounty-target { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; padding: 10px 12px; border-radius: 12px; background: rgba(245, 197, 66, 0.08); }++.arena-topic-cta { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }++.arena-angle-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 10px; }++.arena-angle { display: flex; flex-direction: column; gap: 3px; padding: 10px 12px; border-radius: 12px; background: var(--bg-tertiary); font-size: 0.88rem; }++.arena-angle span { color: var(--text-muted); font-size: 0.78rem; }++.arena-member { display: grid; grid-template-columns: minmax(180px, 1fr) 1.2fr auto; align-items: center; gap: 12px; padding: 8px 0; border-top: 1px solid var(--border); }++.arena-member.is-active .arena-chip strong { color: #f87171; }++.arena-member-progress { display: flex; gap: 6px; }++.arena-member-progress .arena-progress { flex: 1; height: 10px; }++.arena-progress.is-cleared .arena-progress-fill { background: #22c55e; }++.arena-angle-list { display: flex; flex-direction: column; gap: 6px; margin-top: 8px; }++.arena-angle-row { display: grid; grid-template-columns: 70px 1fr; gap: 8px; align-items: center; font-size: 0.82rem; }++.arena-angle-row .arena-progress { height: 8px; }++.arena-angle-row.is-cleared { color: #22c55e; }++.arena-angle-row small { color: var(--text-muted); }++.arena-rivalries { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 10px; }++.arena-rivalry { display: flex; flex-direction: column; gap: 6px; padding: 10px 12px; border-radius: 12px; background: var(--bg-card); border: 1px solid var(--border); }++.arena-rivalry.is-open { border-color: rgba(239, 68, 68, 0.45); }++.arena-receipt-mini { display: block; font-size: 0.8rem; color: var(--text-secondary); quotes: "“" "”"; }++.arena-level-card { display: flex; flex-direction: column; gap: 8px; padding: 12px 14px; border-radius: 14px; background: var(--bg-tertiary); }++.arena-level-head { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }++.arena-level-nums { display: flex; gap: 18px; flex-wrap: wrap; }++.arena-level-nums span { display: flex; flex-direction: column; line-height: 1.1; }++.arena-level-nums b { font-size: 1.1rem; } .arena-level-nums small { font-size: 0.66rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.06em; }++++/* the ears (live console) */++.arena-console { display: flex; flex-direction: column; gap: 14px; }++.arena-console-head { display: grid; grid-template-columns: auto 1fr auto; gap: 14px; align-items: center; padding: 14px; border-radius: 16px; background: var(--bg-card); border: 1px solid var(--border); }++.arena-console-head h1 { margin: 0 0 4px; font-size: 1.4rem; display: flex; align-items: center; gap: 8px; }++.arena-console-status { font-size: 0.85rem; color: var(--text-secondary); display: flex; align-items: center; gap: 8px; }++.arena-console-status.is-on { color: #4ade80; }++.arena-console-level { display: flex; flex-direction: column; align-items: flex-end; gap: 4px; min-width: 200px; }++.arena-console-level .arena-xp-track { width: 100%; }++.arena-console-level small { font-size: 0.72rem; color: var(--text-muted); }++.arena-console-cols { display: grid; grid-template-columns: 1.4fr 1fr; gap: 14px; }++.arena-mic-line-row { display: grid; grid-template-columns: 52px 1fr auto; gap: 8px; align-items: baseline; padding: 4px 0; font-size: 0.9rem; border-top: 1px solid var(--border); }++.arena-mic-line-row:first-child { border-top: 0; }++.arena-mic-time { font-size: 0.72rem; color: var(--text-muted); font-variant-numeric: tabular-nums; }++.arena-judgement { margin-top: 10px; padding: 10px 12px; border-radius: 12px; background: var(--bg-tertiary); font-size: 0.85rem; color: var(--text-secondary); border-left: 3px solid var(--border-light); }++.arena-judgement.is-hit { border-left-color: #f97316; color: var(--text-primary); }++.arena-console-beef { display: flex; flex-direction: column; gap: 6px; padding: 8px 0; border-top: 1px solid var(--border); }++.arena-console-beef a { color: var(--text-primary); text-decoration: none; }++.arena-console-beef .arena-tug { height: 14px; font-size: 0.6rem; }++.arena-expires { font-variant-numeric: tabular-nums; }++++@media (max-width: 900px) {++ .arena-ladders, .arena-beef-cols, .arena-console-cols, .arena-beef-tape { grid-template-columns: 1fr; }++ .arena-console-head { grid-template-columns: auto 1fr; }++ .arena-console-level { grid-column: 1 / -1; align-items: stretch; }++ .arena-member { grid-template-columns: 1fr; gap: 6px; }++}++@media (max-width: 600px) {++ .arena-hero { padding: 14px 0 10px; } .arena-hero h1 { font-size: 1.5rem; }++ .arena-live h2, .arena-beefs h2, .arena-board-head h2, .arena-ladder h2 { flex-wrap: wrap; } .arena-live h2 small, .arena-board-head h2 small { flex-basis: 100%; }++ .arena-board-head { flex-direction: column; align-items: flex-start; }++ .arena-hero-actions { flex-direction: column; align-items: stretch; }++ .arena-pulse-inner { padding: 10px 12px; }++ .arena-mic-grid, .arena-beef-grid, .arena-topic-grid { grid-template-columns: 1fr; }++ .arena-mic-card { grid-template-columns: 1fr; }++ .arena-mic-thumb .arena-live-thumb { width: 100%; height: auto; aspect-ratio: 16 / 9; }++ .arena-beef-sides { grid-template-columns: 1fr; }++ .arena-beef-side-b { flex-direction: row; text-align: left; }++ .arena-beef-vs { display: none; }++ .arena-beef-foot, .arena-topic-foot { flex-direction: column; align-items: flex-start; }++ .arena-topic-actions { margin-left: 0; }++ .arena-debate { grid-template-columns: 1fr; }++ .arena-debate-tally { flex-direction: row; justify-content: center; }++ .arena-row { grid-template-columns: 28px 44px 1fr; grid-auto-rows: auto; gap: 8px 10px; padding: 10px; }++ .arena-row .arena-portrait-sm { width: 44px; height: 44px; font-size: 1.1rem; }++ .arena-row-stats { grid-column: 1 / -1; justify-content: space-between; }++ .arena-row-expand { grid-column: 1 / -1; }++ .arena-row-expand-actions { flex-wrap: wrap; }++ .arena-profile { grid-template-columns: 1fr; padding: 14px; }++ .arena-profile-stats { grid-template-columns: 1fr; }++ .arena-numbers { grid-template-columns: repeat(2, 1fr); }++ .arena-quips { grid-template-columns: 1fr; }++ .arena-beef-hero h1 { font-size: 1.25rem; }++ .arena-topic-page { padding: 14px; } .arena-topic-page h1.arena-topic-text { font-size: 1.25rem; }++ .arena-level-nums { gap: 12px; }++ .arena-lightbox-inner { padding: 14px; }++}++++.arena-leaderboard { margin: 0 0 28px; }++.arena-lore { display: flex; flex-direction: column; gap: 10px; }@@@ -62,7 +62,7 @@</script><!-- Stylesheets -->-- <link rel="stylesheet" href="/css/style.css?v=145">++ <link rel="stylesheet" href="/css/style.css?v=146"><link rel="stylesheet" href="/css/chat-shared.css?v=1"><link rel="stylesheet" href="/css/broadcast.css?v=11"><link rel="stylesheet" href="/css/cosmetics.css?v=2">@@@ -5107,7 +5107,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig<script src="/js/staff-console.js?v=1" defer></script><script src="/js/dashboard-moderation.js?v=5" defer></script><script src="/js/ai-viewers.js?v=2" defer></script>--<script src="/js/arena.js?v=4" defer></script>++<script src="/js/arena.js?v=5" 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>@@@ -1,22 +1,30 @@/**-- * OpenVibe.Live — Arena tab (streamer vs streamer)++ * OpenVibe.Live — Arena tab (speech-driven beefs + the community shit-talking board)** Routes (handled from app.js):-- * /arena main event + live matchups + leaderboard (rows expand)-- * /arena/<username> fighter profile (stats drill down, voice + quotes, portrait lightbox)-- * /arena/battle/<a>/<b> head-to-head battle (rounds, tale of the tape, walkouts, crowd vote)++ * /arena pulse · on the mic now · open beefs · the board · ladders++ * /arena/beef/<id> one beef: tug-of-war, clock, announcer feed, receipts, sides++ * /arena/topic/<id> one board event: angles, progress, best lines, sides/bounty/phrase++ * /arena/live/<username> the ears: what the listener hears from a live fighter (auto-refresh)++ * /arena/<username> fighter profile (stats drill down, voice + quotes, level, beefs, rivalries)*-- * Talks to /api/arena/* (server/arena). Everything renders with escaped template strings —-- * persona/quote text is AI-written or transcribed and must never be trusted as HTML.++ * Nothing here starts a fight. Beefs open when a streamer says another fighter's name on mic++ * while talking shit (server/arena/listener.js). Everything renders with escaped template++ * strings — persona/quote/headline text is AI-written or transcribed and is never trusted as HTML.*/'use strict';const ARENA_STATS = ['hype', 'grind', 'chat', 'loyalty', 'clutch', 'vibe', 'mic'];const ARENA_STAT_LABEL = { hype: 'Hype', grind: 'Grind', chat: 'Chat', loyalty: 'Loyalty', clutch: 'Clutch', vibe: 'Vibe', mic: 'Mic' };--let _arenaRoster = null; // cached leaderboard payload--let _arenaLiveTimer = null;++const ARENA_KIND = {++ topic: { label: 'Topic', icon: 'fa-comment-dots' },++ debate: { label: 'Debate', icon: 'fa-scale-unbalanced' },++ phrase: { label: 'Say it', icon: 'fa-quote-left' },++ bounty: { label: 'Bounty', icon: 'fa-sack-dollar' },++};++let _arenaRoster = null;++let _arenaTimers = [];let _arenaImagePoll = null;--let _arenaBattleTimers = [];let _arenaUtterance = null;function _aEsc(s) { return typeof esc === 'function' ? esc(String(s ?? '')) : String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); }@@@ -24,15 +32,24 @@ function _aNum(n) { n = Number(n) || 0; return n >= 1000 ? `${(n / 1000).toFixedfunction _aInitial(u) { return (u.display_name || u.username || '?').trim().charAt(0).toUpperCase(); }function _aStamp(sec) { sec = Math.max(0, Math.floor(sec || 0)); const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60; return (h ? `${h}:` : '') + `${h ? String(m).padStart(2, '0') : m}:${String(s).padStart(2, '0')}`; }function _aDate(d) { try { return new Date(String(d).replace(' ', 'T') + (String(d).endsWith('Z') ? '' : 'Z')).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); } catch { return String(d || ''); } }++function _aAgo(d) {++ if (!d) return '';++ const ms = Date.now() - new Date(String(d).includes('T') ? d : String(d).replace(' ', 'T') + 'Z').getTime();++ const m = Math.round(ms / 60000);++ if (m < 1) return 'just now'; if (m < 60) return `${m}m ago`; const h = Math.round(m / 60); if (h < 48) return `${h}h ago`; return `${Math.round(h / 24)}d ago`;++}++function _aClock(sec) { sec = Math.max(0, Math.floor(sec || 0)); if (sec >= 3600) return `${Math.floor(sec / 3600)}h ${String(Math.floor((sec % 3600) / 60)).padStart(2, '0')}m`; return `${Math.floor(sec / 60)}:${String(sec % 60).padStart(2, '0')}`; }++function _aToast(msg, type = 'info') { if (typeof toast === 'function') toast(msg, type); else console.log('[Arena]', msg); }++function _aMe() { return (typeof currentUser !== 'undefined' && currentUser) ? currentUser : null; }++function _aSpinner(text) { return `<div class="arena-loading"><i class="fa-solid fa-circle-notch fa-spin"></i><span>${_aEsc(text)}</span></div>`; }function _aStopTimers() {-- if (_arenaLiveTimer) { clearInterval(_arenaLiveTimer); _arenaLiveTimer = null; }++ for (const t of _arenaTimers) { clearInterval(t); clearTimeout(t); }++ _arenaTimers = [];if (_arenaImagePoll) { clearInterval(_arenaImagePoll); _arenaImagePoll = null; }-- for (const t of _arenaBattleTimers) clearTimeout(t);-- _arenaBattleTimers = [];_aStopSpeaking();-- if (typeof _aTalkStop === 'function') _aTalkStop();}++function _aEvery(ms, fn) { const t = setInterval(() => { if (typeof currentPage !== 'undefined' && currentPage !== 'arena') return _aStopTimers(); fn(); }, ms); _arenaTimers.push(t); return t; }// ── Speech: hear the taunt / quotes read out (browser TTS, no server cost) ──function _aStopSpeaking() {@@@ -61,26 +78,13 @@ function _aBindSpeak(root) {/** 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 u = f.user || f;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>`;-- }++ 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, { clickable = false } = {}) {-- return `<div class="arena-bars">${ARENA_STATS.map(k => `-- <div class="arena-bar ${clickable ? 'is-clickable' : ''}" data-stat="${k}" title="${_aEsc(ARENA_STAT_LABEL[k])}">-- <span class="arena-bar-label">${_aEsc(ARENA_STAT_LABEL[k])}</span>-- <span class="arena-bar-track"><span class="arena-bar-fill" style="width:${Math.max(0, Math.min(100, r[k] || 0))}%"></span></span>-- <span class="arena-bar-val">${r[k] ?? '–'}</span>-- </div>`).join('')}</div>`;--}--/** Heptagon radar chart as inline SVG (no library). */function _aRadar(r, color, size = 200) {const c = size / 2, R = size / 2 - 28;@@@ -119,8 +123,16 @@ function _aSpark(series, color) {function _aChannelLink(u) { return `/${encodeURIComponent(u.username)}`; }function _aFighterLink(u) { return `/arena/${encodeURIComponent(u.username)}`; }--function _aBattleLink(a, b) { return `/arena/battle/${encodeURIComponent(a.username)}/${encodeURIComponent(b.username)}`; }--function _aA(href, inner, cls = '') { return `<a class="${cls}" href="${_aEsc(href)}" onclick="return handleLinkClick(event, '${_aEsc(href)}')">${inner}</a>`; }++function _aConsoleLink(u) { return `/arena/live/${encodeURIComponent(u.username)}`; }++function _aBeefLink(b) { return `/arena/beef/${b.id}`; }++function _aTopicLink(t) { return `/arena/topic/${t.id}`; }++function _aVodLink(vodId, sec) { return vodId ? `/vod/${vodId}?t=${Math.max(0, Math.floor(sec || 0))}` : null; }++function _aA(href, inner, cls = '', title = '') { return `<a class="${cls}" href="${_aEsc(href)}" ${title ? `title="${_aEsc(title)}"` : ''} onclick="return handleLinkClick(event, '${_aEsc(href)}')">${inner}</a>`; }++function _aPlay(vodId, sec, label = '') { const h = _aVodLink(vodId, sec); return h ? _aA(h, `<i class="fa-solid fa-play"></i>${label ? ` ${_aEsc(label)}` : ''}`, 'arena-play', 'Hear them say it (jumps to the VOD)') : ''; }++function _aLevelPill(level) { return `<span class="arena-lvl" title="Trash Level — earned by talking shit on the board and in beefs">TL ${_aEsc(level ?? 1)}</span>`; }++function _aBriefChip(f, extra = '') {++ return _aA(_aFighterLink(f.user), `${_aPortrait(f, 'xs')}<span><strong>${_aEsc(f.fighter_name)}${f.live ? ' <span class="arena-live-pill">LIVE</span>' : ''}</strong><small>${f.rank ? `#${f.rank} · ` : ''}${_aLevelPill(f.level)}${extra}</small></span>`, 'arena-chip');++}// ── Page entry ───────────────────────────────────────────────@@@ -128,128 +140,302 @@ async function loadArenaPage(segments = []) {_aStopTimers();const root = document.getElementById('arena-root');if (!root) return;-- const [, first, second, third] = segments;++ const [, first, second] = segments;try {-- if (first === 'battle' && second && third) return await _aRenderBattle(root, second, third);-- if (first === 'talk') return second ? await _aRenderSession(root, second) : await _aRenderTalk(root);-- if (first && first !== 'battle') return await _aRenderFighter(root, first);++ if (first === 'beef' && second) return await _aRenderBeef(root, Number(second));++ if (first === 'topic' && second) return await _aRenderTopic(root, Number(second));++ if (first === 'live' && second) return await _aRenderConsole(root, second);++ if (first) 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: main event + live matchups + leaderboard ───────────++// ── Home ─────────────────────────────────────────────────────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 = _aSpinner('Reading the room…');++ const [boardData, beefs, live, roster] = await Promise.all([api('/arena/board'), api('/arena/beefs'), api('/arena/live').catch(() => ({ live: [] })), api('/arena/fighters')]);++ _arenaRoster = roster;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. Tap a fighter to expand, pick two to fight, and let chat decide the last round.</p>++ <h1><i class="fa-solid fa-hand-fist"></i> Arena</h1>++ <p class="arena-lede">Nobody clicks "fight" here. Say a fighter's name on mic while you're talking shit and the beef opens itself. Talk on a board topic and your Trash Level climbs. Chat picks sides, hypes, starts topics and posts bounties.</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>'}++ ${_aMe() ? `<button class="btn btn-primary" id="arena-new-topic-btn"><i class="fa-solid fa-plus"></i> Start a topic</button>` : ''}++ <span class="arena-note">${roster.ai ? '<i class="fa-solid fa-ear-listen"></i> AI ears on' : '<i class="fa-solid fa-ear-deaf"></i> AI off — keyword judging'}</span></div></div>-- <section id="arena-main-event" class="arena-main-event" style="display:none"></section>-- <section id="arena-talk-card" class="arena-talk-card" style="display:none"></section>-- <section id="arena-live" class="arena-live"></section>-- <section class="arena-board">++ <section class="arena-pulse" id="arena-pulse">${_aPulse(boardData.pulse)}</section>++ <section class="arena-live" id="arena-live">${_aRenderLive(live.live || [])}</section>++ <section class="arena-beefs" id="arena-beefs">${_aRenderBeefs(beefs)}</section>++ <section class="arena-board" id="arena-board">${_aRenderBoard(boardData)}</section>++ <section class="arena-ladders">++ <div class="arena-ladder">++ <h2><i class="fa-solid fa-fire"></i> Trash Level ladder <small>XP from beef hits, cleared angles, conquered topics, chat hype</small></h2>++ ${_aLevelsList(boardData.levels || [])}++ </div>++ <div class="arena-ladder">++ <h2><i class="fa-solid fa-crown"></i> Chat clout <small>viewers who pick the winning side</small></h2>++ ${_aCloutList(boardData.clout || [])}++ </div>++ </section>++ <section class="arena-leaderboard"><div class="arena-board-head">-- <h2>Leaderboard</h2>-- <input id="arena-search" type="search" placeholder="Find a fighter…" autocomplete="off">++ <h2><i class="fa-solid fa-ranking-star"></i> Power ladder <small>${roster.fighters.length} fighters · stats from real streams</small></h2>++ <input type="search" id="arena-search" placeholder="Find a fighter…" autocomplete="off"></div><div class="arena-list" id="arena-list"></div>++ </section>++ <section class="arena-rules">++ <h3>How the Arena works</h3>++ <ul>++ <li><b>Beefs.</b> Say another fighter's name on stream while talking shit → a beef opens and they go on the clock (15 min if they're live, 24 h if not; the clock resets every time a side answers). Silence is a forfeit. Best mouth after 24 h wins. Beat someone ranked 4+ spots above you and it's an <b>upset</b>.</li>++ <li><b>The board.</b> Topics, debates, phrase challenges and bounties come from chat (<code>!topic</code>, <code>!bounty</code>) and from the community pulse — the AI reads global chat, transcripts and the AI timeline every 30 min. Click a topic, talk on it, clear its angles → XP → Trash Level.</li>++ <li><b>Chat.</b> <code>!hype</code> boosts your streamer · <code>!side <name></code> picks a side (clout if you're right) · <code>!topic <text></code> · <code>!bounty <user></code> · <code>!beef</code> · <code>!board</code> · <code>!arena</code>.</li>++ <li><b>Language.</b> Nothing gets censored for being offensive. The only lines that don't count: real threats, anything sexual about minors, and doxxing.</li>++ </ul></section>`;-- _aRenderLive(live);-- _aRenderList(fighters);-- api('/arena/main-event').then(me => _aRenderMainEvent(me)).catch(() => {});-- api('/arena/talk?generate=0').then(t => _aRenderTalkCard(t)).catch(() => {});-- document.getElementById('arena-search').addEventListener('input', (e) => {++ _aRenderList(roster.fighters);++ _aBindHome(root);++ document.getElementById('arena-search')?.addEventListener('input', (e) => {const q = e.target.value.trim().toLowerCase();-- _aRenderList(q ? fighters.filter(f => [f.user.username, f.user.display_name, f.persona.fighter_name, f.persona.class, f.persona.element].some(s => String(s || '').toLowerCase().includes(q))) : fighters);++ _aRenderList(!q ? roster.fighters : roster.fighters.filter(f => [f.persona.fighter_name, f.user.display_name, f.user.username, f.persona.class, f.persona.element].filter(Boolean).some(s => s.toLowerCase().includes(q))));});-- 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(_aBattleLink(a.user, b.user));++ document.getElementById('arena-new-topic-btn')?.addEventListener('click', () => _aTopicComposer());++ _aEvery(20000, async () => {++ try {++ const [b, l, bd] = await Promise.all([api('/arena/beefs'), api('/arena/live'), api('/arena/board')]);++ const beefsEl = document.getElementById('arena-beefs'), liveEl = document.getElementById('arena-live'), boardEl = document.getElementById('arena-board'), pulseEl = document.getElementById('arena-pulse');++ if (beefsEl) beefsEl.innerHTML = _aRenderBeefs(b);++ if (liveEl) liveEl.innerHTML = _aRenderLive(l.live || []);++ if (boardEl) boardEl.innerHTML = _aRenderBoard(bd);++ if (pulseEl) pulseEl.innerHTML = _aPulse(bd.pulse);++ _aBindHome(root);++ } catch { /* keep the last render */ }});-- _arenaLiveTimer = setInterval(async () => {-- if (currentPage !== 'arena' || !document.getElementById('arena-live')) return _aStopTimers();-- try { _aRenderLive(await api('/arena/live')); } catch { /* keep last */ }-- }, 30000);--}----function _aRenderMainEvent(me) {-- const el = document.getElementById('arena-main-event');-- if (!el || !me || !me.a) return;-- const side = (f, s) => _aA(_aBattleLink(me.a.user, me.b.user), `${_aPortrait(f, 'sm')}<div><strong>${_aEsc(f.persona.fighter_name)}</strong><span>${_aEsc(f.user.display_name)} · #${f.rank} · PWR ${f.ratings.power}</span></div>`, `arena-me-side is-${s}`);-- const render = (votes, outcome, yourVote) => {-- el.style.display = '';-- el.innerHTML = `-- <div class="arena-main-event-head">-- <h2><i class="fa-solid fa-star"></i> Main event · ${_aEsc(me.day)}</h2>-- ${_aA(_aBattleLink(me.a.user, me.b.user), '<i class="fa-solid fa-play"></i> Watch the fight', 'btn btn-ghost')}-- </div>-- <div class="arena-main-event-body">-- ${side(me.a, 'a')}-- <div class="arena-me-center">-- <div class="arena-me-score">${outcome.a} – ${outcome.b}</div>-- <div class="arena-me-vote">-- <button data-side="a" class="${yourVote === 'a' ? 'is-picked' : ''}">Back ${_aEsc(me.a.persona.fighter_name.split(' ')[0])}</button>-- <button data-side="b" class="${yourVote === 'b' ? 'is-picked' : ''}">Back ${_aEsc(me.b.persona.fighter_name.split(' ')[0])}</button>-- </div>-- <div class="arena-me-tally">${votes.a + votes.b} vote${votes.a + votes.b === 1 ? '' : 's'} · ${outcome.winner ? `${_aEsc((outcome.winner === 'a' ? me.a : me.b).persona.fighter_name)} leads` : 'dead even'}</div>-- </div>-- ${side(me.b, 'b')}-- </div>-- <p class="arena-me-intro"><i class="fa-solid fa-microphone-lines"></i> ${_aEsc(me.commentary?.intro || '')}</p>`;-- el.querySelectorAll('.arena-me-vote button').forEach(btn => btn.addEventListener('click', async () => {-- btn.disabled = true;-- try { const r = await api(`/arena/battle/${encodeURIComponent(me.a.user.username)}/${encodeURIComponent(me.b.user.username)}/vote`, { method: 'POST', body: { side: btn.dataset.side } }); render(r.votes, r.outcome, r.your_side); }-- catch (err) { btn.disabled = false; if (typeof showToast === 'function') showToast(err?.message || 'Vote failed', 'error'); }-- }));-- };-- render(me.votes, me.outcome, me.your_vote || null);++ _aEvery(1000, () => _aTickClocks(root));++}++++function _aPulse(p) {++ if (!p || !p.text) return `<div class="arena-pulse-inner"><span class="arena-pulse-kicker"><i class="fa-solid fa-heart-pulse"></i> Pulse</span><span class="arena-pulse-text">The community is quiet. Start something.</span></div>`;++ return `<div class="arena-pulse-inner">++ <span class="arena-pulse-kicker"><i class="fa-solid fa-heart-pulse fa-beat"></i> Pulse</span>++ <span class="arena-pulse-text">${_aEsc(p.text)}</span>++ <span class="arena-pulse-meta">${p.sources ? _aEsc(p.sources) + ' · ' : ''}read ${_aEsc(_aAgo(p.at))}</span>++ </div>`;}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="${_aEsc(_aChannelLink(f.user))}" onclick="return handleLinkClick(event, '${_aEsc(_aChannelLink(f.user))}')">-- ${f.thumbnail_url ? `<img class="arena-live-thumb" src="${_aEsc(f.thumbnail_url)}?t=${Date.now()}" alt="">` : _aPortrait(f, 'sm')}-- <div class="arena-live-meta">-- <strong>${_aEsc(f.persona.fighter_name)}</strong>-- <span>${_aEsc(f.user.display_name)} · <i class="fa-solid fa-eye"></i> ${_aNum(f.stream.viewer_count)} · PWR ${f.ratings.power}</span>-- ${f.hot_mic ? `<span title="Hot mic — last thing the transcript heard"><i class="fa-solid fa-microphone"></i> “${_aEsc(f.hot_mic.text.length > 70 ? f.hot_mic.text.slice(0, 70) + '…' : f.hot_mic.text)}”</span>` : ''}++ if (!live.length) return `<h2><span class="arena-live-dot"></span> On the mic now <small>nobody from the roster is live</small></h2><p class="arena-note">When a fighter goes live with transcription on, the Arena listens for name-drops and board topics.</p>`;++ return `<h2><span class="arena-live-dot"></span> On the mic now <small>${live.length} live · the Arena is listening</small></h2>++ <div class="arena-mic-grid">${live.map(f => `++ <div class="arena-mic-card ${f.transcribed ? '' : 'is-quiet'}" style="--fc:${_aEsc(f.user.profile_color || '#8b5cf6')}">++ ${_aA(_aChannelLink(f.user), f.thumbnail_url ? `<img class="arena-live-thumb" src="${_aEsc(f.thumbnail_url)}" alt="">` : _aPortrait(f, 'sm'), 'arena-mic-thumb')}++ <div class="arena-mic-body">++ <div class="arena-mic-head">++ ${_aA(_aFighterLink(f.user), `<strong>${_aEsc(f.persona.fighter_name)}</strong>`)}++ <span class="arena-mic-meta">#${f.rank} · PWR ${f.ratings.power} · ${_aLevelPill(f.level)} · <i class="fa-solid fa-eye"></i> ${_aNum(f.stream.viewer_count)}</span>++ </div>++ ${f.hot_mic ? `<q class="arena-mic-line">${_aEsc(f.hot_mic.text)}</q>` : `<span class="arena-mic-line arena-mic-line-empty">${f.transcribed ? 'listening…' : 'no transcript yet — mic stats need transcription on'}</span>`}++ <div class="arena-mic-tags">++ ${f.active_topic ? _aA(_aTopicLink(f.active_topic), `<i class="fa-solid fa-comment-dots"></i> on: ${_aEsc(f.active_topic.text)}`, 'arena-tag') : '<span class="arena-tag arena-tag-dim">no board topic</span>'}++ ${f.open_beefs ? `<span class="arena-tag arena-tag-hot"><i class="fa-solid fa-fire"></i> ${f.open_beefs} beef${f.open_beefs > 1 ? 's' : ''} open</span>` : ''}++ ${_aA(_aConsoleLink(f.user), '<i class="fa-solid fa-ear-listen"></i> ears', 'arena-tag')}++ </div></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')}-- ${_aA(_aBattleLink(m.a.user, m.b.user), 'VS<small>fight</small>', 'arena-live-vs')}-- ${card(m.b, 'b')}-- </div>`).join('')}-- ${live.waiting ? `<div class="arena-live-match arena-live-waiting">${card(live.waiting, 'a')}<div class="arena-live-vs arena-live-vs-idle">?<small>waiting for a challenger</small></div></div>` : ''}++ </div>`).join('')}</div>`;++}++++// ── Beefs ────────────────────────────────────────────────────++++function _aTug(b, { big = false } = {}) {++ const ca = b.a.user.profile_color || '#8b5cf6', cb = b.b.user.profile_color || '#e74c3c';++ return `<div class="arena-tug ${big ? 'arena-tug-big' : ''}" title="${_aEsc(`${b.a.fighter_name} ${b.a.total} — ${b.b.total} ${b.b.fighter_name}`)}">++ <span class="arena-tug-a" style="width:${b.share_a}%;--fc:${_aEsc(ca)}"><b>${b.share_a >= 12 ? `${b.share_a}%` : ''}</b></span>++ <span class="arena-tug-b" style="width:${100 - b.share_a}%;--fc:${_aEsc(cb)}"><b>${100 - b.share_a >= 12 ? `${100 - b.share_a}%` : ''}</b></span>++ </div>`;++}++function _aClockTag(b) {++ if (b.status !== 'open' || !b.on_clock) return '';++ const who = b.on_clock === 'a' ? b.a : b.b;++ return `<span class="arena-clock ${b.clock_seconds_left < 120 ? 'is-urgent' : ''}" data-until="${_aEsc(b.clock_until)}" data-who="${_aEsc(who.fighter_name)}"><i class="fa-solid fa-stopwatch"></i> ${_aEsc(who.fighter_name)} has <b>${_aClock(b.clock_seconds_left)}</b> to answer${b.clock_is_live_window ? '' : ' (offline clock)'}</span>`;++}++function _aTickClocks(root) {++ root.querySelectorAll('.arena-clock[data-until]').forEach(el => {++ const left = Math.max(0, Math.round((Date.parse(el.dataset.until) - Date.now()) / 1000));++ const b = el.querySelector('b'); if (b) b.textContent = _aClock(left);++ el.classList.toggle('is-urgent', left < 120);++ if (left === 0) el.innerHTML = `<i class="fa-solid fa-hourglass-end"></i> ${_aEsc(el.dataset.who)} ran out of time…`;++ });++}++function _aBeefTags(b) {++ const tags = [];++ if (b.rematch) tags.push('<span class="arena-tag arena-tag-hot"><i class="fa-solid fa-rotate-left"></i> REMATCH</span>');++ if (b.bounty) tags.push('<span class="arena-tag arena-tag-gold"><i class="fa-solid fa-sack-dollar"></i> BOUNTY</span>');++ if (b.upset) tags.push('<span class="arena-tag arena-tag-hot"><i class="fa-solid fa-bolt"></i> UPSET</span>');++ if (b.streaks?.a >= 2) tags.push(`<span class="arena-tag">${_aEsc(b.a.fighter_name)} on a ${b.streaks.a}-beef streak</span>`);++ if (b.streaks?.b >= 2) tags.push(`<span class="arena-tag">${_aEsc(b.b.fighter_name)} on a ${b.streaks.b}-beef streak</span>`);++ if (b.history?.fights) tags.push(`<span class="arena-tag arena-tag-dim">history ${b.history.wins_1}–${b.history.wins_2} in ${b.history.fights}</span>`);++ return tags.join('');++}++function _aBeefCard(b) {++ const open = b.status === 'open';++ const winner = !open && b.winner_user_id ? (b.winner_user_id === b.a.user.id ? b.a : b.b) : null;++ return `<div class="arena-beef ${open ? 'is-open' : 'is-done'}" data-beef="${b.id}">++ <div class="arena-beef-head">++ ${_aA(_aBeefLink(b), `<span class="arena-beef-headline">${_aEsc((open ? b.headline : b.result_headline || b.headline) || `${b.a.fighter_name} vs ${b.b.fighter_name}`)}</span>`)}++ <span class="arena-beef-tags">${_aBeefTags(b)}${!open ? `<span class="arena-tag ${winner ? 'arena-tag-gold' : 'arena-tag-dim'}">${winner ? `${_aEsc(winner.fighter_name)} won${b.resolution === 'forfeit' ? ' by forfeit' : ''}` : 'draw'}</span>` : ''}</span>++ </div>++ <div class="arena-beef-sides">++ ${_aA(_aFighterLink(b.a.user), `${_aPortrait(b.a, 'sm')}<span><strong>${_aEsc(b.a.fighter_name)}${b.a.live ? ' <span class="arena-live-pill">LIVE</span>' : ''}</strong><small>#${b.a.rank || '–'} · ${b.a.hits} hits · crowd ${b.a.crowd}/${b.rules.crowd_max}</small></span>`, 'arena-beef-side arena-beef-side-a')}++ <span class="arena-beef-vs">${open ? 'VS' : 'FINAL'}</span>++ ${_aA(_aFighterLink(b.b.user), `${_aPortrait(b.b, 'sm')}<span><strong>${_aEsc(b.b.fighter_name)}${b.b.live ? ' <span class="arena-live-pill">LIVE</span>' : ''}</strong><small>#${b.b.rank || '–'} · ${b.b.hits} hits · crowd ${b.b.crowd}/${b.rules.crowd_max}</small></span>`, 'arena-beef-side arena-beef-side-b')}++ </div>++ ${_aTug(b)}++ <div class="arena-beef-foot">++ ${open ? _aClockTag(b) : `<span class="arena-note">ended ${_aEsc(_aAgo(b.resolved_at))}</span>`}++ ${open ? `<span class="arena-beef-actions">++ <button class="btn btn-ghost btn-sm arena-hype-btn" data-beef="${b.id}" data-side="a" title="Hype ${_aEsc(b.a.fighter_name)}"><i class="fa-solid fa-fire"></i> ${_aEsc(b.a.fighter_name)}</button>++ <button class="btn btn-ghost btn-sm arena-hype-btn" data-beef="${b.id}" data-side="b" title="Hype ${_aEsc(b.b.fighter_name)}"><i class="fa-solid fa-fire"></i> ${_aEsc(b.b.fighter_name)}</button>++ <span class="arena-sides-tally" title="who chat thinks wins"><i class="fa-solid fa-people-group"></i> ${b.sides.a}–${b.sides.b}</span>++ </span>` : ''}++ </div>++ ${b.feed?.length ? `<q class="arena-beef-last">${_aEsc(b.feed[b.feed.length - 1].announcer || b.feed[b.feed.length - 1].text || '')}</q>` : ''}++ </div>`;++}++function _aRenderBeefs(beefs) {++ const open = beefs.open || [], done = beefs.resolved || [];++ return `<h2><i class="fa-solid fa-fire-flame-curved"></i> Beefs <small>${open.length ? `${open.length} open` : 'none open — say a name on mic'}</small></h2>++ ${open.length ? `<div class="arena-beef-grid">${open.map(_aBeefCard).join('')}</div>` : `<div class="arena-beef-empty"><i class="fa-solid fa-microphone-lines"></i><p>No beef right now. A streamer only has to say another fighter's name while talking shit — the ears do the rest.</p></div>`}++ ${done.length ? `<details class="arena-done"><summary>Settled beefs <small>${done.length}</small></summary><div class="arena-beef-grid">${done.slice(0, 6).map(_aBeefCard).join('')}</div></details>` : ''}`;++}++++async function _aHype(beefId, side, btn) {++ try {++ const r = await api(`/arena/beefs/${beefId}/hype`, { method: 'POST', body: { side } });++ _aToast(r.added ? `🔥 Hyped! crowd ${r.crowd}/10 (${r.hypers} hyping)` : `You already hyped this side (${r.hypers} hyping)`, r.added ? 'success' : 'info');++ if (btn) btn.classList.add('is-hyped');++ } catch (err) { _aToast(err?.message || 'Hype failed', 'error'); }++}++async function _aSide(beefId, side) {++ try { const r = await api(`/arena/beefs/${beefId}/side`, { method: 'POST', body: { side } }); _aToast(`🗳️ Side picked. Crowd: ${r.a}–${r.b}. Pick right → clout.`, 'success'); return r; }++ catch (err) { _aToast(err?.message || 'Failed', 'error'); }++}++function _aBindHome(root) {++ root.querySelectorAll('.arena-hype-btn').forEach(btn => btn.onclick = (e) => { e.preventDefault(); _aHype(btn.dataset.beef, btn.dataset.side, btn); });++ root.querySelectorAll('.arena-topic-hype').forEach(btn => btn.onclick = async (e) => {++ e.preventDefault();++ try { const r = await api(`/arena/board/topics/${btn.dataset.topic}/hype`, { method: 'POST', body: { user_id: Number(btn.dataset.user) } }); _aToast(r.added ? `🔥 Hyped (${r.hypers} hyping)` : 'Already hyped', r.added ? 'success' : 'info'); btn.classList.add('is-hyped'); }++ catch (err) { _aToast(err?.message || 'Failed', 'error'); }++ });++ root.querySelectorAll('.arena-debate-side').forEach(btn => btn.onclick = async (e) => {++ e.preventDefault();++ try { const r = await api(`/arena/board/topics/${btn.dataset.topic}/side`, { method: 'POST', body: { side: btn.dataset.side } }); _aToast(`🗳️ You're with ${btn.dataset.label}. ${r.a}–${r.b}`, 'success'); const t = btn.closest('.arena-topic'); if (t) { const tally = t.querySelector('.arena-debate-tally'); if (tally) tally.innerHTML = _aDebateTally(r); } }++ catch (err) { _aToast(err?.message || 'Failed', 'error'); }++ });++ root.querySelectorAll('.arena-topic-join').forEach(btn => btn.onclick = async (e) => {++ e.preventDefault();++ try { await api(`/arena/board/topics/${btn.dataset.topic}/join`, { method: 'POST' }); _aToast('🎤 You\'re on it. Start talking on stream — the ears are listening.', 'success'); navigate(`/arena/topic/${btn.dataset.topic}`); }++ catch (err) { _aToast(err?.message || 'Failed', 'error'); }++ });++ _aBindSpeak(root);++}++++// ── Board ────────────────────────────────────────────────────++++function _aDebateTally(s) { return `<span class="arena-debate-bar"><span style="width:${s.share_a}%"></span></span><span class="arena-debate-nums">${s.a} · ${s.b}</span>`; }++function _aHeat(t, hotThreshold) {++ const pct = Math.min(100, Math.round((t.heat / Math.max(hotThreshold * 2, 1)) * 100));++ return `<span class="arena-heat ${t.hot ? 'is-hot' : ''}" title="heat: talk + hype + joins in the last hours"><i class="fa-solid fa-fire"></i> ${t.hot ? 'HOT' : ''} ${t.heat}<span class="arena-heat-bar"><span style="width:${pct}%"></span></span></span>`;++}++function _aTopicTitle(t) {++ if (t.kind === 'debate') return `<span class="arena-debate-title"><b>${_aEsc(t.side_a)}</b> <em>vs</em> <b>${_aEsc(t.side_b)}</b></span>`;++ if (t.kind === 'phrase') return `Work “<b>${_aEsc(t.phrase)}</b>” into your stream`;++ if (t.kind === 'bounty') return `Bounty on <b>${_aEsc(t.target?.fighter_name || '?')}</b> — double XP for talking shit about them`;++ return _aEsc(t.text);++}++function _aTopicCard(t, hot, { me } = {}) {++ const k = ARENA_KIND[t.kind] || ARENA_KIND.topic;++ const onIt = me && t.talking_now.some(f => f.user.id === me.id);++ const onRoster = me && _arenaRoster?.fighters?.some(f => f.user.id === me.id);++ return `<div class="arena-topic arena-topic-${t.kind} ${t.hot ? 'is-hot' : ''}" data-topic="${t.id}">++ <div class="arena-topic-head">++ <span class="arena-kind"><i class="fa-solid ${k.icon}"></i> ${k.label}</span>++ ${_aHeat(t, hot)}++ <span class="arena-topic-by">${t.created_by === 'ai' ? '<i class="fa-solid fa-heart-pulse"></i> pulse' : `<i class="fa-solid fa-user"></i> ${_aEsc(t.creator_name || t.created_by)}`}${t.expires_at ? ` · <span class="arena-expires" data-until="${_aEsc(t.expires_at)}">${_aEsc(_aClock((Date.parse(t.expires_at) - Date.now()) / 1000))} left</span>` : ''}</span>++ </div>++ ${t.headline ? `<div class="arena-topic-headline">${_aEsc(t.headline)}</div>` : ''}++ ${_aA(_aTopicLink(t), `<div class="arena-topic-text">${_aTopicTitle(t)}</div>`, 'arena-topic-link')}++ ${t.hint ? `<p class="arena-topic-hint">${_aEsc(t.hint)}</p>` : ''}++ ${t.kind === 'debate' ? `<div class="arena-debate">++ <button class="btn btn-ghost btn-sm arena-debate-side" data-topic="${t.id}" data-side="a" data-label="${_aEsc(t.side_a)}">${_aEsc(t.side_a)}</button>++ <span class="arena-debate-tally">${_aDebateTally(t.sides || { a: 0, b: 0, share_a: 50 })}</span>++ <button class="btn btn-ghost btn-sm arena-debate-side" data-topic="${t.id}" data-side="b" data-label="${_aEsc(t.side_b)}">${_aEsc(t.side_b)}</button>++ </div>` : ''}++ <div class="arena-topic-foot">++ <span class="arena-topic-who">${t.talking_now.length ? `<span class="arena-live-dot arena-live-dot-sm"></span> ${t.talking_now.map(f => _aA(_aFighterLink(f.user), _aPortrait(f, 'xs'), '', `${f.fighter_name} is on this`)).join('')}` : ''}${t.members.filter(m => !m.active).slice(0, 6).map(m => _aA(_aFighterLink(m.user), _aPortrait(m, 'xs'), 'is-dim', `${m.fighter_name} · ${m.cleared}/${t.angles.length || 3} angles`)).join('')}</span>++ <span class="arena-topic-stats">${t.hits} hits · ${t.conquered} conquered${t.angles?.length ? ` · ${t.angles.length} angles` : ''}</span>++ <span class="arena-topic-actions">++ ${t.talking_now.length && me && !onIt ? t.talking_now.slice(0, 2).map(f => `<button class="btn btn-ghost btn-sm arena-topic-hype" data-topic="${t.id}" data-user="${f.user.id}"><i class="fa-solid fa-fire"></i> ${_aEsc(f.fighter_name)}</button>`).join('') : ''}++ ${onRoster && t.kind !== 'bounty' ? (onIt ? '<span class="arena-tag arena-tag-hot"><i class="fa-solid fa-microphone"></i> you\'re on it</span>' : `<button class="btn btn-primary btn-sm arena-topic-join" data-topic="${t.id}"><i class="fa-solid fa-microphone"></i> Talk on this</button>`) : ''}++ </span>++ </div>++ </div>`;++}++function _aRenderBoard(bd) {++ const me = _aMe();++ const open = bd.open || [], resolved = bd.resolved || [];++ return `<div class="arena-board-head">++ <h2><i class="fa-solid fa-comments"></i> The board <small>${open.length} open · sorted by heat</small></h2>++ ${me ? `<button class="btn btn-ghost btn-sm" id="arena-board-new"><i class="fa-solid fa-plus"></i> Topic</button>` : '<span class="arena-note">sign in (or type <code>!topic</code> in any chat) to add one</span>'}++ </div>++ ${open.length ? `<div class="arena-topic-grid">${open.map(t => _aTopicCard(t, bd.hot_threshold || 12, { me })).join('')}</div>` : '<p class="arena-note">The board is empty — the pulse fills it in a moment, or start a topic.</p>'}++ ${resolved.length ? `<details class="arena-done"><summary>Recently settled <small>${resolved.length}</small></summary><div class="arena-resolved">${resolved.map(t => `<div class="arena-resolved-row">${_aA(_aTopicLink(t), `<span class="arena-kind">${_aEsc((ARENA_KIND[t.kind] || ARENA_KIND.topic).label)}</span> ${_aTopicTitle(t)}`)}<span class="arena-note">${t.resolved?.headline ? _aEsc(t.resolved.headline) : t.winner_side ? `${_aEsc(t.winner_side === 'a' ? t.side_a : t.side_b)} won` : 'closed'}</span></div>`).join('')}</div></details>` : ''}`;++}++function _aTopicComposer() {++ const box = document.createElement('div');++ box.className = 'arena-lightbox';++ box.innerHTML = `<button class="arena-lightbox-close" aria-label="Close">×</button>++ <div class="arena-lightbox-inner arena-composer">++ <h3><i class="fa-solid fa-comment-dots"></i> Put a topic on the board</h3>++ <p class="arena-note">Something the streamers should talk shit about. The AI cuts it into 3 angles; anyone who clears them levels up. One line, up to 140 characters.</p>++ <textarea id="arena-composer-text" maxlength="140" rows="3" placeholder="e.g. Streamers who read donations in a baby voice"></textarea>++ <div class="arena-composer-actions"><button class="btn btn-primary" id="arena-composer-go">Post it</button></div></div>`;++ const close = () => box.remove();++ box.addEventListener('click', (e) => { if (e.target === box || e.target.closest('.arena-lightbox-close')) close(); });++ box.querySelector('#arena-composer-go').addEventListener('click', async () => {++ const text = box.querySelector('#arena-composer-text').value.trim();++ if (!text) return;++ try { const r = await api('/arena/board/topics', { method: 'POST', body: { text } }); close(); _aToast('📌 On the board.', 'success'); navigate(`/arena/topic/${r.topic.id}`); }++ catch (err) { _aToast(err?.message || 'Failed', 'error'); }++ });++ document.body.appendChild(box);++ box.querySelector('textarea').focus();++}++document.addEventListener('click', (e) => { if (e.target.closest('#arena-board-new')) { e.preventDefault(); _aTopicComposer(); } });++++// ── Ladders ──────────────────────────────────────────────────++++function _aLevelsList(rows) {++ if (!rows.length) return '<p class="arena-note">Nobody has XP yet. Talk on a topic or start a beef.</p>';++ return `<div class="arena-mini-list">${rows.map((r, i) => `<div class="arena-mini-row">++ <span class="arena-rank ${i < 3 ? `arena-rank-${i + 1}` : ''}">${i + 1}</span>++ ${_aA(_aFighterLink(r.user), `${_aPortrait(r, 'xs')}<span><strong>${_aEsc(r.fighter_name)}</strong><small>${r.xp} XP · ${r.beef_hits} beef hits · ${r.angles_cleared} angles · ${r.topics_conquered} conquered</small></span>`, 'arena-chip')}++ <span class="arena-lvl arena-lvl-big">LVL ${r.level}</span>++ </div>`).join('')}</div>`;++}++function _aCloutList(rows) {++ if (!rows.length) return '<p class="arena-note">Pick sides in beefs and debates (<code>!side</code>) — call it right and you show up here.</p>';++ return `<div class="arena-mini-list">${rows.map((r, i) => `<div class="arena-mini-row">++ <span class="arena-rank ${i < 3 ? `arena-rank-${i + 1}` : ''}">${i + 1}</span>++ <span class="arena-chip"><span><strong>${r.username ? _aA(_aChannelLink(r), _aEsc(r.name)) : _aEsc(r.name)}</strong><small>${r.wins}/${r.picks} right · ${r.accuracy}%${r.streak >= 2 ? ` · ${r.streak} streak` : ''}</small></span></span>++ <span class="arena-lvl">${r.wins} W</span>++ </div>`).join('')}</div>`;}function _aRenderList(fighters) {@@@ -261,13 +447,13 @@ function _aRenderList(fighters) {<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>' : ''}<i class="fa-solid fa-chevron-right arena-chevron"></i></strong>++ <strong>${_aEsc(f.persona.fighter_name)} ${f.live ? '<span class="arena-live-pill">LIVE</span>' : ''} ${_aLevelPill(f.level?.level)}<i class="fa-solid fa-chevron-right arena-chevron"></i></strong><span class="arena-row-sub">${_aEsc(f.user.display_name)} · ${_aEsc(f.persona.class)} · ${_aEsc(f.persona.element)}${f.voice?.has_data ? ` · <i class="fa-solid fa-microphone" title="has transcript data"></i> Mic ${f.ratings.mic}` : ''}</span><em class="arena-row-taunt">“${_aEsc(f.persona.taunt)}”</em></span><span class="arena-row-stats"><span class="arena-power"><b>${f.ratings.power}</b><small>PWR</small></span>-- <span class="arena-record">${f.record.wins}W–${f.record.losses}L</span>++ <span class="arena-record" title="beef record">${f.record.wins}W–${f.record.losses}L</span></span><div class="arena-row-expand"><div>${_aRadar(f.ratings, f.user.profile_color || '#8b5cf6', 180)}<div class="arena-mini-record">${f.category ? _aEsc(f.category) + ' · ' : ''}last live ${_aEsc(f.last_live_at ? _aDate(f.last_live_at) : '—')}</div></div>@@@ -278,7 +464,7 @@ function _aRenderList(fighters) {</div><div class="arena-row-expand-actions">${_aA(_aFighterLink(f.user), '<i class="fa-solid fa-id-card"></i> Full profile', 'btn btn-primary')}-- <button class="btn btn-ghost arena-row-fight" data-user="${_aEsc(f.user.username)}"><i class="fa-solid fa-hand-fist"></i> Fight someone</button>++ ${f.live ? _aA(_aConsoleLink(f.user), '<i class="fa-solid fa-ear-listen"></i> Listen in', 'btn btn-ghost') : ''}${_aSpeakBtn(f.persona.taunt, 'btn btn-ghost')}</div></div>@@@ -287,15 +473,188 @@ function _aRenderList(fighters) {if (e.target.closest('a, button')) return;row.classList.toggle('is-open');}));-- el.querySelectorAll('.arena-row-fight').forEach(btn => btn.addEventListener('click', () => {-- const me = fighters.find(f => f.user.username === btn.dataset.user);-- const pool = fighters.filter(f => f !== me);-- if (!me || !pool.length) return;-- navigate(_aBattleLink(me.user, pool[Math.floor(Math.random() * pool.length)].user));-- }));_aBindSpeak(el);}++// ── Beef detail ──────────────────────────────────────────────++++function _aFeedLine(e, b) {++ const side = e.side === 'a' ? b.a : b.b;++ const sys = e.kind && e.kind !== 'hit';++ return `<div class="arena-feed-line ${sys ? 'is-system' : `is-${e.side}`}">++ ${sys ? '' : _aA(_aFighterLink(side.user), _aPortrait(side, 'xs'))}++ <div class="arena-feed-body">++ ${e.announcer ? `<div class="arena-feed-announcer"><i class="fa-solid fa-bullhorn"></i> ${_aEsc(e.announcer)}</div>` : ''}++ ${e.text ? `<q>${_aEsc(e.text)}</q>` : ''}++ <small>${sys ? _aEsc(e.kind) : `${_aEsc(side.fighter_name)}${e.quality != null ? ` · ${e.quality}/10` : ''}${e.about ? ` · ${_aEsc(e.about)}` : ''}${e.bonus ? ` · ${_aEsc(e.bonus)}` : ''}`} · ${_aEsc(_aAgo(e.at))} ${_aPlay(e.vod_id, e.sec)} ${e.text ? _aSpeakBtn(e.text, '') : ''}</small>++ </div>++ </div>`;++}++async function _aRenderBeef(root, id) {++ root.innerHTML = _aSpinner('Pulling the receipts…');++ const b = await api(`/arena/beefs/${id}`);++ const open = b.status === 'open';++ const winner = !open && b.winner_user_id ? (b.winner_user_id === b.a.user.id ? b.a : b.b) : null;++ const draw = () => {++ root.innerHTML = `++ <div class="arena-back">${_aA('/arena', '<i class="fa-solid fa-arrow-left"></i> Arena')} ${_aA(_aFighterLink(b.a.user), _aEsc(b.a.fighter_name))} · ${_aA(_aFighterLink(b.b.user), _aEsc(b.b.fighter_name))}</div>++ <div class="arena-beef-page">++ <div class="arena-beef-hero">++ <div class="arena-beef-tags">${_aBeefTags(b)}${!open ? `<span class="arena-tag ${winner ? 'arena-tag-gold' : 'arena-tag-dim'}">${winner ? `${_aEsc(winner.fighter_name)} won${b.resolution === 'forfeit' ? ' by forfeit' : b.resolution === 'score' ? ' on points' : ''}` : 'draw'}</span>` : '<span class="arena-tag arena-tag-hot"><i class="fa-solid fa-fire"></i> OPEN</span>'}</div>++ <h1>${_aEsc((open ? b.headline : b.result_headline || b.headline) || `${b.a.fighter_name} vs ${b.b.fighter_name}`)} ${_aSpeakBtn((open ? b.headline : b.result_headline || b.headline) || '')}</h1>++ ${b.opener_line ? `<p class="arena-beef-opener">It started with: <q>${_aEsc(b.opener_line)}</q></p>` : ''}++ </div>++ <div class="arena-beef-tape">++ ${['a', 'b'].map(s => { const f = b[s]; return `<div class="arena-beef-tape-side arena-beef-tape-${s}" style="--fc:${_aEsc(f.user.profile_color || (s === 'a' ? '#8b5cf6' : '#e74c3c'))}">++ ${_aA(_aFighterLink(f.user), _aPortrait(f, 'md'))}++ <h2>${_aEsc(f.fighter_name)}${f.live ? ' <span class="arena-live-pill">LIVE</span>' : ''}</h2>++ <div class="arena-beef-tape-meta">#${f.rank || '–'} · ${_aLevelPill(f.level)}${b.streaks?.[s] >= 2 ? ` · ${b.streaks[s]}-beef streak` : ''}</div>++ <div class="arena-beef-tape-nums"><span><b>${f.hits}</b><small>hits</small></span><span><b>${f.score}</b><small>quality</small></span><span><b>${f.crowd}</b><small>crowd /${b.rules.crowd_max}</small></span><span><b>${f.total}</b><small>total</small></span></div>++ ${open ? `<div class="arena-beef-tape-actions"><button class="btn btn-primary btn-sm arena-hype-btn" data-beef="${b.id}" data-side="${s}"><i class="fa-solid fa-fire"></i> Hype</button><button class="btn btn-ghost btn-sm arena-side-btn" data-beef="${b.id}" data-side="${s}"><i class="fa-solid fa-flag"></i> I'm with ${_aEsc(f.fighter_name.split(' ')[0])}</button></div>` : ''}++ ${f.live ? _aA(_aConsoleLink(f.user), '<i class="fa-solid fa-ear-listen"></i> listen in', 'arena-tag') : ''}++ </div>`; }).join('')}++ </div>++ ${_aTug(b, { big: true })}++ <div class="arena-beef-status">++ ${open ? _aClockTag(b) : ''}++ <span class="arena-sides-tally" id="arena-sides-tally"><i class="fa-solid fa-people-group"></i> chat says ${b.sides.a}–${b.sides.b} (${b.sides.share_a}% ${_aEsc(b.a.fighter_name)})</span>++ ${b.ends_at && open ? `<span class="arena-note"><i class="fa-solid fa-hourglass-half"></i> hard end in ${_aEsc(_aClock((Date.parse(b.ends_at) - Date.now()) / 1000))}</span>` : ''}++ </div>++ <div class="arena-beef-cols">++ <section class="arena-feed">++ <h3><i class="fa-solid fa-bullhorn"></i> Ringside <small>every judged hit, newest last</small></h3>++ ${b.feed.length ? b.feed.map(e => _aFeedLine(e, b)).join('') : '<p class="arena-note">Nothing judged yet.</p>'}++ </section>++ <aside class="arena-beef-aside">++ ${b.history?.fights ? `<section><h3><i class="fa-solid fa-receipt"></i> Receipts <small>${b.history.wins_1}–${b.history.wins_2} in ${b.history.fights} earlier beef${b.history.fights > 1 ? 's' : ''}</small></h3>++ ${b.history.receipts.map(r => { const f = r.side_user_id === b.a.user.id ? b.a : b.b; return `<div class="arena-receipt"><q>${_aEsc(r.text)}</q><small>${_aEsc(f.fighter_name)} · ${r.quality}/10 · ${_aA(_aBeefLink({ id: r.beef_id }), `beef #${r.beef_id}`)} ${_aPlay(r.vod_id, r.sec)}</small></div>`; }).join('') || '<p class="arena-note">No quotable receipts.</p>'}</section>` : '<section><h3><i class="fa-solid fa-receipt"></i> Receipts</h3><p class="arena-note">First time these two have beef.</p></section>'}++ <section><h3><i class="fa-solid fa-gavel"></i> Rules</h3><ul class="arena-rules-list">++ <li>Answer on your own stream within <b>${b.rules.response_live_min} min</b> if you're live, <b>${b.rules.response_offline_hours} h</b> if not — or forfeit.</li>++ <li>Every judged answer resets the other side's clock. Beef hard-ends after ${b.rules.max_hours} h; higher total wins.</li>++ <li>Total = hit quality (AI-judged 1–10) + crowd hype (max ${b.rules.crowd_max}). Chat's side pick is clout, not score.</li>++ <li>Beat someone ranked 4+ spots above you → <b>upset</b>. Same two again → <b>rematch</b> (+ rivalry receipts).</li>++ </ul></section>++ </aside>++ </div>++ </div>`;++ root.querySelectorAll('.arena-hype-btn').forEach(btn => btn.onclick = () => _aHype(b.id, btn.dataset.side, btn));++ root.querySelectorAll('.arena-side-btn').forEach(btn => btn.onclick = async () => { const r = await _aSide(b.id, btn.dataset.side); if (r) { const t = document.getElementById('arena-sides-tally'); if (t) t.innerHTML = `<i class="fa-solid fa-people-group"></i> chat says ${r.a}–${r.b} (${r.share_a}% ${_aEsc(b.a.fighter_name)})`; } });++ _aBindSpeak(root);++ };++ draw();++ _aEvery(1000, () => _aTickClocks(root));++ if (open) _aEvery(12000, async () => { try { const fresh = await api(`/arena/beefs/${id}`); if (JSON.stringify(fresh.feed) !== JSON.stringify(b.feed) || fresh.status !== b.status || fresh.share_a !== b.share_a) { Object.assign(b, fresh); draw(); } } catch { /* */ } });++}++++// ── Topic detail ─────────────────────────────────────────────++++async function _aRenderTopic(root, id) {++ root.innerHTML = _aSpinner('Opening the topic…');++ let t = await api(`/arena/board/topics/${id}`);++ const me = _aMe();++ const draw = () => {++ const k = ARENA_KIND[t.kind] || ARENA_KIND.topic;++ const onIt = me && t.talking_now.some(f => f.user.id === me.id);++ const onRoster = me && (_arenaRoster?.fighters?.some(f => f.user.id === me.id) ?? true);++ const members = t.members || [];++ root.innerHTML = `++ <div class="arena-back">${_aA('/arena', '<i class="fa-solid fa-arrow-left"></i> Arena')}</div>++ <div class="arena-topic-page arena-topic-${t.kind} ${t.hot ? 'is-hot' : ''}">++ <div class="arena-topic-head">++ <span class="arena-kind"><i class="fa-solid ${k.icon}"></i> ${k.label}</span>++ ${_aHeat(t, 12)}++ <span class="arena-topic-by">${t.created_by === 'ai' ? '<i class="fa-solid fa-heart-pulse"></i> from the pulse' : `started by ${_aEsc(t.creator_name || t.created_by)}`} · ${_aEsc(_aAgo(t.created_at))}${t.expires_at && t.status === 'open' ? ` · <span class="arena-expires" data-until="${_aEsc(t.expires_at)}">${_aEsc(_aClock((Date.parse(t.expires_at) - Date.now()) / 1000))} left</span>` : ''}${t.status !== 'open' ? ` · <b>${_aEsc(t.status)}</b>` : ''}</span>++ </div>++ ${t.headline ? `<div class="arena-topic-headline">${_aEsc(t.headline)} ${_aSpeakBtn(t.headline, '')}</div>` : ''}++ <h1 class="arena-topic-text">${_aTopicTitle(t)}</h1>++ ${t.hint ? `<p class="arena-topic-hint">${_aEsc(t.hint)}</p>` : ''}++ ${t.source_note ? `<p class="arena-note"><i class="fa-solid fa-satellite-dish"></i> ${_aEsc(t.source_note)}</p>` : ''}++ ${t.resolved?.headline ? `<div class="arena-topic-result"><i class="fa-solid fa-flag-checkered"></i> ${_aEsc(t.resolved.headline)}</div>` : ''}++ ${t.kind === 'debate' ? `<div class="arena-debate arena-debate-big">++ <button class="btn btn-ghost arena-debate-side" data-topic="${t.id}" data-side="a" data-label="${_aEsc(t.side_a)}" ${t.status !== 'open' ? 'disabled' : ''}>${_aEsc(t.side_a)}</button>++ <span class="arena-debate-tally">${_aDebateTally(t.sides || { a: 0, b: 0, share_a: 50 })}</span>++ <button class="btn btn-ghost arena-debate-side" data-topic="${t.id}" data-side="b" data-label="${_aEsc(t.side_b)}" ${t.status !== 'open' ? 'disabled' : ''}>${_aEsc(t.side_b)}</button>++ <p class="arena-note">Streamers: say which side you're on and argue it on mic. Chat: pick a side — the side with more talk + more chat wins when the clock runs out, and right picks earn clout.</p>++ </div>` : ''}++ ${t.kind === 'bounty' && t.target ? `<div class="arena-bounty-target">${_aBriefChip(t.target, ' · the mark')}<span class="arena-note">Any fighter who lands judged shit talk on ${_aEsc(t.target.fighter_name)} while this is open gets <b>double XP</b>. The mark can answer back on their own stream.</span></div>` : ''}++ <div class="arena-topic-cta">++ ${onRoster && t.status === 'open' && t.kind !== 'bounty' ? (onIt ? `<span class="arena-tag arena-tag-hot"><i class="fa-solid fa-microphone"></i> You're on this — talk on stream, the ears are listening</span> <button class="btn btn-ghost btn-sm" id="arena-topic-leave">Leave topic</button> ${_aA(_aConsoleLink(me), '<i class="fa-solid fa-ear-listen"></i> my ears', 'btn btn-ghost btn-sm')}` : `<button class="btn btn-primary" id="arena-topic-join"><i class="fa-solid fa-microphone"></i> Talk on this</button><span class="arena-note">Sets it as your active topic. Go live with transcription on and just talk — every 30 s the judge scores what you said.</span>`) : ''}++ ${!me ? '<span class="arena-note">Sign in to talk on this (streamers) or hype whoever\'s on it.</span>' : ''}++ </div>++ ${t.angles?.length ? `<section class="arena-angles"><h3><i class="fa-solid fa-diagram-project"></i> Angles <small>clear all ${t.angles.length} to conquer the topic (+60 XP)</small></h3>++ <div class="arena-angle-grid">${t.angles.map(a => `<div class="arena-angle"><b>${a.idx + 1}. ${_aEsc(a.text)}</b><span>${_aEsc(a.hint || '')}</span></div>`).join('')}</div></section>` : (t.kind === 'topic' ? '<p class="arena-note">Angles get cut the moment someone joins.</p>' : '')}++ ${members.length ? `<section class="arena-members"><h3><i class="fa-solid fa-microphone-lines"></i> Who's talking <small>${t.talking_now.length} live on it</small></h3>++ ${members.map(m => { const prog = (t.progress || {})[m.user.id] || []; return `<div class="arena-member ${m.active ? 'is-active' : ''}">++ ${_aBriefChip(m, ` · ${m.cleared}/${t.angles.length || '?'} angles · ${m.score} pts${m.conquered_at ? ' · <b>conquered</b>' : ''}`)}++ <div class="arena-member-progress">${(t.angles || []).map(a => { const p = prog.find(x => x.angle_idx === a.idx) || { progress: 0 }; return `<span class="arena-progress ${p.cleared ? 'is-cleared' : ''}" title="${_aEsc(a.text)}: ${p.progress}%"><span class="arena-progress-fill" style="width:${p.progress}%"></span></span>`; }).join('')}</div>++ ${me && m.user.id !== me.id && t.status === 'open' ? `<button class="btn btn-ghost btn-sm arena-topic-hype" data-topic="${t.id}" data-user="${m.user.id}"><i class="fa-solid fa-fire"></i> Hype</button>` : ''}++ </div>`; }).join('')}</section>` : ''}++ ${t.best_lines?.length ? `<section class="arena-best-lines"><h3><i class="fa-solid fa-quote-left"></i> Best lines <small>as heard on stream</small></h3>++ ${t.best_lines.map(l => `<div class="arena-quote"><div><q>${_aEsc(l.text)}</q><small>${_aA(_aFighterLink(l.user), _aEsc(l.fighter_name))}${l.angle_idx >= 0 && t.angles[l.angle_idx] ? ` · angle ${l.angle_idx + 1}` : ''}</small></div><div class="arena-quote-actions">${_aPlay(l.vod_id, l.sec)} ${_aSpeakBtn(l.text, '')}</div></div>`).join('')}</section>` : ''}++ </div>`;++ document.getElementById('arena-topic-join')?.addEventListener('click', async () => { try { const r = await api(`/arena/board/topics/${t.id}/join`, { method: 'POST' }); t = r.topic; _aToast('🎤 You\'re on it. Go talk.', 'success'); draw(); } catch (err) { _aToast(err?.message || 'Failed', 'error'); } });++ document.getElementById('arena-topic-leave')?.addEventListener('click', async () => { try { await api(`/arena/board/topics/${t.id}/leave`, { method: 'POST' }); t = await api(`/arena/board/topics/${id}`); draw(); } catch (err) { _aToast(err?.message || 'Failed', 'error'); } });++ _aBindHome(root);++ };++ draw();++ _aEvery(15000, async () => { try { const fresh = await api(`/arena/board/topics/${id}`); if (JSON.stringify(fresh) !== JSON.stringify(t)) { t = fresh; draw(); } } catch { /* */ } });++}++++// ── Live console ("the ears") ────────────────────────────────++++async function _aRenderConsole(root, username) {++ root.innerHTML = _aSpinner('Putting the ears on…');++ let c = await api(`/arena/console/${encodeURIComponent(username)}`);++ const draw = () => {++ const f = c.fighter, L = c.listener || {}, lvl = c.level || {};++ const me = _aMe(), mine = me && me.id === f.user.id;++ const xpPct = lvl.xp_per_level ? Math.round((lvl.xp_into_level / lvl.xp_per_level) * 100) : 0;++ root.innerHTML = `++ <div class="arena-back">${_aA('/arena', '<i class="fa-solid fa-arrow-left"></i> Arena')} ${_aA(_aFighterLink(f.user), _aEsc(f.fighter_name))} ${c.live ? _aA(_aChannelLink(f.user), '<i class="fa-solid fa-tv"></i> watch', '') : ''}</div>++ <div class="arena-console" style="--fc:${_aEsc(f.user.profile_color || '#8b5cf6')}">++ <div class="arena-console-head">++ ${_aPortrait(f, 'sm')}++ <div>++ <h1>${_aEsc(f.fighter_name)} ${c.live ? '<span class="arena-live-pill">LIVE</span>' : '<span class="arena-tag arena-tag-dim">offline</span>'}</h1>++ <div class="arena-console-status ${L.listening ? 'is-on' : ''}">++ <i class="fa-solid ${L.listening ? 'fa-ear-listen' : 'fa-ear-deaf'}"></i>++ ${!c.live ? 'Not live — the ears only work on a live, transcribed stream.' : !c.transcribed ? 'Live, but no transcript in the last 30 min. Turn on audio transcription in the dashboard and the Arena hears you.' : L.listening ? `Listening. ${L.pending_topic_words || 0} words buffered for the topic judge · ${L.mention_buffers || 0} name-drop buffer${L.mention_buffers === 1 ? '' : 's'} open${L.last_judge_at ? ` · last judged ${_aEsc(_aAgo(L.last_judge_at))}` : ''}` : 'Live and transcribed — the listener picks this stream up on its next 15 s tick.'}++ </div>++ </div>++ <div class="arena-console-level">++ <span class="arena-lvl arena-lvl-big">TRASH LVL ${lvl.level || 1}</span>++ <span class="arena-xp-track"><span class="arena-xp-fill" style="width:${xpPct}%"></span></span>++ <small>${lvl.xp_into_level || 0}/${lvl.xp_per_level || 50} XP to level ${(lvl.level || 1) + 1} · ${lvl.recent_xp || 0} this week</small>++ </div>++ </div>++ <div class="arena-console-cols">++ <section class="arena-console-main">++ <h3><i class="fa-solid fa-microphone"></i> Hot mic <small>last lines the ears heard</small></h3>++ <div class="arena-mic-feed">${c.hot_mic?.length ? c.hot_mic.map(l => `<div class="arena-mic-line-row"><span class="arena-mic-time">${_aStamp(l.sec)}</span><span>${_aEsc(l.text)}</span>${_aPlay(l.vod_id, l.sec)}</div>`).join('') : '<p class="arena-note">Nothing heard yet.</p>'}</div>++ ${L.last_beef_judgement ? `<div class="arena-judgement ${L.last_beef_judgement.aimed_at_target ? 'is-hit' : ''}"><b><i class="fa-solid fa-gavel"></i> Beef judge (${_aEsc(_aAgo(L.last_beef_judgement.at))}):</b> ${L.last_beef_judgement.aimed_at_target ? `HIT · ${L.last_beef_judgement.quality}/10 · ${_aEsc(L.last_beef_judgement.about || '')}${L.last_beef_judgement.opened ? ' · <b>beef opened</b>' : ''}${L.last_beef_judgement.bounty ? ' · <b>bounty collected</b>' : ''}` : `no beef — ${_aEsc(L.last_beef_judgement.about || 'name dropped but not aimed at them')}`}${L.last_beef_judgement.flagged ? ' · <span class="arena-tag arena-tag-dim">line not counted (threat/minor/dox)</span>' : ''}</div>` : ''}++ ${L.last_topic_judgement ? `<div class="arena-judgement ${L.last_topic_judgement.applied ? 'is-hit' : ''}"><b><i class="fa-solid fa-gavel"></i> Topic judge (${_aEsc(_aAgo(L.last_topic_judgement.at))}):</b> ${L.last_topic_judgement.applied ? `angle ${L.last_topic_judgement.angle_idx + 1} · ${L.last_topic_judgement.quality}/10 · +${L.last_topic_judgement.progress_gain || 0}% → ${L.last_topic_judgement.progress}%${L.last_topic_judgement.cleared_angle ? ' · <b>angle cleared</b>' : ''}${L.last_topic_judgement.conquered ? ' · <b>TOPIC CONQUERED</b>' : ''}` : `off topic — ${_aEsc(L.last_topic_judgement.about || 'keep it on the angles')}`}</div>` : ''}++ </section>++ <aside class="arena-console-aside">++ <section>++ <h3><i class="fa-solid fa-comment-dots"></i> Active topic</h3>++ ${c.active_topic ? `${_aA(_aTopicLink(c.active_topic), `<b>${_aTopicTitle(c.active_topic)}</b>`)}++ <div class="arena-angle-list">${(c.active_topic.angles || []).map(a => { const p = (c.active_topic.my_progress || []).find(x => x.angle_idx === a.idx) || { progress: 0 }; return `<div class="arena-angle-row ${p.cleared_at ? 'is-cleared' : ''}"><span class="arena-progress"><span class="arena-progress-fill" style="width:${Math.round(p.progress || 0)}%"></span></span><span><b>${a.idx + 1}.</b> ${_aEsc(a.text)} <small>${Math.round(p.progress || 0)}%${p.cleared_at ? ' ✓' : ''}</small></span></div>`; }).join('')}</div>` : `<p class="arena-note">${mine ? 'No active topic. Pick one on the board and talk on it.' : 'Not on a board topic.'}</p>${mine ? _aA('/arena', 'Open the board', 'btn btn-ghost btn-sm') : ''}`}++ </section>++ <section>++ <h3><i class="fa-solid fa-fire-flame-curved"></i> Open beefs</h3>++ ${c.open_beefs?.length ? c.open_beefs.map(b => `<div class="arena-console-beef">${_aA(_aBeefLink(b), `<b>${_aEsc(b.headline || `${b.a.fighter_name} vs ${b.b.fighter_name}`)}</b>`)}${_aTug(b)}${_aClockTag(b)}</div>`).join('') : `<p class="arena-note">None. ${mine ? 'Say another fighter\'s name while talking shit and one opens.' : ''}</p>`}++ </section>++ ${c.bounty_on_me ? `<section><h3><i class="fa-solid fa-sack-dollar"></i> Bounty on ${_aEsc(f.fighter_name)}</h3>${_aA(_aTopicLink(c.bounty_on_me), _aEsc(c.bounty_on_me.headline || c.bounty_on_me.text))}<p class="arena-note">Everyone gets double XP for talking shit about ${mine ? 'you' : 'them'} until it expires. ${mine ? 'Answer on mic.' : ''}</p></section>` : ''}++ <section><h3><i class="fa-solid fa-circle-info"></i> How it's judged</h3><ul class="arena-rules-list"><li>Every 15 s the ears read new transcript lines.</li><li>A fighter's name in a line opens a 45 s window; ≥20 words go to the beef judge.</li><li>Otherwise lines pool for the topic judge (also ≥20 words, ≥30 s apart).</li><li>Offensive language is fine. Threats, minors, doxxing → line ignored.</li></ul></section>++ </aside>++ </div>++ </div>`;++ };++ draw();++ _aEvery(1000, () => _aTickClocks(root));++ _aEvery(10000, async () => { try { const fresh = await api(`/arena/console/${encodeURIComponent(username)}`); const lvlUp = (fresh.level?.level || 1) > (c.level?.level || 1); c = fresh; draw(); if (lvlUp) _aLevelUp(c.level.level); } catch { /* */ } });++}++// ── Fighter profile ──────────────────────────────────────────function _aVoiceCard(f) {@@@ -303,7 +662,7 @@ function _aVoiceCard(f) {const q = f.quotes;const color = f.user.profile_color || '#8b5cf6';if (!v.has_data) {-- return `<div class="arena-voice" style="--fc:${_aEsc(color)}"><div class="arena-voice-head"><h3><i class="fa-solid fa-microphone-slash"></i> On the mic</h3></div><p class="arena-voice-empty">No transcript data yet — the audio transcription picks this up on their next streams. Until then, MIC is rated at the floor.</p></div>`;++ return `<div class="arena-voice" style="--fc:${_aEsc(color)}"><div class="arena-voice-head"><h3><i class="fa-solid fa-microphone-slash"></i> On the mic</h3></div><p class="arena-voice-empty">No transcript data yet — the audio transcription picks this up on their next streams. Until then, MIC is rated at the floor and the Arena can't hear them.</p></div>`;}const quotes = (q && q.picks && q.picks.length) ? q.picks : [];return `<div class="arena-voice" style="--fc:${_aEsc(color)}">@@@ -324,15 +683,23 @@ function _aVoiceCard(f) {${quotes.length ? `<div class="arena-quotes">${quotes.map(p => `<div class="arena-quote"><div><q>${_aEsc(p.text)}</q><small>${_aEsc(p.why || '')}${p.vod_id ? ` · at ${_aStamp(p.start_sec)}` : ''}</small></div>-- <div class="arena-quote-actions">-- ${p.vod_id ? `<a href="/vod/${p.vod_id}?t=${p.start_sec}" onclick="return handleLinkClick(event, '/vod/${p.vod_id}?t=${p.start_sec}')" title="Hear them say it (jumps to the VOD)"><i class="fa-solid fa-play"></i></a>` : ''}-- ${_aSpeakBtn(p.text, '')}-- </div>++ <div class="arena-quote-actions">${_aPlay(p.vod_id, p.start_sec)} ${_aSpeakBtn(p.text, '')}</div></div>`).join('')}</div>` : '<p class="arena-voice-empty">Quotes appear once enough lines have been transcribed.</p>'}${q?._fallback ? '<p class="arena-note">Quotes picked by heuristic — the AI curates these once enabled.</p>' : ''}</div>`;}++function _aLevelCard(f) {++ const l = f.level || {};++ const pct = l.xp_per_level ? Math.round((l.xp_into_level / l.xp_per_level) * 100) : 0;++ return `<div class="arena-level-card">++ <div class="arena-level-head"><span class="arena-lvl arena-lvl-big">TRASH LVL ${l.level || 1}</span><span class="arena-note">${l.xp || 0} XP · ${l.recent_xp || 0} this week${f.ratings.talk_bonus ? ` · <b>+${f.ratings.talk_bonus} POWER</b> from the mouth` : ''}</span></div>++ <span class="arena-xp-track"><span class="arena-xp-fill" style="width:${pct}%"></span></span>++ <div class="arena-level-nums"><span><b>${l.beef_hits || 0}</b><small>beef hits</small></span><span><b>${l.angles_cleared || 0}</b><small>angles cleared</small></span><span><b>${l.topics_conquered || 0}</b><small>topics conquered</small></span><span><b>${f.record.wins}–${f.record.losses}${f.record.draws ? `–${f.record.draws}` : ''}</b><small>beef record</small></span></div>++ ${l.best_line ? `<div class="arena-quote"><div><q>${_aEsc(l.best_line.text)}</q><small>best line on record · ${l.best_line.score}/10</small></div><div class="arena-quote-actions">${_aPlay(l.best_line.vod_id, l.best_line.sec)} ${_aSpeakBtn(l.best_line.text, '')}</div></div>` : ''}++ </div>`;++}++async function _aRenderFighter(root, username) {root.innerHTML = _aSpinner('Pulling the fighter file…');const f = await api(`/arena/fighters/${encodeURIComponent(username)}`);@@@ -341,15 +708,15 @@ async function _aRenderFighter(root, username) {return;}const p = f.persona, color = f.user.profile_color || '#8b5cf6';-- const recent = f.recent_battles || [];++ const beefs = f.beefs || [], rivalries = f.rivalries || [];root.innerHTML = `-- <div class="arena-back">${_aA('/arena', '<i class="fa-solid fa-arrow-left"></i> Arena')}</div>++ <div class="arena-back">${_aA('/arena', '<i class="fa-solid fa-arrow-left"></i> Arena')} ${f.live ? _aA(_aConsoleLink(f.user), '<i class="fa-solid fa-ear-listen"></i> Listen in live', '') : ''}</div><div class="arena-profile" style="--fc:${_aEsc(color)}"><div class="arena-profile-portrait" id="arena-profile-portrait">${_aPortrait(f, 'lg')}${f.image_pending ? '<div class="arena-portrait-pending"><i class="fa-solid fa-wand-magic-sparkles fa-fade"></i> painting portrait…</div>' : ''}<div class="arena-profile-rank">#${f.rank} <small>of ${f.roster_size}</small></div>-- ${f.rivalry ? `<div class="arena-mini-record"><i class="fa-solid fa-fire"></i> Rivalry: ${_aA(_aBattleLink(f.user, f.rivalry.opponent), _aEsc(f.rivalry.fighter_name))} (${f.rivalry.wins}–${f.rivalry.losses} in ${f.rivalry.fights} fights)</div>` : ''}++ ${f.active_topic ? `<div class="arena-mini-record"><i class="fa-solid fa-comment-dots"></i> Talking on: ${_aA(_aTopicLink(f.active_topic), _aEsc(f.active_topic.text))}</div>` : ''}</div><div class="arena-profile-main"><div class="arena-profile-name">@@@ -359,9 +726,10 @@ async function _aRenderFighter(root, username) {</div><div class="arena-profile-power"><div class="arena-power arena-power-lg"><b>${f.ratings.power}</b><small>POWER</small></div>-- ${f.ratings.talk_bonus ? `<div class="arena-talk-bonus" title="Trash Talk bonus — earned on /arena/talk, decays over a week"><i class="fa-solid fa-microphone-lines"></i> +${f.ratings.talk_bonus} trash talk</div>` : ''}-- <div class="arena-record arena-record-lg">${f.record.wins}W – ${f.record.losses}L</div>++ ${f.ratings.talk_bonus ? `<div class="arena-talk-bonus" title="Mouth bonus — recent Trash Level XP and beef wins, decays over a week"><i class="fa-solid fa-microphone-lines"></i> +${f.ratings.talk_bonus} mouth</div>` : ''}++ <div class="arena-record arena-record-lg" title="beef record">${f.record.wins}W – ${f.record.losses}L</div></div>++ ${_aLevelCard(f)}<div class="arena-profile-stats">${_aRadar(f.ratings, color)}<div class="arena-quips">${ARENA_STATS.map(k => `<div class="arena-quip is-clickable" data-stat="${k}" title="Tap for the breakdown"><b>${_aEsc(ARENA_STAT_LABEL[k])} ${f.ratings[k]} <i class="fa-solid fa-magnifying-glass-chart" style="font-size:0.7em;opacity:0.6"></i></b><span>${_aEsc((p.stat_quips || {})[k] || '')}</span></div>`).join('')}</div>@@@ -388,15 +756,12 @@ async function _aRenderFighter(root, username) {</div></div></div>-- ${(f.trash_talk || []).length ? `<section class="arena-challenge"><h2><i class="fa-solid fa-microphone-lines"></i> Trash talk record</h2><div class="arena-talk-list">${f.trash_talk.map(e => _aTalkEntry(e, { compact: true })).join('')}</div></section>` : ''}-- ${recent.length ? `<section class="arena-challenge"><h2>Recent fights</h2><div class="arena-opponents">${recent.map(b => _aA(_aBattleLink(f.user, b.opponent), `${_aPortrait({ user: b.opponent }, 'xs')}<span><strong>${b.result === 'win' ? '<span style="color:var(--success)">W</span>' : b.result === 'loss' ? '<span style="color:var(--danger)">L</span>' : 'D'} vs ${_aEsc(b.opponent_fighter_name)}</strong><small>${_aEsc(b.day)} · rounds ${b.rounds_won}/${b.rounds_total}</small></span>`, 'arena-opponent')).join('')}</div></section>` : ''}-- <section class="arena-challenge">-- <h2>Pick an opponent</h2>-- <div class="arena-opponents" id="arena-opponents">${_aSpinner('Scouting…')}</div>-- </section>`;++ ${rivalries.length ? `<section class="arena-challenge"><h2><i class="fa-solid fa-skull-crossbones"></i> Rivalries</h2><div class="arena-rivalries">${rivalries.map(r => `<div class="arena-rivalry ${r.open ? 'is-open' : ''}">${_aBriefChip(r.opponent, ` · ${r.wins}–${r.losses} in ${r.fights}${r.open ? ' · <b>beef open</b>' : ''}`)}${r.receipts.map(x => `<q class="arena-receipt-mini">${_aEsc(x.text)}</q>`).join('')}</div>`).join('')}</div></section>` : ''}++ ${beefs.length ? `<section class="arena-challenge"><h2><i class="fa-solid fa-fire-flame-curved"></i> Beefs</h2><div class="arena-beef-grid">${beefs.map(_aBeefCard).join('')}</div></section>` : `<section class="arena-challenge"><h2><i class="fa-solid fa-fire-flame-curved"></i> Beefs</h2><p class="arena-note">No beef on record. Someone only has to say their name…</p></section>`}`;_aBindSpeak(root);++ _aBindHome(root);++ _aEvery(1000, () => _aTickClocks(root));-- // Stat drill-downroot.querySelectorAll('.arena-quip.is-clickable').forEach(el => el.addEventListener('click', async () => {const stat = el.dataset.stat;const box = document.getElementById('arena-stat-detail');@@@ -419,20 +784,9 @@ async function _aRenderFighter(root, username) {} catch (err) { box.innerHTML = `<div class="arena-stat-detail">${_aEsc(err?.message || 'Failed')}</div>`; }}));-- // Portrait lightbox ("how this was painted")const portrait = root.querySelector('.arena-portrait-lg');if (portrait && f.image_url) portrait.addEventListener('click', () => _aLightbox(f));-- // Opponent picker-- try {-- const data = _arenaRoster || await api('/arena/fighters');-- _arenaRoster = data;-- const others = (data.fighters || []).filter(o => o.user.id !== f.user.id);-- const el = document.getElementById('arena-opponents');-- el.innerHTML = others.map(o => _aA(_aBattleLink(f.user, o.user), `${_aPortrait(o, 'xs')}<span><strong>${_aEsc(o.persona.fighter_name)}</strong><small>#${o.rank} · PWR ${o.ratings.power}${o.live ? ' · LIVE' : ''}</small></span>`, 'arena-opponent')).join('') || '<p class="arena-note">Nobody else on the roster yet.</p>';-- } catch { /* */ }---- // Poll for the AI portrait while it is being painted.if (f.image_pending || (!f.image_url && f.image_generation === 'ai')) {let tries = 0;_arenaImagePoll = setInterval(async () => {@@@ -442,7 +796,7 @@ async function _aRenderFighter(root, username) {if (fresh.image_url) {const holder = document.getElementById('arena-profile-portrait');if (holder) {-- holder.querySelector('.arena-portrait')?.outerHTML && (holder.querySelector('.arena-portrait').outerHTML = _aPortrait(fresh, 'lg'));++ const old = holder.querySelector('.arena-portrait'); if (old) old.outerHTML = _aPortrait(fresh, 'lg');holder.querySelector('.arena-portrait-pending')?.remove();holder.querySelector('.arena-portrait-lg')?.addEventListener('click', () => _aLightbox(fresh));}@@@ -473,467 +827,6 @@ function _aLightbox(f) {document.body.appendChild(box);}--// ── Battle ───────────────────────────────────────────────────----function _aTape(A, B) {-- const rows = [-- ['Rank', `#${A.rank}`, `#${B.rank}`, A.rank < B.rank ? 'a' : B.rank < A.rank ? 'b' : null],-- ['Power', A.ratings.power, B.ratings.power],-- ['Hours live (90d)', A.raw.hours, B.raw.hours],-- ['Peak viewers', A.raw.peak_viewers, B.raw.peak_viewers],-- ['Avg viewers', A.raw.avg_viewers, B.raw.avg_viewers],-- ['Chat msgs / hr', A.raw.messages_per_hour, B.raw.messages_per_hour],-- ['Followers', A.raw.followers, B.raw.followers],-- ['Clips', A.raw.clips, B.raw.clips],-- ['Talk time %', A.raw.voice?.talk_ratio_pct ?? 0, B.raw.voice?.talk_ratio_pct ?? 0],-- ['Hype words / hr', A.raw.voice?.hype_per_hour ?? 0, B.raw.voice?.hype_per_hour ?? 0],-- ['Record', `${A.record.wins}–${A.record.losses}`, `${B.record.wins}–${B.record.losses}`, A.record.wins > B.record.wins ? 'a' : B.record.wins > A.record.wins ? 'b' : null],-- ];-- return `<details class="arena-tape"><summary><i class="fa-solid fa-scale-balanced"></i> Tale of the tape</summary><table>-- <thead><tr><th>${_aEsc(A.persona.fighter_name)}</th><th></th><th>${_aEsc(B.persona.fighter_name)}</th></tr></thead>-- <tbody>${rows.map(([label, a, b, better]) => {-- const win = better !== undefined ? better : (Number(a) > Number(b) ? 'a' : Number(b) > Number(a) ? 'b' : null);-- return `<tr><td class="${win === 'a' ? 'is-better' : ''}">${_aEsc(_aNum(a) === 'NaN' ? a : (typeof a === 'number' ? _aNum(a) : a))}</td><td class="arena-tape-label">${_aEsc(label)}</td><td class="${win === 'b' ? 'is-better' : ''}">${_aEsc(typeof b === 'number' ? _aNum(b) : b)}</td></tr>`;-- }).join('')}</tbody></table></details>`;--}----async function _aRenderBattle(root, a, b) {-- root.innerHTML = `<div class="arena-loading arena-loading-battle"><i class="fa-solid fa-bolt fa-beat"></i><span>Fighters are entering the arena…</span><small>the announcer is warming up</small></div>`;-- const battle = await api(`/arena/battle/${encodeURIComponent(a)}/${encodeURIComponent(b)}`);-- const A = battle.a, B = battle.b;-- 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'))}">-- ${_aA(_aFighterLink(f.user), _aPortrait(f, 'md'))}-- <h2>${_aEsc(f.persona.fighter_name)}</h2>-- <p class="arena-title">${_aEsc(f.persona.title)}</p>-- <p class="arena-handle">${_aEsc(f.user.display_name)} · ${_aEsc(f.persona.class)} · #${f.rank}</p>-- <div class="arena-power"><b>${f.ratings.power}</b><small>PWR</small></div>-- ${_aRatingBars(f.ratings)}-- <p class="arena-side-taunt">“${_aEsc(f.persona.taunt)}” ${_aSpeakBtn(f.persona.taunt)}</p>-- ${f.walkout ? `<p class="arena-walkout"><i class="fa-solid fa-microphone"></i> Walkout line: <q>${_aEsc(f.walkout.text)}</q> ${f.walkout.vod_id ? `<a href="/vod/${f.walkout.vod_id}?t=${f.walkout.start_sec}" onclick="return handleLinkClick(event, '/vod/${f.walkout.vod_id}?t=${f.walkout.start_sec}')" title="Hear them say it"><i class="fa-solid fa-play"></i> hear it</a>` : ''}</p>` : ''}-- </div>`;-- const h = battle.history || { fights: 0 };-- root.innerHTML = `-- <div class="arena-back">${_aA('/arena', '<i class="fa-solid fa-arrow-left"></i> Arena')} <span class="arena-note">Battle of ${_aEsc(battle.day)} — same matchup, same rounds all day; the crowd vote is the last round.</span></div>-- <div class="arena-battle">-- ${side(A, 'a')}-- <div class="arena-center">-- <div class="arena-vs">VS</div>-- <div class="arena-scoreboard" id="arena-scoreboard"><span id="arena-score-a">0</span><span class="arena-score-sep">–</span><span id="arena-score-b">0</span></div>-- <div class="arena-history">${h.fights ? `All-time: ${_aEsc(A.persona.fighter_name)} ${h.a_wins} – ${h.b_wins} ${_aEsc(B.persona.fighter_name)} (${h.fights} fight${h.fights === 1 ? '' : 's'})` : 'First meeting'}</div>-- <div class="arena-commentary" id="arena-commentary"></div>-- <div class="arena-vote" id="arena-vote"></div>-- ${_aTape(A, B)}-- <div class="arena-battle-actions">-- <button class="btn btn-ghost" id="arena-share"><i class="fa-solid fa-link"></i> Share</button>-- ${_aA(_aBattleLink(B.user, A.user), '<i class="fa-solid fa-right-left"></i> Swap corners', 'btn btn-ghost')}-- <button class="btn btn-ghost" id="arena-rematch"><i class="fa-solid fa-dice"></i> Random rematch</button>-- <button class="btn btn-ghost" id="arena-replay"><i class="fa-solid fa-rotate-left"></i> Replay</button>-- </div>-- </div>-- ${side(B, 'b')}-- </div>`;-- _aBindSpeak(root);-- _aPlayBattle(battle);-- document.getElementById('arena-share').addEventListener('click', async () => {-- const url = `${location.origin}${_aBattleLink(A.user, B.user)}`;-- try { await navigator.clipboard.writeText(url); document.getElementById('arena-share').innerHTML = '<i class="fa-solid fa-check"></i> Copied'; } catch { prompt('Battle link', url); }-- });-- document.getElementById('arena-replay').addEventListener('click', () => { for (const t of _arenaBattleTimers) clearTimeout(t); _arenaBattleTimers = []; _aPlayBattle(battle); });-- document.getElementById('arena-rematch').addEventListener('click', async () => {-- const data = _arenaRoster || await api('/arena/fighters');-- _arenaRoster = data;-- const pool = (data.fighters || []).filter(f => f.user.id !== A.user.id);-- if (!pool.length) return;-- navigate(_aBattleLink(A.user, pool[Math.floor(Math.random() * pool.length)].user));-- });--}----/** 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');-- box.innerHTML = ''; sa.textContent = '0'; sb.textContent = '0';-- document.getElementById('arena-vote').innerHTML = '';-- let a = 0, b = 0;-- const line = (html, cls = '', detail = null) => {-- const d = document.createElement('div');-- d.className = `arena-line ${cls}${detail ? ' is-expandable' : ''}`;-- d.innerHTML = html + (detail ? `<div class="arena-line-detail">${detail}</div>` : '');-- if (detail) d.addEventListener('click', () => d.classList.toggle('is-open'));-- box.appendChild(d); box.scrollTop = box.scrollHeight;-- };-- const at = (ms, fn) => _arenaBattleTimers.push(setTimeout(fn, ms));-- let t = 0;-- line(`<i class="fa-solid fa-microphone-lines"></i> ${_aEsc(c.intro)}`, 'arena-line-intro');-- 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;-- const meta = battle.stat_meta?.[r.stat];-- const detail = `${_aEsc(ARENA_STAT_LABEL[r.stat])} rating ${A.ratings[r.stat]} vs ${B.ratings[r.stat]} (${_aEsc(meta?.desc || '')}) · roll ${r.a} vs ${r.b}, margin ${r.margin}${r.upset ? ' · the lower rating won this one' : ''} · tap to collapse`;-- line(`<span class="arena-round-tag">R${i + 1} · ${_aEsc(r.label)}</span> ${_aEsc(c.rounds[i] || `${winner.persona.fighter_name} takes it.`)} <span class="arena-round-score">${r.a}–${r.b}${r.upset ? ' · UPSET' : ''}</span>`, `arena-line-${r.winner}${r.upset ? ' arena-line-upset' : ''}`, detail);-- document.querySelector(`.arena-side-${r.winner}`)?.classList.add('is-hit');-- setTimeout(() => document.querySelector(`.arena-side-${r.winner}`)?.classList.remove('is-hit'), 600);-- });-- });-- 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)} ${_aSpeakBtn(`${c.intro} ${c.rounds.join(' ')} ${c.finisher} ${c.verdict}`)}`, 'arena-line-verdict');-- _aBindSpeak(box);-- _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);--}----// ── Trash Talk ───────────────────────────────────────────────----const ARENA_TALK_SCORES = [['spice', 'Spice', 'fa-pepper-hot'], ['wit', 'Wit', 'fa-brain'], ['on_topic', 'On topic', 'fa-bullseye'], ['delivery', 'Delivery', 'fa-microphone'], ['crowd', 'Crowd', 'fa-people-group']];--let _arenaTalkTimers = [];--function _aTalkStop() { for (const t of _arenaTalkTimers) clearInterval(t); _arenaTalkTimers = []; }----function _aStampClass(stamp) { return `arena-stamp arena-stamp--${String(stamp || '').toLowerCase()}`; }--function _aCountdown(iso) {-- const ms = Date.parse(iso) - Date.now();-- if (ms <= 0) return 'new topic any moment';-- const h = Math.floor(ms / 3600000), m = Math.floor((ms % 3600000) / 60000);-- return h ? `${h}h ${m}m left` : `${m}m left`;--}----/** Topic card on the Arena home. */--function _aRenderTalkCard(t) {-- const el = document.getElementById('arena-talk-card');-- if (!el || !t || !t.topic) return;-- const top = t.entries && t.entries[0];-- el.style.display = '';-- el.innerHTML = `-- <div class="arena-talk-card-head"><h2><i class="fa-solid fa-microphone-lines"></i> Trash Talk <small>${_aEsc(_aCountdown(t.topic.ends_at))}</small></h2>${_aA('/arena/talk', `<i class="fa-solid fa-fire"></i> ${t.can_enter ? 'Enter now' : 'Open'}`, 'btn btn-primary')}</div>-- <p class="arena-talk-topic">“${_aEsc(t.topic.topic)}”</p>-- <div class="arena-talk-card-foot">-- <span>${t.entries?.length ? `${t.entries.length} entr${t.entries.length === 1 ? 'y' : 'ies'}` : 'No entries yet — be first'}${top ? ` · leading: <b>${_aEsc(top.fighter_name)}</b> ${top.total}/50 <span class="${_aStampClass(top.stamp)}">${_aEsc(top.stamp)}</span>` : ''}</span>-- <span class="arena-note">Streamers talk on the mic or type it · the AI judges · chat types <code>!hype</code> · up to +${t.rules?.bonus_max || 12} POWER</span>-- </div>`;--}----function _aScoreBars(scores, { animate = false } = {}) {-- if (!scores) return '';-- return `<div class="arena-talk-scores ${animate ? 'is-animated' : ''}">${ARENA_TALK_SCORES.map(([k, label, icon], i) => `-- <div class="arena-talk-score" style="--i:${i}">-- <span class="arena-talk-score-label"><i class="fa-solid ${icon}"></i> ${_aEsc(label)}</span>-- <span class="arena-talk-score-track"><span class="arena-talk-score-fill" style="width:${Math.min(100, (Number(scores[k]) || 0) * 10)}%"></span></span>-- <b>${Number(scores[k]) || 0}</b>-- </div>`).join('')}</div>`;--}----function _aTalkEntry(e, { compact = false, mine = false, hypeable = true } = {}) {-- const flagged = e.flagged;-- const jump = e.vod_id && e.start_sec != null ? `<a class="arena-talk-hear" href="/vod/${e.vod_id}?t=${e.start_sec}" onclick="return handleLinkClick(event, '/vod/${e.vod_id}?t=${e.start_sec}')" title="Hear them say it"><i class="fa-solid fa-play"></i> hear it</a>` : '';-- return `<div class="arena-talk-entry ${flagged ? 'is-flagged' : ''} ${mine ? 'is-mine' : ''}" data-id="${e.id}">-- <div class="arena-talk-entry-head">-- ${e.user ? _aA(_aFighterLink(e.user), `${_aPortrait({ user: e.user, image_url: e.image_url }, 'xs')}<span><strong>${_aEsc(e.fighter_name)}</strong><small>${_aEsc(e.user.display_name)}${e.rank ? ` · #${e.rank}` : ''} · ${e.source === 'mic' ? '<i class="fa-solid fa-microphone"></i> live mic' : '<i class="fa-solid fa-keyboard"></i> typed'}${e.topic ? ` · <em>${_aEsc(e.topic)}</em>` : ''}</small></span>`, 'arena-talk-who') : ''}-- <div class="arena-talk-total"><b>${e.total}</b><small>/50</small> <span class="${_aStampClass(e.stamp)}">${_aEsc(e.stamp || '')}</span></div>-- </div>-- ${e.text ? `<blockquote class="arena-talk-text">${_aEsc(e.text)} ${_aSpeakBtn(e.text)} ${jump}</blockquote>` : '<p class="arena-talk-void"><i class="fa-solid fa-ban"></i> Voided by the judge — crossed the line.</p>'}-- ${e.verdict ? `<p class="arena-talk-verdict"><i class="fa-solid fa-gavel"></i> ${_aEsc(e.verdict)}</p>` : ''}-- ${!compact ? _aScoreBars(e.scores) : ''}-- ${!compact && e.note && (mine || !flagged) ? `<p class="arena-talk-note">Judge's note: ${_aEsc(e.note)}</p>` : ''}-- ${!compact && !flagged ? `<div class="arena-talk-hype"><button class="btn btn-ghost btn-sm arena-hype-btn" data-id="${e.id}" ${!hypeable || !e.hype_open ? 'disabled' : ''}><i class="fa-solid fa-fire"></i> Hype</button><span class="arena-talk-hype-count">${e.crowd_uniques} hyped · Crowd ${e.scores?.crowd ?? 0}/10</span><span class="arena-note">or type <code>!hype</code> in their chat</span></div>` : ''}-- </div>`;--}----async function _aRenderTalk(root) {-- _aTalkStop();-- root.innerHTML = _aSpinner('Setting up the ring…');-- const t = await api('/arena/talk');-- const topic = t.topic;-- const me = (typeof currentUser !== 'undefined' && currentUser) ? currentUser : null;-- const rules = t.rules || {};-- root.innerHTML = `-- <div class="arena-back">${_aA('/arena', '<i class="fa-solid fa-arrow-left"></i> Arena')}</div>-- <div class="arena-talk-hero">-- <div class="arena-talk-hero-main">-- <div class="arena-talk-kicker"><i class="fa-solid fa-microphone-lines"></i> Trash Talk · topic changes every ${rules.topic_slot_hours || 6} h · <span id="arena-talk-countdown">${_aEsc(_aCountdown(topic.ends_at))}</span></div>-- <h1 class="arena-talk-topic-big">“${_aEsc(topic.topic)}”</h1>-- ${topic.hint ? `<p class="arena-talk-hint"><i class="fa-solid fa-lightbulb"></i> ${_aEsc(topic.hint)}</p>` : ''}-- </div>-- <div class="arena-talk-how">-- <h3>How it works</h3>-- <ol>-- <li><b>Streamers</b> on the roster answer the topic — on the <b>live mic</b> (we transcribe what you say on stream) or by <b>typing</b> it.</li>-- <li>The <b>AI judge</b> scores Spice, Wit, On-topic and Delivery (0–10 each). Slurs or cruelty void the entry.</li>-- <li><b>Viewers</b> add the fifth score: type <code>!hype</code> in that streamer's chat (or tap Hype here). 3 people = 1 point, up to 10.</li>-- <li>Your total (/50) becomes a <b>Trash Talk bonus</b> on POWER — up to +${rules.bonus_max || 12}, fading over ${rules.bonus_days || 7} days.</li>-- </ol>-- <p class="arena-note">Chat commands: <code>!talk</code> topic · <code>!hype</code> · <code>!arena</code> card · <code>!vote a|b</code> main event · <code>!fight <user></code></p>-- </div>-- </div>-- <section class="arena-sessions-live" id="arena-live-sessions" style="display:none"></section>-- <section id="arena-session-card"></section>-- <section class="arena-talk-enter" id="arena-talk-enter"></section>-- <section class="arena-board">-- <div class="arena-board-head"><h2>This topic's entries <small class="arena-note">${t.entries.length} so far</small></h2>${t.ai ? '' : '<span class="arena-note">AI judge is off — heuristic scoring</span>'}</div>-- <div class="arena-talk-list" id="arena-talk-entries">${t.entries.length ? t.entries.map(e => _aTalkEntry(e, { mine: me && e.user.id === me.id })).join('') : '<div class="arena-empty"><i class="fa-solid fa-microphone-slash"></i><p>Nobody has talked yet. The mic is right there.</p></div>'}</div>-- </section>-- ${t.hall_of_trash?.length ? `<section class="arena-board"><div class="arena-board-head"><h2><i class="fa-solid fa-trophy"></i> Hall of Trash <small class="arena-note">best lines, last 30 days</small></h2></div><div class="arena-talk-list">${t.hall_of_trash.map(e => _aTalkEntry(e, { compact: true })).join('')}</div></section>` : ''}`;-- _aBindSpeak(root);-- _aBindHype(root);-- _aRenderTalkEnter(t, me);-- _aRenderSessionCard(t, me);-- api('/arena/talk/sessions').then(r => _aRenderLiveSessions(r.sessions || [])).catch(() => {});-- _arenaTalkTimers.push(setInterval(() => { const el = document.getElementById('arena-talk-countdown'); if (el) el.textContent = _aCountdown(topic.ends_at); }, 30000));--}----function _aBindHype(root) {-- root.querySelectorAll('.arena-hype-btn').forEach(btn => btn.addEventListener('click', async () => {-- btn.disabled = true;-- try {-- const r = await api(`/arena/talk/${btn.dataset.id}/hype`, { method: 'POST' });-- const entry = btn.closest('.arena-talk-entry');-- entry.querySelector('.arena-talk-hype-count').textContent = `${r.crowd_uniques} hyped · Crowd ${r.crowd}/10`;-- entry.querySelector('.arena-talk-total').innerHTML = `<b>${r.total}</b><small>/50</small> <span class="${_aStampClass(r.stamp)}">${_aEsc(r.stamp)}</span>`;-- const crowdFill = entry.querySelectorAll('.arena-talk-score-fill')[4]; if (crowdFill) crowdFill.style.width = `${Math.min(100, r.crowd * 10)}%`;-- const crowdNum = entry.querySelectorAll('.arena-talk-score b')[4]; if (crowdNum) crowdNum.textContent = r.crowd;-- btn.innerHTML = r.added ? '<i class="fa-solid fa-fire"></i> Hyped!' : '<i class="fa-solid fa-check"></i> Already hyped';-- entry.classList.add('is-hit'); setTimeout(() => entry.classList.remove('is-hit'), 600);-- } catch (err) { btn.disabled = false; if (typeof showToast === 'function') showToast(err?.message || 'Hype failed', 'error'); }-- }));--}----/** The entry panel: live mic (countdown + hot-mic feed) or typed, then the score reveal. */--function _aRenderTalkEnter(t, me) {-- const el = document.getElementById('arena-talk-enter');-- if (!el) return;-- if (!me) { el.innerHTML = `<div class="arena-talk-gate"><i class="fa-solid fa-right-to-bracket"></i> <b>Streamers:</b> sign in to enter. <b>Viewers:</b> hype an entry below, or type <code>!hype</code> in the streamer's chat.</div>`; return; }-- if (!t.on_roster) { el.innerHTML = `<div class="arena-talk-gate"><i class="fa-solid fa-hand-fist"></i> You're not on the roster yet — stream once and you're in. Until then: hype away.</div>`; return; }-- if (t.my_entry) { el.innerHTML = `<div class="arena-talk-gate is-done"><i class="fa-solid fa-circle-check"></i> You're in this round — <b>${t.my_entry.total}/50 · ${_aEsc(t.my_entry.stamp)}</b>. Next topic in ${_aEsc(_aCountdown(t.topic.ends_at))}. Tell chat to <code>!hype</code>.</div>`; return; }-- const mic = t.mic || {};-- el.innerHTML = `-- <div class="arena-talk-panel">-- <div class="arena-talk-panel-head"><h2><i class="fa-solid fa-fire"></i> Your turn</h2><span class="arena-note">one entry per topic</span></div>-- <div class="arena-talk-modes">-- <button class="arena-talk-mode ${mic.available ? 'active' : ''}" data-mode="mic" ${mic.available ? '' : 'disabled'} title="${mic.available ? 'Say it on stream — we transcribe it' : mic.reason === 'not_live' ? 'Go live to use the live mic' : 'Live, but no transcription yet — talk for a few seconds and reload, or type it'}"><i class="fa-solid fa-microphone"></i> Live mic${mic.available ? '' : mic.reason === 'not_live' ? ' <small>(go live)</small>' : ' <small>(no transcript yet)</small>'}</button>-- <button class="arena-talk-mode ${mic.available ? '' : 'active'}" data-mode="text"><i class="fa-solid fa-keyboard"></i> Type it</button>-- </div>-- <div class="arena-talk-mode-body" id="arena-talk-mode-body"></div>-- <div class="arena-talk-result" id="arena-talk-result"></div>-- </div>`;-- const body = document.getElementById('arena-talk-mode-body');-- const renderMode = (mode) => {-- el.querySelectorAll('.arena-talk-mode').forEach(b => b.classList.toggle('active', b.dataset.mode === mode));-- if (mode === 'text') {-- body.innerHTML = `<textarea id="arena-talk-text" maxlength="${t.rules.text_max}" rows="3" placeholder="Talk your talk… (${t.rules.text_max} characters, PG-13, petty not cruel)"></textarea>-- <div class="arena-talk-actions"><span class="arena-note" id="arena-talk-count">0 / ${t.rules.text_max}</span><button class="btn btn-primary" id="arena-talk-submit"><i class="fa-solid fa-gavel"></i> Send it to the judge</button></div>`;-- const ta = document.getElementById('arena-talk-text');-- ta.addEventListener('input', () => { document.getElementById('arena-talk-count').textContent = `${ta.value.length} / ${t.rules.text_max}`; });-- document.getElementById('arena-talk-submit').addEventListener('click', () => submit('text', ta.value));-- } else {-- body.innerHTML = `<div class="arena-mic">-- <div class="arena-mic-status" id="arena-mic-status"><span class="arena-mic-dot"></span> Ready. Press start, then say it on stream — you have ${t.rules.mic_window_sec}s.</div>-- <div class="arena-mic-feed" id="arena-mic-feed"><span class="arena-note">Hot mic — what the transcription hears shows up here (a few seconds behind).</span></div>-- <div class="arena-talk-actions"><button class="btn btn-primary" id="arena-mic-start"><i class="fa-solid fa-microphone"></i> I'm about to cook</button><button class="btn btn-ghost" id="arena-mic-drop" disabled><i class="fa-solid fa-hand-point-down"></i> Drop the mic</button></div>-- </div>`;-- let poll = null;-- document.getElementById('arena-mic-start').addEventListener('click', async () => {-- try {-- await api('/arena/talk/mic/start', { method: 'POST' });-- document.getElementById('arena-mic-start').disabled = true; document.getElementById('arena-mic-drop').disabled = false;-- const status = document.getElementById('arena-mic-status'), feed = document.getElementById('arena-mic-feed');-- status.classList.add('is-live');-- poll = setInterval(async () => {-- try {-- const f = await api('/arena/talk/mic/feed');-- if (!f.active) return;-- status.innerHTML = `<span class="arena-mic-dot"></span> LIVE MIC · ${f.remaining_sec}s left · ${f.lines.length} line${f.lines.length === 1 ? '' : 's'} heard`;-- if (f.lines.length) feed.innerHTML = f.lines.map(l => `<div class="arena-mic-line"><span>${_aStamp(l.at)}</span>${_aEsc(l.text)}</div>`).join('');-- if (f.remaining_sec <= 0) { clearInterval(poll); poll = null; submit('mic'); }-- } catch { /* */ }-- }, 2500);-- _arenaTalkTimers.push(poll);-- } catch (err) { if (typeof showToast === 'function') showToast(err?.message || 'Could not start', 'error'); }-- });-- document.getElementById('arena-mic-drop').addEventListener('click', () => { if (poll) { clearInterval(poll); poll = null; } submit('mic'); });-- }-- };-- const submit = async (mode, text) => {-- const res = document.getElementById('arena-talk-result');-- el.querySelectorAll('button').forEach(b => b.disabled = true);-- res.innerHTML = `<div class="arena-loading"><i class="fa-solid fa-gavel fa-shake"></i><span>The judge is reading…</span></div>`;-- try {-- const r = await api('/arena/talk/submit', { method: 'POST', body: { mode, text } });-- const e = r.entry;-- body.innerHTML = '';-- res.innerHTML = `<div class="arena-talk-reveal ${e.flagged ? 'is-flagged' : ''}">-- <div class="arena-talk-reveal-head"><span class="arena-talk-reveal-total"><b id="arena-reveal-total">0</b><small>/50</small></span><span class="${_aStampClass(e.stamp)} arena-stamp--big" id="arena-reveal-stamp">${_aEsc(e.stamp)}</span></div>-- <blockquote class="arena-talk-text">${_aEsc(e.text || '')}</blockquote>-- ${_aScoreBars(e.scores || { spice: 0, wit: 0, on_topic: 0, delivery: 0, crowd: 0 }, { animate: true })}-- <p class="arena-talk-verdict"><i class="fa-solid fa-gavel"></i> ${_aEsc(e.verdict || '')}</p>-- ${e.note ? `<p class="arena-talk-note">Judge's note: ${_aEsc(e.note)}</p>` : ''}-- <p class="arena-note">${e.flagged ? 'This entry was voided and is hidden from others.' : `Now tell chat to type <code>!hype</code> — every 3 people add a Crowd point. Bonus lands on your POWER within a minute.`}</p>-- </div>`;-- const totalEl = document.getElementById('arena-reveal-total');-- const start = performance.now(), dur = 1400;-- const tick = (now) => { const p = Math.min(1, (now - start) / dur); totalEl.textContent = (e.total * (1 - Math.pow(1 - p, 3))).toFixed(p < 1 ? 1 : 0); if (p < 1) requestAnimationFrame(tick); else totalEl.textContent = e.total; };-- requestAnimationFrame(tick);-- const list = document.getElementById('arena-talk-entries');-- if (list) { const empty = list.querySelector('.arena-empty'); if (empty) empty.remove(); list.insertAdjacentHTML('afterbegin', _aTalkEntry(e, { mine: true, hypeable: false })); _aBindSpeak(list); _aBindHype(list); }-- } catch (err) {-- res.innerHTML = `<div class="arena-talk-gate is-error"><i class="fa-solid fa-triangle-exclamation"></i> ${_aEsc(err?.message || 'The judge walked out.')}</div>`;-- el.querySelectorAll('button').forEach(b => b.disabled = false);-- }-- };-- el.querySelectorAll('.arena-talk-mode').forEach(b => b.addEventListener('click', () => renderMode(b.dataset.mode)));-- renderMode(mic.available ? 'mic' : 'text');--}----// ── Live trash-talk sessions ─────────────────────────────────----function _aSessionLink(u) { return `/arena/talk/${encodeURIComponent(u.username)}`; }----function _aRenderLiveSessions(list) {-- const el = document.getElementById('arena-live-sessions');-- if (!el) return;-- if (!list.length) { el.style.display = 'none'; return; }-- el.style.display = '';-- el.innerHTML = `<h2><span class="arena-live-dot"></span> Talking trash right now</h2><div class="arena-session-strip">${list.map(s => _aA(_aSessionLink(s.user), `<span class="arena-live-dot"></span><span><strong>${_aEsc(s.fighter_name)}</strong><small>${_aEsc(s.topic || '…')} · ${s.progress}%</small></span><span class="arena-session-lvl">LVL ${s.level}</span>`, 'arena-session-chip')).join('')}</div>`;--}----/** Streamer-side card on /arena/talk: start a live session, or jump to the running one. */--async function _aRenderSessionCard(t, me) {-- const el = document.getElementById('arena-session-card');-- if (!el || !me || !t.on_roster) return;-- let view = null;-- try { view = await api(`/arena/talk/session/${encodeURIComponent(me.username)}`); } catch { view = null; }-- const live = view?.session && view.session.status === 'live';-- const mic = t.mic || {};-- el.innerHTML = `<div class="arena-talk-panel">-- <div class="arena-talk-panel-head"><h2><i class="fa-solid fa-tower-broadcast"></i> Live session <span class="arena-note" style="font-weight:400">power-level by talking trash on stream</span></h2>${live ? '<span class="arena-session-status"><span class="arena-live-dot"></span> live now</span>' : ''}</div>-- <p class="arena-note" style="margin:0 0 10px">Start a session while you're live and the transcription does the rest: talk trash on the current topic, the judge scores it every ~30 s, the bar fills, the topic changes when you've cleared it, and every cleared topic counts for your POWER. Viewers push it with <code>!hype</code>.</p>-- <div class="arena-session-controls">-- ${live ? _aA(_aSessionLink(me), '<i class="fa-solid fa-satellite-dish"></i> Open your live console', 'btn btn-primary')-- : `<button class="btn btn-primary" id="arena-session-start" ${mic.available ? '' : 'disabled'} title="${mic.available ? '' : mic.reason === 'not_live' ? 'Go live first' : 'Live, but no transcription yet — talk for a few seconds and reload'}"><i class="fa-solid fa-play"></i> Start live session${mic.available ? '' : mic.reason === 'not_live' ? ' <small>(go live first)</small>' : ' <small>(waiting for transcription)</small>'}</button>`}-- ${view?.session && !live ? `<span class="arena-note">Last session: level ${view.session.level}, ${view.session.topics_cleared} topic${view.session.topics_cleared === 1 ? '' : 's'} cleared${view.session.end_reason ? ` (${_aEsc(String(view.session.end_reason).replace('_', ' '))})` : ''}</span>` : ''}-- </div>-- </div>`;-- el.querySelector('#arena-session-start')?.addEventListener('click', async (e) => {-- e.target.disabled = true;-- try { await api('/arena/talk/session/start', { method: 'POST' }); navigate(_aSessionLink(me)); }-- catch (err) { e.target.disabled = false; if (typeof showToast === 'function') showToast(err?.message || 'Could not start', 'error'); }-- });--}----async function _aRenderSession(root, username) {-- _aTalkStop();-- root.innerHTML = _aSpinner('Tuning in…');-- let v;-- try { v = await api(`/arena/talk/session/${encodeURIComponent(username)}`); }-- catch (err) { root.innerHTML = `<div class="arena-empty"><i class="fa-solid fa-user-slash"></i><p>${_aEsc(err?.message || 'No such fighter')}</p></div>`; return; }-- const me = (typeof currentUser !== 'undefined' && currentUser) ? currentUser : null;-- const mine = !!(me && me.id === v.user.id);-- if (!v.session) {-- root.innerHTML = `<div class="arena-back">${_aA('/arena/talk', '<i class="fa-solid fa-arrow-left"></i> Trash Talk')}</div><div class="arena-empty"><i class="fa-solid fa-microphone-slash"></i><p><strong>${_aEsc(v.fighter_name)}</strong> hasn't run a live trash-talk session yet.${mine ? ' Start one from the Trash Talk page while you are live.' : ''}</p>${_aA('/arena/talk', 'Back', 'btn')}</div>`;-- return;-- }-- let last = null;-- const render = (s) => {-- const t = s.active_topic;-- const live = s.status === 'live';-- const xpInLevel = s.xp - (s.level - 1) * s.xp_per_level;-- const prev = last; last = s;-- if (prev && s.level > prev.level) _aLevelUp(s.level);-- const clearedFlash = prev && s.topics_cleared > prev.topics_cleared;-- const lines = s.recent_lines || [];-- const newestAt = prev ? Math.max(0, ...(prev.recent_lines || []).map(l => l.at)) : -1;-- root.innerHTML = `-- <div class="arena-back">${_aA('/arena/talk', '<i class="fa-solid fa-arrow-left"></i> Trash Talk')} ${_aA(_aFighterLink(v.user), `<i class="fa-solid fa-id-card"></i> ${_aEsc(v.fighter_name)}`)}</div>-- <div class="arena-session">-- <div class="arena-session-main">-- <div class="arena-session-head">-- <div class="arena-session-who">${_aPortrait({ user: v.user, image_url: v.image_url }, 'sm')}<div><strong>${_aEsc(v.fighter_name)}</strong><div class="arena-session-status ${live ? '' : 'is-ended'}">${live ? '<span class="arena-live-dot"></span> talking trash live' : `session ended${s.end_reason ? ' · ' + _aEsc(String(s.end_reason).replace('_', ' ')) : ''}`}</div></div></div>-- <div class="arena-session-level"><div><b>LVL ${s.level}</b><small>TRASH LEVEL</small></div><div class="arena-xp"><div class="arena-xp-track"><span class="arena-xp-fill" style="width:${Math.min(100, (xpInLevel / s.xp_per_level) * 100)}%"></span></div><div class="arena-xp-label">${s.xp} XP · ${s.next_level_xp - s.xp} to next</div></div></div>-- </div>-- ${t ? `<div class="arena-session-topic ${clearedFlash ? 'is-cleared-flash' : ''}">-- <div class="arena-session-topic-kicker">Topic ${t.idx + 1} · ${_aEsc(t.tone || '')} <span>${live ? 'talk about this — the judge listens every ~30 s' : 'session over'}</span></div>-- <h2>“${_aEsc(t.topic)}”</h2>-- ${t.hint ? `<p class="arena-session-hint"><i class="fa-solid fa-lightbulb"></i> ${_aEsc(t.hint)}</p>` : ''}-- <div class="arena-progress"><span class="arena-progress-fill" style="width:${t.progress}%"></span></div>-- <div class="arena-progress-label"><span>${t.progress}% cleared · ${t.hits} hit${t.hits === 1 ? '' : 's'} · ${t.hypers} hyping</span><span>${t.progress >= 100 ? 'CLEARED' : `${100 - t.progress}% to the next topic`}</span></div>-- ${t.last_judgement ? `<div class="arena-session-verdict"><span class="${t.last_judgement.is_trash_talk ? 'is-hit' : 'is-miss'}">${t.last_judgement.is_trash_talk ? '<i class="fa-solid fa-fire"></i> that was trash talk' : '<i class="fa-solid fa-ellipsis"></i> not trash talk (yet)'}</span> · quality ${t.last_judgement.quality}/10 · “${_aEsc(t.last_judgement.about || '')}”${t.last_judgement.fallback ? ' · heuristic judge' : ''}</div>` : (live ? '<div class="arena-session-verdict"><span class="is-miss"><i class="fa-solid fa-ear-listen"></i> listening…</span> say something spicy</div>' : '')}-- ${t.best_line ? `<p class="arena-talk-note">Best line so far: “${_aEsc(t.best_line)}” ${t.best_vod_id ? `<a class="arena-talk-hear" href="/vod/${t.best_vod_id}?t=${t.best_line_sec}" onclick="return handleLinkClick(event, '/vod/${t.best_vod_id}?t=${t.best_line_sec}')"><i class="fa-solid fa-play"></i> hear it</a>` : ''}</p>` : ''}-- <div class="arena-session-controls">-- ${mine && live ? `<button class="btn btn-ghost" id="arena-session-skip"><i class="fa-solid fa-forward"></i> Skip topic</button><button class="btn btn-ghost" id="arena-session-stop"><i class="fa-solid fa-stop"></i> End session</button>` : ''}-- ${!mine && live ? `<button class="btn btn-primary" id="arena-session-hype"><i class="fa-solid fa-fire"></i> Hype</button><span class="arena-note">or type <code>!hype</code> in their chat</span>` : ''}-- ${_aA(_aChannelLink(v.user), '<i class="fa-solid fa-tv"></i> Watch the stream', 'btn btn-ghost')}-- </div>-- </div>` : ''}-- <div class="arena-session-feed">-- <div class="arena-session-feed-head"><span><i class="fa-solid fa-microphone"></i> Hot mic</span><span>${s.lines_seen} lines · ${_aNum(s.words_seen)} words this session</span></div>-- ${lines.length ? lines.map(l => `<div class="arena-session-line ${l.at > newestAt ? 'is-new' : ''}"><span>${_aStamp(l.at)}</span><span>${_aEsc(l.text)}</span>${l.vod_id ? `<a href="/vod/${l.vod_id}?t=${Math.max(0, l.sec - 2)}" onclick="return handleLinkClick(event, '/vod/${l.vod_id}?t=${Math.max(0, l.sec - 2)}')" title="Hear it"><i class="fa-solid fa-play"></i></a>` : '<span></span>'}</div>`).join('') : '<p class="arena-voice-empty">Nothing heard yet — the transcription runs a few seconds behind.</p>'}-- </div>-- </div>-- <div class="arena-session-side">-- <div class="arena-session-stats"><div class="arena-session-stat"><b>${s.topics_cleared}</b><span>topics cleared</span></div><div class="arena-session-stat"><b>${s.xp}</b><span>xp</span></div><div class="arena-session-stat"><b>${v.talk_bonus || 0}</b><span>power bonus</span></div></div>-- <div class="arena-session-card"><h3><i class="fa-solid fa-tags"></i> What they talked about</h3>${s.talked_about.length ? `<div class="arena-about">${s.talked_about.map(a => `<span class="${a.hit ? 'is-hit' : ''}" title="topic ${a.topic_idx + 1}">${_aEsc(a.text)}</span>`).join('')}</div>` : '<p class="arena-voice-empty">Tags appear after the first judged chunk.</p>'}</div>-- <div class="arena-session-card"><h3><i class="fa-solid fa-list-check"></i> Topics this session</h3>${s.cleared_topics.length ? `<div class="arena-cleared-list">${s.cleared_topics.slice().reverse().map(c => `<div class="arena-cleared ${c.status === 'skipped' ? 'is-skipped' : ''}"><b>${c.status === 'cleared' ? '<i class="fa-solid fa-check"></i>' : '<i class="fa-solid fa-forward"></i>'} ${_aEsc(c.topic)}</b>${c.best_line ? `<q>${_aEsc(c.best_line)}</q> ${c.best_vod_id ? `<a class="arena-talk-hear" href="/vod/${c.best_vod_id}?t=${c.best_line_sec}" onclick="return handleLinkClick(event, '/vod/${c.best_vod_id}?t=${c.best_line_sec}')"><i class="fa-solid fa-play"></i></a>` : ''}` : ''}<small>${c.status === 'cleared' ? `cleared · score ${c.score} · ${c.hits} hit${c.hits === 1 ? '' : 's'}` : 'skipped'}</small></div>`).join('')}</div>` : '<p class="arena-voice-empty">No topics cleared yet.</p>'}</div>-- </div>-- </div>`;-- root.querySelector('#arena-session-skip')?.addEventListener('click', async () => { try { await api('/arena/talk/session/skip', { method: 'POST' }); refresh(); } catch (err) { if (typeof showToast === 'function') showToast(err?.message, 'error'); } });-- root.querySelector('#arena-session-stop')?.addEventListener('click', async () => { if (!confirm('End your trash-talk session?')) return; try { await api('/arena/talk/session/stop', { method: 'POST' }); refresh(); } catch (err) { if (typeof showToast === 'function') showToast(err?.message, 'error'); } });-- root.querySelector('#arena-session-hype')?.addEventListener('click', async (e) => {-- e.target.disabled = true;-- try { const r = await api(`/arena/talk/session/${encodeURIComponent(v.user.username)}/hype`, { method: 'POST' }); e.target.innerHTML = r.added ? '<i class="fa-solid fa-fire"></i> Hyped!' : '<i class="fa-solid fa-check"></i> Already hyped'; refresh(); }-- catch (err) { e.target.disabled = false; if (typeof showToast === 'function') showToast(err?.message || 'Hype failed', 'error'); }-- });-- };-- const refresh = async () => {-- if (currentPage !== 'arena') return _aTalkStop();-- try { const nv = await api(`/arena/talk/session/${encodeURIComponent(username)}`); if (nv.session) { v = nv; render(nv.session); } } catch { /* keep */ }-- };-- render(v.session);-- if (v.session.status === 'live') _arenaTalkTimers.push(setInterval(refresh, 4000));--}--function _aLevelUp(level) {const el = document.createElement('div');el.className = 'arena-levelup';@@@ -1,77 +1,138 @@/**-- * OpenVibe.Live — Arena chat commands (viewers play along from chat)++ * OpenVibe.Live — Arena chat commands (viewers play along from any stream chat)*-- * !hype hype the streamer's latest trash-talk entry (one per person; Crowd score)-- * !talk show the current trash-talk topic + how to enter-- * !arena [user] fighter card summary + link (defaults to the streamer whose chat this is)-- * !vote a|b vote in today's Main Event (signed-in only)-- * !fight <user> post a battle link: this streamer vs <user>++ * !topic <text> start a board topic from chat (one per person per 10 min)++ * !bounty <user> put a bounty on a fighter: double XP for anyone who talks shit about them (1/hour)++ * !hype hype the streamer: their newest open beef, else their active board topic++ * !side <name|a|b> pick a side in the streamer's newest open beef (clout if your pick wins)++ * !beef what beefs this streamer has open + clocks++ * !board the pulse + hottest events on the board++ * !arena [user] fighter card summary + link** Called from ChatServer.handleBangCommand; returns true when the command was handled.-- * Replies go to the sender as a system line; hype milestones are announced to the room.++ * Replies go to the sender as a system line; milestones are announced to the room.*/'use strict';const db = require('../db/database');++const COMMANDS = ['!topic', '!bounty', '!hype', '!side', '!beef', '!board', '!arena'];const RATE_MS = 4000;--const _last = new Map(); // voterKey → ms++const TOPIC_RATE_MS = 10 * 60 * 1000;++const BOUNTY_RATE_MS = 60 * 60 * 1000;++const _last = new Map(); // voterKey → ms++const _lastTopic = new Map(); // voterKey → ms++const _lastBounty = new Map();function voterKey(client) {if (client.user?.id) return `user:${client.user.id}`;if (client.anonId) return `anon:${client.anonId}`;return `ip:${String(client.ip || '')}`;}----function rateLimited(key) {++function limited(map, key, ms) {const now = Date.now();-- if (now - (_last.get(key) || 0) < RATE_MS) return true;-- _last.set(key, now);++ if (now - (map.get(key) || 0) < ms) return true;++ map.set(key, now);return false;}----function base() {-- try { const c = require('../config'); return String(c.baseUrl || '').replace(/\/$/, ''); } catch { return ''; }++function base() { try { const c = require('../config'); return String(c.baseUrl || '').replace(/\/$/, ''); } catch { return ''; } }++function clock(b) {++ if (!b.on_clock || b.clock_seconds_left == null) return '';++ const s = b.clock_seconds_left, who = (b.on_clock === 'a' ? b.a : b.b).fighter_name;++ const t = s >= 3600 ? `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m` : `${Math.floor(s / 60)}m ${s % 60}s`;++ return ` · ${who} on the clock (${t} to answer)`;}++function beefLine(b) { return `${b.a.fighter_name} ${b.share_a}% — ${100 - b.share_a}% ${b.b.fighter_name}${clock(b)} → ${base()}/arena/beef/${b.id}`; }function handle(chat, ws, client, cmd, parts) {-- if (!['!hype', '!talk', '!arena', '!vote', '!fight'].includes(cmd)) return false;++ if (!COMMANDS.includes(cmd)) return false;const reply = (message) => chat.sendTo(ws, { type: 'system', message });-- let arena, talk;-- try { arena = require('./arena-service'); talk = require('./trash-talk'); } catch { reply('The Arena is closed right now.'); return true; }++ const room = (message) => client.streamId && chat.broadcastToStream(client.streamId, { type: 'system', message });++ let arena, board, beef;++ try { arena = require('./arena-service'); board = require('./board'); beef = require('./beef'); } catch { reply('The Arena is closed right now.'); return true; }if (!arena.arenaEnabled()) { reply('The Arena is closed right now.'); return true; }const key = voterKey(client);-- if (rateLimited(key)) { reply('Easy — one Arena command every few seconds.'); return true; }++ if (limited(_last, key, RATE_MS)) { reply('Easy — one Arena command every few seconds.'); return true; }const stream = client.streamId ? db.getStreamById(client.streamId) : null;const streamer = stream ? db.getUserById(stream.user_id) : null;++ const who = client.user?.display_name || client.user?.username || 'Chat';(async () => {try {++ if (cmd === '!topic') {++ if (!client.user?.id) return reply('Sign in to start a topic.');++ const text = parts.slice(1).join(' ').trim();++ if (!text) return reply('Usage: !topic <something the streamers should talk shit about>');++ if (limited(_lastTopic, key, TOPIC_RATE_MS)) return reply('One topic per 10 minutes — let the last one cook.');++ const onRoster = !!arena.loadRoster().byId[client.user.id];++ const t = board.createTopic({ text, createdBy: onRoster ? 'streamer' : 'chat', creatorUserId: client.user.id, creatorName: who });++ board.ensureAngles(t).catch(() => {});++ reply(`📌 Topic is on the board: “${t.text}” → ${base()}/arena/topic/${t.id}`);++ room(`📌 ${who} put a topic on the Arena board: “${t.text}” — streamers, click it and start talking. ${base()}/arena/topic/${t.id}`);++ return;++ }++ if (cmd === '!bounty') {++ if (!client.user?.id) return reply('Sign in to put up a bounty.');++ const target = parts[1] ? db.getUserByUsername(String(parts[1]).replace(/^@/, '')) : null;++ if (!target) return reply('Usage: !bounty <username> — double XP for anyone who talks shit about them on stream.');++ const roster = arena.loadRoster();++ if (!roster.byId[target.id]) return reply(`${target.display_name || target.username} isn't on the Arena roster.`);++ if (target.id === client.user.id) return reply("You can't put a bounty on yourself. Nice try.");++ if (board.openBountyOn(target.id)) return reply(`There's already a bounty on ${target.display_name || target.username}. Go collect it.`);++ if (limited(_lastBounty, key, BOUNTY_RATE_MS)) return reply('One bounty per hour per person.');++ const name = board.fighterBrief(target.id, roster).fighter_name;++ const t = board.createTopic({ text: `Bounty: ${name}. Anyone who talks shit about them on stream gets double XP.`, hint: `Put up by ${who}. Say the name, collect the bag.`, createdBy: 'chat', creatorUserId: client.user.id, creatorName: who, kind: 'bounty', targetUserId: target.id, headline: `WANTED: ${name} — chat wants smoke` });++ reply(`💰 Bounty posted on ${name} → ${base()}/arena/topic/${t.id}`);++ room(`💰 ${who} put a bounty on ${name}. Any streamer who talks shit about them on mic gets double XP for the next ${board.KIND_TTL_HOURS.bounty} hours.`);++ return;++ }if (cmd === '!hype') {-- if (!streamer) return reply('!hype works inside a streamer\'s chat.');-- // A live session (they are talking trash on stream right now) takes priority.-- try {-- const s = require('./talk-session').hypeSession(streamer.id, key);-- if (s) {-- if (!s.added) return reply(`You already hyped this topic. ${s.hypers} hyping · level ${s.level} · ${s.progress}% to the next topic.`);-- reply(`🔥 Hyped the live session! ${s.hypers} hyping · level ${s.level} (${s.xp} XP) · topic ${s.progress}% cleared → ${base()}/arena/talk/${encodeURIComponent(streamer.username)}`);-- if (s.hypers === 1 || s.hypers % 5 === 0) chat.broadcastToStream(client.streamId, { type: 'system', message: `🎤 ${streamer.display_name || streamer.username} is talking trash live — ${s.hypers} hyping. Type !hype to push the topic. ${base()}/arena/talk/${encodeURIComponent(streamer.username)}` });-- return;-- }-- } catch (e) { return reply(`Arena: ${e.message}`); }-- const entry = talk.latestEntryFor(streamer.id);-- if (!entry) return reply(`${streamer.display_name || streamer.username} hasn't entered the current Trash Talk topic yet — ${base()}/arena/talk`);-- const r = talk.hype(entry.id, key);-- if (!r.added) return reply(`You already hyped this one. Crowd score ${r.crowd}/10 (${r.crowd_uniques} hyped).`);-- reply(`🔥 Hyped! Crowd score ${r.crowd}/10 (${r.crowd_uniques} hyped) · total ${r.total}/50 · ${r.stamp}`);-- if (r.crowd_uniques === 1 || r.crowd_uniques % 5 === 0) {-- chat.broadcastToStream(client.streamId, { type: 'system', message: `🎤 Chat is hyping ${streamer.display_name || streamer.username}'s trash talk — ${r.crowd_uniques} so far. Type !hype to add yours.` });++ if (!streamer) return reply("!hype works inside a streamer's chat.");++ const open = beef.openBeefsFor(streamer.id);++ if (open.length) {++ const b = open[0];++ const side = b.a_user_id === streamer.id ? 'a' : 'b';++ const r = beef.hype(b.id, side, key);++ const v = beef.get(b.id);++ if (!r.added) return reply(`You already hyped this beef. ${beefLine(v)}`);++ reply(`🔥 Hyped ${streamer.display_name || streamer.username} in their beef. ${beefLine(v)}`);++ const n = side === 'a' ? v.a.crowd : v.b.crowd;++ if (n === 1 || n % 5 === 0) room(`🔥 Chat is hyping ${streamer.display_name || streamer.username}'s beef with ${(side === 'a' ? v.b : v.a).fighter_name} — ${n} so far. Type !hype to add yours. ${base()}/arena/beef/${v.id}`);++ return;}++ const t = board.activeTopicFor(streamer.id);++ if (!t) return reply(`${streamer.display_name || streamer.username} has no beef open and isn't on a board topic. Start one: !topic <text> · ${base()}/arena`);++ const r = board.hypeTopic(t.id, streamer.id, key);++ if (!r.added) return reply(`You already hyped them on “${t.text}”. ${r.hypers} hyping.`);++ reply(`🔥 Hyped ${streamer.display_name || streamer.username} on “${t.text}” — ${r.hypers} hyping → ${base()}/arena/topic/${t.id}`);++ if (r.hypers === 1 || r.hypers % 5 === 0) room(`🎤 ${streamer.display_name || streamer.username} is talking shit on “${t.text}” — ${r.hypers} hyping. Type !hype. ${base()}/arena/topic/${t.id}`);return;}-- if (cmd === '!talk') {-- const t = await talk.getTopic({ generate: false });-- return reply(`🎤 Trash Talk topic: "${t.topic}" — ${t.hint || ''} Streamers enter at ${base()}/arena/talk; viewers type !hype in their chat to boost the Crowd score.`);++ if (cmd === '!side') {++ if (!streamer) return reply("!side works inside a streamer's chat.");++ const open = beef.openBeefsFor(streamer.id);++ if (!open.length) return reply(`${streamer.display_name || streamer.username} has no open beef to pick a side in.`);++ const b = beef.get(open[0].id);++ const arg = String(parts[1] || '').toLowerCase().replace(/^@/, '');++ const matches = (side) => [side.user.username, side.user.display_name, side.fighter_name].filter(Boolean).some(n => String(n).toLowerCase() === arg);++ const pick = arg === 'a' || matches(b.a) ? 'a' : arg === 'b' || matches(b.b) ? 'b' : !arg ? (b.a.user.id === streamer.id ? 'a' : 'b') : null;++ if (!pick) return reply(`Usage: !side <name> — (a) ${b.a.fighter_name} vs (b) ${b.b.fighter_name}`);++ const r = beef.pickSide(b.id, key, pick, client.user?.id || null);++ return reply(`🗳️ You're with ${(pick === 'a' ? b.a : b.b).fighter_name}. Crowd: ${r.a}–${r.b}. Pick right and you earn clout. ${base()}/arena/beef/${b.id}`);++ }++ if (cmd === '!beef') {++ if (!streamer) return reply(`Open beefs → ${base()}/arena`);++ const open = beef.openBeefsFor(streamer.id).map(b => beef.get(b.id));++ if (!open.length) {++ const lvl = board.levelView(streamer.id);++ return reply(`${streamer.display_name || streamer.username} has no beef open right now (Trash Level ${lvl.level}). Another streamer only has to say their name on mic… ${base()}/arena`);++ }++ return reply(open.map(b => `🥊 ${b.headline || `${b.a.fighter_name} vs ${b.b.fighter_name}`}: ${beefLine(b)}`).join(' · '));++ }++ if (cmd === '!board') {++ const v = board.boardView();++ const top = v.open.slice(0, 3).map(t => `${t.hot ? '🔥 ' : ''}${t.kind === 'debate' ? `${t.side_a} vs ${t.side_b}` : t.kind === 'bounty' ? `Bounty: ${t.target?.fighter_name}` : t.kind === 'phrase' ? `Say “${t.phrase}”` : t.text} (${t.heat} heat)`).join(' · ');++ return reply(`📰 ${v.pulse?.text || 'The board is quiet.'} ${top ? `Hottest: ${top}` : ''} → ${base()}/arena · !topic <text> to add one`);}if (cmd === '!arena') {const target = parts[1] ? db.getUserByUsername(String(parts[1]).replace(/^@/, '')) : streamer;@@@ -79,26 +140,7 @@ function handle(chat, ws, client, cmd, parts) {const card = await arena.getFighter(target.id, { generate: false });if (!card || card.not_on_roster) return reply(`${target.display_name || target.username} isn't on the Arena roster yet.`);const r = card.ratings;-- return reply(`🥊 ${card.persona.fighter_name} — ${card.persona.title} · #${card.rank} of ${card.roster_size} · PWR ${r.power}${r.talk_bonus ? ` (+${r.talk_bonus} trash talk)` : ''} · ${card.record.wins}W–${card.record.losses}L · "${card.persona.taunt}" → ${base()}/arena/${encodeURIComponent(target.username)}`);-- }-- if (cmd === '!vote') {-- if (!client.user?.id) return reply('Sign in to vote in the Main Event.');-- const side = String(parts[1] || '').toLowerCase();-- const me = await arena.getMainEvent({ generate: false });-- if (!me) return reply('No Main Event today.');-- const pick = side === 'a' || side === me.a.user.username.toLowerCase() ? 'a' : side === 'b' || side === me.b.user.username.toLowerCase() ? 'b' : null;-- if (!pick) return reply(`Usage: !vote a|b — today: (a) ${me.a.persona.fighter_name} vs (b) ${me.b.persona.fighter_name}`);-- const r = arena.castVote(me.id, `user:${client.user.id}`, pick);-- return reply(`🗳️ Voted ${(pick === 'a' ? me.a : me.b).persona.fighter_name}. Crowd: ${r.votes.a}–${r.votes.b} · score ${r.outcome.a}–${r.outcome.b}${r.outcome.winner ? ` · ${(r.outcome.winner === 'a' ? me.a : me.b).persona.fighter_name} leads` : ''} → ${base()}/arena/battle/${encodeURIComponent(me.a.user.username)}/${encodeURIComponent(me.b.user.username)}`);-- }-- if (cmd === '!fight') {-- if (!streamer) return reply('!fight works inside a streamer\'s chat: !fight <username>');-- const opp = parts[1] ? db.getUserByUsername(String(parts[1]).replace(/^@/, '')) : null;-- if (!opp) return reply('Usage: !fight <username>');-- if (opp.id === streamer.id) return reply('They can\'t fight themselves. Yet.');-- const url = `${base()}/arena/battle/${encodeURIComponent(streamer.username)}/${encodeURIComponent(opp.username)}`;-- chat.broadcastToStream(client.streamId, { type: 'system', message: `🥊 ${client.user?.display_name || client.user?.username || 'Chat'} called out ${opp.display_name || opp.username}: ${streamer.display_name || streamer.username} vs ${opp.display_name || opp.username} → ${url}` });-- return;++ return reply(`🥊 ${card.persona.fighter_name} — ${card.persona.title} · #${card.rank} of ${card.roster_size} · PWR ${r.power}${r.talk_bonus ? ` (+${r.talk_bonus} mouth)` : ''} · Trash Level ${card.level.level} · beefs ${card.record.wins}W–${card.record.losses}L · “${card.persona.taunt}” → ${base()}/arena/${encodeURIComponent(target.username)}`);}} catch (err) {reply(`Arena: ${err.message}`);@@@ -107,4 +149,4 @@ function handle(chat, ws, client, cmd, parts) {return true;}--module.exports = { handle, _voterKey: voterKey };++module.exports = { handle, COMMANDS, _voterKey: voterKey };@@@ -14,7 +14,9 @@ const arena = require('./arena-service');const INTERVAL_MS = 20 * 60 * 1000;const BATCH = 6;const IMAGE_BATCH = 2;++const CLOCK_MS = 60 * 1000;let _timer = null;++let _clock = null;let _busy = false;async function tick() {@@@ -49,11 +51,28 @@ function start() {_timer = setInterval(() => tick().catch(e => console.warn('[Arena] job:', e.message)), INTERVAL_MS);if (_timer.unref) _timer.unref();setTimeout(() => tick().catch(() => {}), 90_000).unref?.();-- // Live trash-talk sessions read the transcript every 15 s (no-op when none are live).-- try { require('./talk-session').start(); } catch (e) { console.warn('[Arena] session ticker not started:', e.message); }-- console.log('[Arena] job started (every 20 min; session ticker every 15 s)');++ // The ears: every 15 s the listener reads live transcripts for name-drops (beefs) and topic talk.++ try { require('./listener').start(); } catch (e) { console.warn('[Arena] listener not started:', e.message); }++ // Clocks + pulse: forfeit beefs whose clock ran out, settle debates/bounties/phrases, and++ // re-read the community every 30 min so the board follows what people are actually saying.++ _clock = setInterval(() => housekeeping().catch(e => console.warn('[Arena] housekeeping:', e.message)), CLOCK_MS);++ if (_clock.unref) _clock.unref();++ setTimeout(() => housekeeping().catch(() => {}), 20_000).unref?.();++ console.log('[Arena] job started (personas every 20 min; listener every 15 s; clocks every 60 s; pulse every 30 min)');}--function stop() { if (_timer) { clearInterval(_timer); _timer = null; } }++async function housekeeping() {++ if (!arena.arenaEnabled()) return;++ const beef = require('./beef'), board = require('./board');++ try { beef.tick(); } catch (e) { console.warn('[Arena] beef tick:', e.message); }++ try { board.resolveExpired(); } catch (e) { console.warn('[Arena] resolve:', e.message); }++ try { await board.refreshPulse(); } catch (e) { console.warn('[Arena] pulse:', e.message); }++}++++function stop() {++ if (_timer) { clearInterval(_timer); _timer = null; }++ if (_clock) { clearInterval(_clock); _clock = null; }++ try { require('./listener').stop(); } catch { /* */ }++}--module.exports = { start, stop, tick };++module.exports = { start, stop, tick, housekeeping };@@@ -1,30 +1,20 @@/**-- * OpenVibe.Live — Arena (streamer vs streamer)++ * OpenVibe.Live — Arena (roster, ratings, personas, portraits, quotes)*-- * Turns the analytics + AI data the site already collects about each streamer into a-- * tongue-in-cheek fighting-game roster:++ * The Arena turns the analytics + AI data the site already collects about each streamer++ * into a fighting-game roster. Everything competitive is driven by what streamers SAY on++ * stream and what chat does — see listener.js (the ears), beef.js (streamer vs streamer)++ * and board.js (topics, angles, Trash Levels). This module owns the roster:** stats → seven 40–99 ratings (HYPE, GRIND, CHAT, LOYALTY, CLUTCH, VIBE, MIC)-- * computed as percentiles across the active roster, plus an overall POWER.-- * Deterministic and free — no AI involved. MIC comes from the continuous-- * audio transcription (stream_timeline_events): how much they talk, how-- * hyped they sound, how often the room laughs.-- * persona → AI "character select" bio (fighter name, class, signature move, taunt,-- * lore …) written from the streamer's AI overview, stream memories, chat-AI-- * notes, VOD overviews and the numbers. Cached 24 h per streamer.-- * quotes → AI-picked "things they actually said" from the transcripts, each linked-- * to the VOD at that second so people can go hear it. Cached 24 h.-- * image → optional AI character portrait. Deliberately NOT a likeness: the image-- * model gets a description of the persona, category, colours and the-- * *scene* of the latest thumbnail (setting/objects/mood — never faces or-- * 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.++ * computed as percentiles across the active roster, plus an overall POWER++ * that also carries the Trash Talk bonus (recent XP + beef wins).++ * persona → AI "character select" bio, cached 24 h.++ * quotes → AI-picked "things they actually said" from the transcripts, VOD-linked.++ * image → optional AI character portrait (never a likeness — persona + an++ * identity-free scene description of the latest thumbnail).*-- * 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.++ * Nothing here touches the AI unless AI is enabled and within budget (server/ai/llm.js).*/'use strict';@@@ -35,13 +25,14 @@ 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 ACTIVE_DAYS = 45;++const STATS_WINDOW_DAYS = 90;const PERSONA_TTL_MS = 24 * 60 * 60 * 1000;const QUOTES_TTL_MS = 24 * 60 * 60 * 1000;const IMAGE_TTL_MS = 7 * 24 * 60 * 60 * 1000;--const RATING_MIN = 40; // nobody on the roster is a 5/99--const MIN_QUOTE_LINES = 20; // need this many transcript lines before picking quotes++const RATING_MIN = 40;++const MIN_QUOTE_LINES = 20;++const TALK_BONUS_MAX = 12;const STAT_KEYS = ['hype', 'grind', 'chat', 'loyalty', 'clutch', 'vibe', 'mic'];const STAT_META = {hype: { label: 'Hype', desc: 'peak concurrent viewers' },@@@ -53,49 +44,30 @@ const STAT_META = {mic: { label: 'Mic', desc: 'how much (and how loud) they talk — from the transcripts' },};const STAT_WEIGHTS = { hype: 0.22, vibe: 0.18, chat: 0.14, loyalty: 0.13, grind: 0.13, clutch: 0.1, mic: 0.1 };--const ROUNDS = [-- { key: 'hype', label: 'Hype Check', stat: 'hype' },-- { key: 'chat', label: 'Chat War', stat: 'chat' },-- { key: 'grind', label: 'Endurance', stat: 'grind' },-- { key: 'clutch', label: 'Clutch Time', stat: 'clutch' },-- { key: 'mic', label: 'Mic Drop', stat: 'mic' },--];--// Words that count as "hyped" when they show up in the transcript.const HYPE_PATTERNS = ["let's go", 'lets go', 'no way', 'oh my god', 'insane', 'clutch', 'holy', 'gg', 'unreal', 'what the', 'bro', 'chat,', 'chat ', 'yo ', 'welcome', 'lfg', 'poggers', 'pog'];--// Transcript text that must never be surfaced on the site, no matter what a model--// "picks": slurs, hate terms, threats and hard sexual profanity. Applied to quote--// candidates BEFORE the AI sees them, to the persona prompt's "things they said", and to--// the live hot-mic line. Deliberately broad — a false positive costs one quote, a false--// negative puts a slur on a public page. (Spelling variants: repeated letters, common--// leetspeak, and word-boundary aware so "hell" does not catch "shell".)--// Stems are matched on a NORMALISED copy of the text: lower-cased, leetspeak folded--// (1→i 3→e 4→a 0→o @→a $→s !→i), runs of 3+ identical letters collapsed to 2, and--// punctuation stripped — so "f4gg0tz", "niggaaa", "r3tards" all reduce to the stems--// below. Each stem may carry a plural/verb suffix.--const BANNED_STEMS = [-- 'nigga', 'nigger', 'niggu', 'fag', 'faggot', 'fagot', 'retard', 'tranny', 'trannie', 'kike', 'spic', 'spick', 'chink', 'gook',-- 'wetback', 'dyke', 'cunt', 'whore', 'slut', 'pussy', 'pussies', 'dick', 'cock', 'rape', 'rapist', 'kys', 'hitler', 'nazi',-- 'molest', 'molester', 'pedo', 'pedophile', 'paedo', 'coon', 'raghead', 'towelhead', 'beaner',++// ── Behavior line (NOT a vocabulary filter) ────────────────++// This platform does not censor words: offensive, controversial, provocative and taboo++// language — slurs included — is allowed and is never a reason to hide transcript text++// or void an Arena entry. What is filtered is BEHAVIOR, not speech: direct threats of++// violence, incitement to self-harm, sexual content involving minors, and doxxing.++// Matched on a lightly normalised copy (lower-case, leetspeak folded, punctuation++// stripped). Applied before model calls on transcript text and before rendering.++const BANNED_PHRASES = [++ /\bkill\s+(?:your|ur)\s*self\b/i, /\bkys\b/i, /\bgo\s+(?:die|hang\s+yourself|drink\s+bleach)\b/i,++ /\b(?:i'?ll|i will|i'?m gonna|i am going to|gonna|going to|we'?ll|we will)\s+(?:kill|shoot|stab|murder|beat\s+(?:you|him|her|them)\s+to\s+death|find\s+and\s+kill|burn)\s+(?:you|him|her|them|your\s+family|his\s+family|her\s+family)\b/i,++ /\b(?:child|kid|minor|underage|preteen)\s*(?:porn|sex|nude|nudes)\b/i, /\bcp\s+(?:link|links|pics|vid|vids)\b/i, /\b(?:molest|rape)\s+(?:a\s+|that\s+|the\s+)?(?:kid|child|minor|baby)\b/i,++ /\b(?:home|house)\s+address\s+is\s+\d/i, /\b(?:his|her|their|your)\s+(?:real\s+)?(?:home\s+)?(?:address|phone\s+number|social\s+security(?:\s+number)?)\s+is\s+(?:\d|[a-z]+\s+\d)/i, /\bdox+(?:ed|ing|x)?\s+(?:him|her|them|you)\b/i,];--const SUFFIX = '(?:s|es|z|ed|ing|er|ers)?';--const BANNED_STEM_RE = new RegExp(`\\b(?:${BANNED_STEMS.join('|')})${SUFFIX}\\b`, 'i');--// Fully collapsed variants ("niggaaaa" → "niga", "faggot" → "fagot") catch stretched spellings.--const collapseRepeats = (t) => t.replace(/([a-z])\1+/g, '$1');--const BANNED_STEM_COLLAPSED_RE = new RegExp(`\\b(?:${[...new Set(BANNED_STEMS.map(collapseRepeats))].join('|')})${SUFFIX}\\b`, 'i');--const BANNED_PHRASES = [/\bkill\s+(?:your|ur|him|her|them)\s*sel(?:f|ves)\b/i, /\bgo\s+die\b/i];--function normalizeForFilter(text) {-- return String(text || '')-- .toLowerCase()++ return String(text || '').toLowerCase().replace(/[1!]/g, 'i').replace(/3/g, 'e').replace(/[4@]/g, 'a').replace(/0/g, 'o').replace(/\$/g, 's')-- .replace(/[^a-z\s]/g, ' ');++ .replace(/[^a-z\s']/g, ' ').replace(/\s+/g, ' ');}function isBannedText(text) {const raw = String(text || '');-- if (BANNED_PHRASES.some(re => re.test(raw))) return true;const norm = normalizeForFilter(raw);-- return BANNED_STEM_RE.test(norm) || BANNED_STEM_COLLAPSED_RE.test(collapseRepeats(norm));++ return BANNED_PHRASES.some(re => re.test(raw) || re.test(norm));}// ── Tables ───────────────────────────────────────────────────@@@ -116,30 +88,11 @@ function ensureTables() {image_error TEXT,updated_at DATETIME DEFAULT CURRENT_TIMESTAMP)`);-- // Added later — inline ALTERs wrapped in try/catch, like the rest of the codebase.for (const col of ['quotes_json TEXT', 'quotes_generated_at DATETIME']) {try { db.run(`ALTER TABLE arena_profiles ADD COLUMN ${col}`); } catch { /* exists */ }}-- db.run(`CREATE TABLE IF NOT EXISTS arena_battles (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- a_user_id INTEGER NOT NULL,-- 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 { /* */ }++ try { require('./board').ensureTables(); require('./beef').ensureTables(); } catch { /* */ }_tablesReady = true;}@@@ -164,15 +117,11 @@ function imageGenAvailable() {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++ 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);}--/** Voice metrics from the continuous transcription (stream_timeline_events). */function voiceStatsFor(userId, win) {const hypeSql = HYPE_PATTERNS.map(() => "(LOWER(text) LIKE ?)").join(' + ');const hypeParams = HYPE_PATTERNS.map(p => `%${p}%`);@@@ -192,11 +141,7 @@ function voiceStatsFor(userId, win) {} catch { speech = {}; }let covered = 0, laughs = 0, topSounds = [];try {-- covered = db.get(`-- SELECT COALESCE(SUM(duration_seconds), 0) AS sec FROM streams-- WHERE user_id = ? AND duration_seconds > 0-- AND id IN (SELECT DISTINCT stream_id FROM stream_timeline_events WHERE user_id = ? AND created_at >= datetime('now', ?))-- `, [userId, userId, win])?.sec || 0;++ covered = db.get(`SELECT COALESCE(SUM(duration_seconds), 0) AS sec FROM streams WHERE user_id = ? AND duration_seconds > 0 AND id IN (SELECT DISTINCT stream_id FROM stream_timeline_events WHERE user_id = ? AND created_at >= datetime('now', ?))`, [userId, userId, win])?.sec || 0;laughs = db.get(`SELECT COUNT(*) AS n FROM stream_timeline_events WHERE user_id = ? AND kind = 'sound' AND (LOWER(label) LIKE '%laugh%' OR LOWER(label) LIKE '%giggle%' OR LOWER(label) LIKE '%chuckle%') AND created_at >= datetime('now', ?)`, [userId, win])?.n || 0;topSounds = db.all(`SELECT label, COUNT(*) AS n FROM stream_timeline_events WHERE user_id = ? AND kind = 'sound' AND label IS NOT NULL AND created_at >= datetime('now', ?) GROUP BY label ORDER BY n DESC LIMIT 5`, [userId, win]);} catch { /* */ }@@@ -209,20 +154,11 @@ function voiceStatsFor(userId, win) {const laughsPerHour = laughs / coveredHours;const hasData = (speech.lines || 0) >= 5;return {-- has_data: hasData,-- lines: speech.lines || 0,-- streams_heard: speech.streams_heard || 0,-- speech_minutes: Number((speechSec / 60).toFixed(1)),-- talk_ratio_pct: Number((talkRatio * 100).toFixed(1)),-- wpm: Number(wpm.toFixed(0)),-- words: speech.words || 0,-- exclaims: speech.exclaims || 0,-- questions: speech.questions || 0,-- hype_hits: speech.hype_hits || 0,-- hype_per_hour: Number(hypePerHour.toFixed(1)),-- laughs, laughs_per_hour: Number(laughsPerHour.toFixed(1)),++ has_data: hasData, lines: speech.lines || 0, streams_heard: speech.streams_heard || 0,++ speech_minutes: Number((speechSec / 60).toFixed(1)), talk_ratio_pct: Number((talkRatio * 100).toFixed(1)),++ wpm: Number(wpm.toFixed(0)), words: speech.words || 0, exclaims: speech.exclaims || 0, questions: speech.questions || 0,++ hype_hits: speech.hype_hits || 0, hype_per_hour: Number(hypePerHour.toFixed(1)), laughs, laughs_per_hour: Number(laughsPerHour.toFixed(1)),top_sounds: topSounds.map(s => ({ label: s.label, n: s.n })),-- // The single number MIC is ranked on: talk share carries it, hype + laughter spice it.voice_score: hasData ? Number((talkRatio * 100 + Math.min(hypePerHour, 60) * 0.5 + Math.min(laughsPerHour, 30) + Math.min(wpm, 200) / 20).toFixed(2)) : 0,};}@@@ -230,69 +166,33 @@ function voiceStatsFor(userId, win) {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++ 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_atFROM streams s LEFT JOIN stream_analytics sa ON sa.stream_id = s.idWHERE 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 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 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);-- const voice = voiceStatsFor(userId, win);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(2)),-- 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,-- voice,++ 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(2)), 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, voice: voiceStatsFor(userId, win),};}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,-- mic: (r) => (r.voice ? r.voice.voice_score : 0),++ 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, mic: (r) => (r.voice ? r.voice.voice_score : 0),};--/**-- * 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).-- */++/** Percentile ratings across a roster: rating = 40 + 59 × percentile; a roster of one is a flat 70. */function computeRatings(rosterRaw) {const ids = Object.keys(rosterRaw);const out = {};@@@ -304,23 +204,25 @@ function computeRatings(rosterRaw) {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);-- }++ 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)));});}-- for (const id of ids) {-- out[id].power = Math.round(STAT_KEYS.reduce((sum, k) => sum + out[id][k] * STAT_WEIGHTS[k], 0));-- }++ for (const id of ids) out[id].power = Math.round(STAT_KEYS.reduce((sum, k) => sum + out[id][k] * STAT_WEIGHTS[k], 0));return out;}--// ── Roster cache (stats are cheap but not free — recompute every few minutes) ──++/** Trash Talk bonus on POWER: recent XP (7 days) + beef wins, capped. */++function talkBonus(userId) {++ try {++ const board = require('./board'), beef = require('./beef');++ return Math.min(TALK_BONUS_MAX, Math.round(board.recentXp(userId) / 25) + beef.recentWins(userId) * 3);++ } catch { return 0; }++}++++// ── Roster cache ─────────────────────────────────────────────--let _roster = null; // { at, byId: { [userId]: { user, raw, ratings } }, order: [userId…] }++let _roster = null;const ROSTER_TTL_MS = 3 * 60 * 1000;function loadRoster(force = false) {@@@ -330,18 +232,14 @@ function loadRoster(force = false) {const rawById = {};for (const id of ids) rawById[id] = rawStatsFor(id);const ratings = computeRatings(rawById);-- // Trash Talk bonus (server/arena/trash-talk.js): recent judged entries add up to-- // TALK_BONUS_MAX POWER, decaying over a week — the one lever streamers pull themselves.-- let bonuses = {};-- try { bonuses = require('./trash-talk').talkBonuses(); } catch { bonuses = {}; }const byId = {};for (const id of ids) {const user = db.getUserById(id);if (!user) continue;-- const talkBonus = bonuses[id] || 0;++ const bonus = talkBonus(id);ratings[id].base_power = ratings[id].power;-- ratings[id].talk_bonus = talkBonus;-- ratings[id].power = Math.min(99 + require('./trash-talk').TALK_BONUS_MAX, ratings[id].power + talkBonus);++ ratings[id].talk_bonus = bonus;++ ratings[id].power = Math.min(99 + TALK_BONUS_MAX, ratings[id].power + bonus);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 { /* */ }}@@@ -351,245 +249,98 @@ function loadRoster(force = false) {}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, history, rivalries ──────────────────────────────----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 };--}----function recentBattles(userId, roster, limit = 8) {-- const rows = db.all(`-- SELECT id, a_user_id, b_user_id, day, winner_user_id, result_json FROM arena_battles-- WHERE a_user_id = ? OR b_user_id = ? ORDER BY day DESC, id DESC LIMIT ?-- `, [userId, userId, limit]);-- return rows.map(b => {-- const oppId = b.a_user_id === userId ? b.b_user_id : b.a_user_id;-- const opp = roster.byId[oppId];-- const oppUser = opp ? opp.user : publicUser(db.getUserById(oppId) || { id: oppId, username: `user${oppId}` });-- const rounds = parseJson(b.result_json, { rounds: [] }).rounds || [];-- const mine = b.a_user_id === userId ? 'a' : 'b';-- return {-- id: b.id, day: b.day,-- opponent: oppUser,-- opponent_fighter_name: opp ? (parseJson(profileRow(oppId)?.persona_json) || fallbackPersona(opp)).fighter_name : oppUser.display_name,-- result: b.winner_user_id == null ? 'draw' : (b.winner_user_id === userId ? 'win' : 'loss'),-- rounds_won: rounds.filter(r => r.winner === mine).length,-- rounds_total: rounds.length,-- };-- });--}----function rivalryFor(userId, roster) {-- const row = db.get(`-- SELECT CASE WHEN a_user_id = ? THEN b_user_id ELSE a_user_id END AS opp, COUNT(*) AS fights,-- SUM(CASE WHEN winner_user_id = ? THEN 1 ELSE 0 END) AS wins,-- SUM(CASE WHEN winner_user_id IS NOT NULL AND winner_user_id != ? THEN 1 ELSE 0 END) AS losses-- FROM arena_battles WHERE a_user_id = ? OR b_user_id = ?-- GROUP BY opp ORDER BY fights DESC, wins ASC LIMIT 1-- `, [userId, userId, userId, userId, userId]);-- if (!row || row.fights < 2) return null;-- const opp = roster.byId[row.opp];-- if (!opp) return null;-- return { opponent: opp.user, fighter_name: (parseJson(profileRow(row.opp)?.persona_json) || fallbackPersona(opp)).fighter_name, fights: row.fights, wins: row.wins || 0, losses: row.losses || 0 };--}----function headToHead(ida, idb) {-- const r = db.get(`-- SELECT COUNT(*) AS fights,-- SUM(CASE WHEN winner_user_id = ? THEN 1 ELSE 0 END) AS a_wins,-- SUM(CASE WHEN winner_user_id = ? THEN 1 ELSE 0 END) AS b_wins-- FROM arena_battles WHERE a_user_id = ? AND b_user_id = ?-- `, [ida, idb, ida, idb]) || {};-- return { fights: r.fights || 0, a_wins: r.a_wins || 0, b_wins: r.b_wins || 0 };++ 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 || '' };}// ── 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 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 freshWithin(ts, ttl) { return !!ts && Date.now() - Date.parse(ts + 'Z') < ttl; }function personaIsFresh(row) { return !!(row && row.persona_json) && freshWithin(row.persona_generated_at, PERSONA_TTL_MS); }function quotesAreFresh(row) { return !!(row && row.quotes_json) && freshWithin(row.quotes_generated_at, QUOTES_TTL_MS); }--/** Everything the site knows about a streamer that a writer could riff on. */function gatherContext(userId) {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 { ctx.memories = db.all('SELECT description FROM stream_memories WHERE user_id = ? ORDER BY captured_at DESC LIMIT 4', [userId]).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 || '');-- }++ if (chatAi) { const ov = parseJson(chatAi.overview, null); ctx.chat_notes = ov ? [ov.alltime, ov.today].filter(Boolean).join(' ') : String(chatAi.overview || ''); }} catch { /* */ }-- try {-- ctx.vods = db.all(`-- SELECT v.title, va.ai_overview_short AS overview FROM vods v-- LEFT JOIN vod_ai_state va ON va.vod_id = v.id-- WHERE v.user_id = ? AND v.is_public = 1 ORDER BY v.created_at DESC LIMIT 5-- `, [userId]).map(v => ({ title: v.title, overview: v.overview || null }));-- } 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.vods = db.all(`SELECT v.title, va.ai_overview_short AS overview FROM vods v LEFT JOIN vod_ai_state va ON va.vod_id = v.id WHERE v.user_id = ? AND v.is_public = 1 ORDER BY v.created_at DESC LIMIT 5`, [userId]).map(v => ({ title: v.title, overview: v.overview || null })); }++ 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 = []; }-- try {-- // A taste of how they actually talk — short, recent, non-empty lines.-- ctx.said = db.all(`SELECT text FROM stream_timeline_events WHERE user_id = ? AND kind = 'speech' AND LENGTH(text) BETWEEN 30 AND 140 ORDER BY created_at DESC LIMIT 24`, [userId]).map(r => r.text).filter(t => !isBannedText(t)).slice(0, 12);-- } catch { ctx.said = []; }++ try { ctx.said = db.all(`SELECT text FROM stream_timeline_events WHERE user_id = ? AND kind = 'speech' AND LENGTH(text) BETWEEN 30 AND 140 ORDER BY created_at DESC LIMIT 24`, [userId]).map(r => r.text).filter(t => !isBannedText(t)).slice(0, 12); } catch { ctx.said = []; }return ctx;}const PERSONA_SCHEMA = {name: 'arena_persona',schema: {-- type: 'object',-- additionalProperties: false,++ 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' },++ element: { type: 'string', description: 'Single word element/vibe' },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` }])),-- },++ 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' },++ 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, how they talk (transcript lines are provided), their running jokes, chat culture, schedule and the numbers you are given.--Hard rules: never mock appearance, body, voice quality, accent, disability, age, ethnicity, gender, religion, health, money troubles or anything a streamer cannot laugh at. Never invent real-life facts; everything is Arena lore. Keep it PG-13. Do not use the word "streamer" more than once. Output only the JSON.`;++const PERSONA_SYSTEM = `You write fighting-game "character select" bios for a live-streaming site's Arena, where streamers rank each other and talk shit about each other on stream. This is a banter arena: be savage, profane, petty and funny — roast their habits, their chat, their schedule, their takes, their gimmicks, how they talk (transcript lines are provided). Trolling energy is welcome; make it quotable.++This platform does not censor language — offensive and taboo words are allowed. The only hard line: no direct threats of violence, nothing sexual about minors, no doxxing. Everything is Arena lore, not real-life claims. 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,-- on_mic: entry.raw.voice.has_data ? { talk_share_pct: entry.raw.voice.talk_ratio_pct, words_per_minute: entry.raw.voice.wpm, hype_words_per_hour: entry.raw.voice.hype_per_hour, laughs_per_hour: entry.raw.voice.laughs_per_hour, stream_sounds: entry.raw.voice.top_sounds.map(s => s.label) } : 'no transcript data yet',-- },-- ai_overview: ctx.overview,-- recent_stream_titles: ctx.titles,-- what_the_camera_saw_recently: ctx.memories,-- chat_ai_notes: ctx.chat_notes,-- recent_vods: ctx.vods,-- things_they_said_on_stream: ctx.said,++ 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,++ on_mic: entry.raw.voice.has_data ? { talk_share_pct: entry.raw.voice.talk_ratio_pct, words_per_minute: entry.raw.voice.wpm, hype_words_per_hour: entry.raw.voice.hype_per_hour, laughs_per_hour: entry.raw.voice.laughs_per_hour, stream_sounds: entry.raw.voice.top_sounds.map(s => s.label) } : 'no transcript data yet' },++ ai_overview: ctx.overview, recent_stream_titles: ctx.titles, what_the_camera_saw_recently: ctx.memories, chat_ai_notes: ctx.chat_notes, recent_vods: ctx.vods, things_they_said_on_stream: ctx.said,};-- const r = await llm.complete({-- role: 'summary', kind: 'arena_persona', source: 'arena', ownerUserId: userId,-- system: PERSONA_SYSTEM,-- user: `Write the Arena persona for this fighter. Facts (JSON):\n${JSON.stringify(facts)}`,-- json: PERSONA_SCHEMA, maxTokens: 950, temperature: 0.95, timeoutMs: 30000,-- });++ 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: 950, 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]);++ 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', mic: 'Caster' }[best];return {-- fighter_name: entry.user.display_name,-- title: `The ${STAT_META[best].label} Specialist`,-- class: cls,++ 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?',++ 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,++ 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,};}--// ── Quotes (from the transcripts; AI picks the good ones) ────++// ── Quotes ───────────────────────────────────────────────────function quoteCandidates(userId, limit = 90) {let rows = [];-- try {-- rows = db.all(`-- SELECT id, stream_id, vod_id, start_sec, text FROM stream_timeline_events-- WHERE user_id = ? AND kind = 'speech' AND created_at >= datetime('now', ?)-- AND LENGTH(text) BETWEEN 25 AND 220-- ORDER BY created_at DESC LIMIT 1500-- `, [userId, `-${STATS_WINDOW_DAYS} days`]);-- } catch { rows = []; }-- const score = (t) => {-- const s = t.toLowerCase();-- let n = (t.match(/!/g) || []).length * 2 + (t.match(/\?/g) || []).length;-- for (const p of HYPE_PATTERNS) if (s.includes(p)) n += 3;-- if (/\b(i|we|you|chat)\b/.test(s)) n += 1;-- if (/\b(um+|uh+|like like)\b/.test(s)) n -= 1;-- return n;-- };-- // Hard filter first — nothing on the banned list is ever a candidate, so the model-- // cannot pick it. Then keep the spiciest, and fill with a spread across time so one-- // stream cannot dominate.++ try { rows = db.all(`SELECT id, stream_id, vod_id, start_sec, text FROM stream_timeline_events WHERE user_id = ? AND kind = 'speech' AND created_at >= datetime('now', ?) AND LENGTH(text) BETWEEN 25 AND 220 ORDER BY created_at DESC LIMIT 1500`, [userId, `-${STATS_WINDOW_DAYS} days`]); } catch { rows = []; }++ const score = (t) => { const s = t.toLowerCase(); let n = (t.match(/!/g) || []).length * 2 + (t.match(/\?/g) || []).length; for (const p of HYPE_PATTERNS) if (s.includes(p)) n += 3; if (/\b(i|we|you|chat)\b/.test(s)) n += 1; if (/\b(um+|uh+|like like)\b/.test(s)) n -= 1; return n; };const scored = rows.filter(r => !isBannedText(r.text)).map(r => ({ ...r, score: score(r.text) })).sort((x, y) => y.score - x.score);const picked = scored.slice(0, Math.ceil(limit / 2));const rest = scored.slice(Math.ceil(limit / 2));@@@ -597,36 +348,13 @@ function quoteCandidates(userId, limit = 90) {for (let i = 0; i < rest.length && picked.length < limit; i += step) picked.push(rest[i]);return picked;}----const QUOTES_SCHEMA = {-- name: 'arena_quotes',-- schema: {-- type: 'object', additionalProperties: false,-- required: ['picks', 'walkout', 'voice_verdict', 'mic_style'],-- properties: {-- picks: { type: 'array', minItems: 1, maxItems: 6, items: { type: 'object', additionalProperties: false, required: ['index', 'why'], properties: { index: { type: 'integer' }, why: { type: 'string', description: '≤ 10 words: why this line is a banger' } } } },-- walkout: { type: 'integer', description: 'index of the line that should play as their walkout' },-- voice_verdict: { type: 'string', description: 'One sentence on how they sound on the mic, announcer voice' },-- mic_style: { type: 'string', description: '2–3 words, e.g. "Rapid-fire tech sermon"' },-- },-- },--};----const QUOTES_SYSTEM = `You pick the most quotable lines a live-streamer actually said, from raw speech-to-text (expect transcription noise). Choose lines that are funny, hype, weirdly profound, or perfect trash talk out of context. Skip anything that would embarrass them or anyone else: no insults about people, nothing about health/money/family, nothing offensive. Prefer complete sentences. Return indexes into the list you are given. Output only the JSON.`;----function fallbackQuotes(candidates) {-- const picks = candidates.slice(0, 5).map(c => ({ ...c, why: 'straight from the transcript' }));-- return { picks, walkout: picks[0] || null, voice_verdict: null, mic_style: null, _fallback: true };--}--++const QUOTES_SCHEMA = { name: 'arena_quotes', schema: { type: 'object', additionalProperties: false, required: ['picks', 'walkout', 'voice_verdict', 'mic_style'], properties: { picks: { type: 'array', minItems: 1, maxItems: 6, items: { type: 'object', additionalProperties: false, required: ['index', 'why'], properties: { index: { type: 'integer' }, why: { type: 'string' } } } }, walkout: { type: 'integer' }, voice_verdict: { type: 'string' }, mic_style: { type: 'string' } } } };++const QUOTES_SYSTEM = `You pick the most quotable lines a live-streamer actually said, from raw speech-to-text (expect transcription noise). Choose lines that are funny, savage, unhinged, weirdly profound, or perfect trash talk out of context — swearing and disrespect are a plus. Offensive or taboo language is allowed on this platform and is not a reason to skip a line. Skip only direct threats of violence, anything sexual about minors, doxxing, and lines that are pure transcription garbage. Prefer complete sentences. Return indexes into the list you are given. Output only the JSON.`;function materializeQuotes(candidates, sel) {-- // Belt and braces: candidates are pre-filtered, but never trust an index blindly.const pick = (i, why) => { const c = candidates[i]; return c && !isBannedText(c.text) ? { text: c.text, stream_id: c.stream_id, vod_id: c.vod_id, start_sec: Math.max(0, Math.floor(Number(c.start_sec) || 0) - 2), why } : null; };const picks = (sel.picks || []).map(p => pick(p.index, p.why)).filter(Boolean);-- const walkout = pick(sel.walkout, 'walkout line') || picks[0] || null;-- return { picks, walkout, voice_verdict: sel.voice_verdict || null, mic_style: sel.mic_style || null };++ return { picks, walkout: pick(sel.walkout, 'walkout line') || picks[0] || null, voice_verdict: sel.voice_verdict || null, mic_style: sel.mic_style || null };}--async function generateQuotes(userId, { force = false } = {}) {ensureTables();const row = profileRow(userId);@@@ -636,20 +364,13 @@ async function generateQuotes(userId, { force = false } = {}) {let result = null;if (aiOn()) {try {-- const r = await llm.complete({-- role: 'summary', kind: 'arena_quotes', source: 'arena', ownerUserId: userId,-- system: QUOTES_SYSTEM,-- user: `Lines (index: text):\n${candidates.map((c, i) => `${i}: ${c.text}`).join('\n')}`,-- json: QUOTES_SCHEMA, maxTokens: 500, temperature: 0.7, timeoutMs: 30000,-- });++ const r = await llm.complete({ role: 'summary', kind: 'arena_quotes', source: 'arena', ownerUserId: userId, system: QUOTES_SYSTEM, user: `Lines (index: text):\n${candidates.map((c, i) => `${i}: ${c.text}`).join('\n')}`, json: QUOTES_SCHEMA, maxTokens: 500, temperature: 0.7, timeoutMs: 30000 });if (r && r.json && Array.isArray(r.json.picks)) result = materializeQuotes(candidates, r.json);} catch (e) { console.warn('[Arena] quotes:', e.message); }}-- if (!result || !result.picks.length) result = materializeQuotes(candidates, { picks: fallbackQuotes(candidates).picks.map((p, i) => ({ index: i, why: p.why })), walkout: 0 });++ if (!result || !result.picks.length) result = materializeQuotes(candidates, { picks: candidates.slice(0, 5).map((c, i) => ({ index: i, why: 'straight from the transcript' })), walkout: 0 });if (!aiOn()) result._fallback = true;-- db.run(`INSERT INTO arena_profiles (user_id, quotes_json, quotes_generated_at, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)-- ON CONFLICT(user_id) DO UPDATE SET quotes_json = excluded.quotes_json, quotes_generated_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP`,-- [userId, JSON.stringify(result)]);++ db.run(`INSERT INTO arena_profiles (user_id, quotes_json, quotes_generated_at, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) ON CONFLICT(user_id) DO UPDATE SET quotes_json = excluded.quotes_json, quotes_generated_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP`, [userId, JSON.stringify(result)]);return result;}@@@ -660,58 +381,38 @@ function imageIsFresh(row) {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}`;++ return fs.existsSync(path.join(ARENA_DIR, base)) ? `/data/arena/${base}` : null;}----/** 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;-- }++ 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 { /* */ }++ 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--++const _imageInFlight = new Map();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 = ''; }-- }++ 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}.`,@@@ -721,29 +422,19 @@ async function generateImage(userId, { force = false } = {}) {`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'));++ 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 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');-- }++ 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);@@@ -753,14 +444,9 @@ async function generateImage(userId, { force = false } = {}) {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 { /* */ }++ 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));@@@ -774,34 +460,20 @@ 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 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, includeQuotes = false } = {}) {const entry = roster.byId[userId];if (!entry) return null;const row = profileRow(userId);const persona = parseJson(row?.persona_json) || fallbackPersona(entry);-- const rank = roster.order.indexOf(userId) + 1;++ const board = require('./board'), beef = require('./beef');const card = {-- user: entry.user,-- rank,-- roster_size: roster.order.length,-- ratings: entry.ratings,-- stat_meta: STAT_META,-- raw: includeRaw ? entry.raw : undefined,-- voice: entry.raw.voice,-- persona,-- persona_is_fallback: !!persona._fallback,-- persona_generated_at: row?.persona_generated_at || null,-- image_url: imageUrlFor(row),-- image_prompt: row?.image_prompt || null,-- image_model: row?.image_model || null,-- image_pending: false,-- record: recordFor(userId),-- live: isLive(userId),++ user: entry.user, rank: roster.order.indexOf(userId) + 1, roster_size: roster.order.length,++ ratings: entry.ratings, stat_meta: STAT_META, raw: includeRaw ? entry.raw : undefined, voice: entry.raw.voice,++ persona, persona_is_fallback: !!persona._fallback, persona_generated_at: row?.persona_generated_at || null,++ image_url: imageUrlFor(row), image_prompt: row?.image_prompt || null, image_model: row?.image_model || null, image_pending: false,++ record: beef.recordFor(userId), level: board.levelView(userId), live: isLive(userId),};if (includeQuotes) card.quotes = parseJson(row?.quotes_json) || null;return card;@@@ -816,17 +488,13 @@ async function getFighter(usernameOrId, { generate = true } = {}) {const row = profileRow(user.id);if (aiOn() && !personaIsFresh(row)) { try { await generatePersona(user.id); } catch (e) { console.warn('[Arena] persona:', e.message); } }if (!quotesAreFresh(profileRow(user.id))) { try { await generateQuotes(user.id); } catch (e) { console.warn('[Arena] quotes:', e.message); } }-- if (aiOn() && !imageIsFresh(profileRow(user.id)) && imageGenAvailable()) {-- // Fire and forget — the client polls until image_url is present.-- generateImage(user.id).catch(() => {});-- }++ if (aiOn() && !imageIsFresh(profileRow(user.id)) && imageGenAvailable()) generateImage(user.id).catch(() => {});}const card = cardFor(user.id, roster, { includeQuotes: true });card.image_pending = !card.image_url && _imageInFlight.has(user.id);card.image_generation = imageGenAvailable() ? 'ai' : 'off';-- card.recent_battles = recentBattles(user.id, roster);-- card.rivalry = rivalryFor(user.id, roster);-- try { card.trash_talk = require('./trash-talk').entriesFor(user.id, 5); } catch { card.trash_talk = []; }++ try { card.beefs = require('./beef').forUser(user.id, 8); } catch { card.beefs = []; }++ try { const t = require('./board').activeTopicFor(user.id); card.active_topic = t ? { id: t.id, text: t.text } : null; } catch { card.active_topic = null; }return card;}@@@ -835,17 +503,14 @@ function listFighters() {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,++ user: c.user, rank: c.rank, ratings: c.ratings, record: c.record, live: c.live, image_url: c.image_url, level: { level: c.level.level, xp: c.level.xp },persona: { fighter_name: c.persona.fighter_name, title: c.persona.title, class: c.persona.class, element: c.persona.element, taunt: c.persona.taunt, lore: c.persona.lore, signature_move: c.persona.signature_move, stat_quips: c.persona.stat_quips },-- persona_is_fallback: c.persona_is_fallback,-- category: roster.byId[id].raw.category,-- last_live_at: roster.byId[id].raw.last_live_at,++ persona_is_fallback: c.persona_is_fallback, category: roster.byId[id].raw.category, last_live_at: roster.byId[id].raw.last_live_at,voice: { has_data: c.voice.has_data, talk_ratio_pct: c.voice.talk_ratio_pct, speech_minutes: c.voice.speech_minutes },};});}--/** Per-stream series for one stat (for the expandable stat view) + where they rank on it. */function getStatDetail(userId, stat) {if (!STAT_KEYS.includes(stat)) return null;const roster = loadRoster();@@@ -855,299 +520,77 @@ function getStatDetail(userId, stat) {let series = [];try {const rows = db.all(`-- SELECT s.id, s.title, s.started_at, s.duration_seconds, s.peak_viewers,-- sa.avg_viewers, sa.total_messages, sa.unique_chatters, sa.new_followers, sa.clips_created,++ SELECT s.id, s.title, s.started_at, s.duration_seconds, s.peak_viewers, sa.avg_viewers, sa.total_messages, sa.unique_chatters, sa.new_followers, sa.clips_created,(SELECT COALESCE(SUM(COALESCE(e.end_sec, e.start_sec + 3) - e.start_sec), 0) FROM stream_timeline_events e WHERE e.stream_id = s.id AND e.kind = 'speech') AS speech_secFROM streams s LEFT JOIN stream_analytics sa ON sa.stream_id = s.id-- WHERE s.user_id = ? AND s.duration_seconds > 0 AND s.started_at >= datetime('now', ?)-- ORDER BY s.started_at DESC LIMIT 14-- `, [userId, win]).reverse();++ WHERE s.user_id = ? AND s.duration_seconds > 0 AND s.started_at >= datetime('now', ?) ORDER BY s.started_at DESC LIMIT 14`, [userId, win]).reverse();const per = {-- hype: r => r.peak_viewers || 0,-- grind: r => Number(((r.duration_seconds || 0) / 3600).toFixed(2)),-- chat: r => Number(((r.total_messages || 0) / Math.max((r.duration_seconds || 0) / 3600, 0.1)).toFixed(1)),-- loyalty: r => (r.new_followers || 0) + (r.unique_chatters || 0) / 4,-- clutch: r => Number((((r.clips_created || 0) * 3) / Math.max((r.duration_seconds || 0) / 3600, 0.1)).toFixed(2)),-- vibe: r => Number((r.avg_viewers || 0).toFixed(1)),++ hype: r => r.peak_viewers || 0, grind: r => Number(((r.duration_seconds || 0) / 3600).toFixed(2)),++ chat: r => Number(((r.total_messages || 0) / Math.max((r.duration_seconds || 0) / 3600, 0.1)).toFixed(1)), loyalty: r => (r.new_followers || 0) + (r.unique_chatters || 0) / 4,++ clutch: r => Number((((r.clips_created || 0) * 3) / Math.max((r.duration_seconds || 0) / 3600, 0.1)).toFixed(2)), vibe: r => Number((r.avg_viewers || 0).toFixed(1)),mic: r => (r.duration_seconds ? Number(((r.speech_sec || 0) / r.duration_seconds * 100).toFixed(1)) : 0),};series = rows.map(r => ({ stream_id: r.id, title: r.title, date: r.started_at, value: per[stat](r) }));} catch { series = []; }const unit = { hype: 'peak viewers', grind: 'hours', chat: 'msgs / hour', loyalty: 'loyalty points', clutch: 'clutch / hour', vibe: 'avg viewers', mic: '% of stream talking' }[stat];-- // MIC is ranked on a composite voice score, but people are shown the talk-time share-- // (the same number the voice card uses) so the drill-down never contradicts the card.const shown = (raw) => (stat === 'mic' ? (raw.voice?.talk_ratio_pct || 0) : (METRIC_FOR_STAT[stat](raw) || 0));const ranked = roster.order.map(id => ({ id, value: METRIC_FOR_STAT[stat](roster.byId[id].raw) || 0, shown: shown(roster.byId[id].raw), rating: roster.byId[id].ratings[stat] })).sort((x, y) => y.value - x.value);const position = ranked.findIndex(r => r.id === userId) + 1;const top = ranked.slice(0, 3).map(r => ({ user: roster.byId[r.id].user, fighter_name: (parseJson(profileRow(r.id)?.persona_json) || fallbackPersona(roster.byId[r.id])).fighter_name, value: Number(Number(r.shown).toFixed(1)), rating: r.rating }));-- return {-- stat, label: STAT_META[stat].label, desc: STAT_META[stat].desc, unit,-- rating: entry.ratings[stat], value: Number(Number(shown(entry.raw)).toFixed(1)),-- position, roster_size: roster.order.length, weight: STAT_WEIGHTS[stat],-- series, top,-- voice: stat === 'mic' ? entry.raw.voice : undefined,-- };--}----// ── Battles ──────────────────────────────────────────────────----function today() { return new Date().toISOString().slice(0, 10); }----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);-- return ROUNDS.map(r => {-- const a = aRatings[r.stat] + rand() * 30;-- const b = bRatings[r.stat] + rand() * 30;-- const winner = a === b ? (aRatings.power >= bRatings.power ? 'a' : 'b') : (a > b ? 'a' : 'b');-- const margin = Math.abs(a - b);-- return { key: r.key, label: r.label, stat: r.stat, a: Math.round(a), b: Math.round(b), winner, margin: Math.round(margin), upset: (winner === 'a' ? aRatings[r.stat] < bRatings[r.stat] : bRatings[r.stat] < aRatings[r.stat]) };-- });--}----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: ROUNDS.length, maxItems: ROUNDS.length, items: { type: 'string', description: 'One vivid play-by-play sentence per round, in order' } },-- finisher: { type: 'string', description: 'The finishing move that decided it, 1 sentence' },-- verdict: { type: 'string', description: 'Announcer verdict + a wink at the crowd vote, ≤ 2 sentences' },-- },-- },--};----const COMMENTARY_SYSTEM = `You are the over-caffeinated ring announcer of a streaming site's Arena, where streamers battle as fighting-game characters. Call the fight from the round results you are given: name the signature moves, riff on their lore, quote their walkout lines if given, celebrate upsets. The "Mic Drop" round is about who talks more and louder on stream. Affectionate roast only — never cruel, never about appearance/body/voice quality/accent/health/identity. PG-13. Output only the JSON.`;----function templateCommentary(A, B, rounds, outcome) {-- const name = (s) => (s === 'a' ? A.persona.fighter_name : B.persona.fighter_name);-- 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) {-- for (const id of [ida, idb]) {-- if (aiOn() && !personaIsFresh(profileRow(id))) { try { await generatePersona(id); } catch { /* */ } }-- if (!quotesAreFresh(profileRow(id))) { try { await generateQuotes(id); } catch { /* */ } }-- }-- }-- const A = cardFor(ida, roster, { includeRaw: true, includeQuotes: true });-- const B = cardFor(idb, roster, { includeRaw: true, includeQuotes: true });---- 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 brief = (X) => ({ name: X.persona.fighter_name, title: X.persona.title, class: X.persona.class, signature_move: X.persona.signature_move, special: X.persona.special, weakness: X.persona.weakness, lore: X.persona.lore, ratings: X.ratings, walkout_line: X.quotes?.walkout?.text || null, mic_style: X.quotes?.mic_style || null });-- const r = await llm.complete({-- role: 'chat', kind: 'arena_commentary', source: 'arena',-- system: COMMENTARY_SYSTEM,-- user: JSON.stringify({-- a: brief(A), b: brief(B),-- rounds: rounds.map(r => ({ round: r.label, stat: r.stat, a_score: r.a, b_score: r.b, winner: r.winner === 'a' ? A.persona.fighter_name : B.persona.fighter_name, upset: r.upset })),-- result_before_crowd_vote: { a_rounds: rounds.filter(r => r.winner === 'a').length, b_rounds: rounds.filter(r => r.winner === 'b').length },-- }),-- json: COMMENTARY_SCHEMA, maxTokens: 700, 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);---- const strip = (X) => ({ ...X, quotes: undefined, walkout: X.quotes?.walkout || null, mic_style: X.quotes?.mic_style || null, voice_verdict: X.quotes?.voice_verdict || null });-- return {-- id: row.id, day, a: strip(A), b: strip(B), rounds, votes, outcome,-- winner: winnerId ? (winnerId === ida ? A.user : B.user) : null,-- commentary,-- commentary_is_fallback: !!commentary._fallback,-- history: headToHead(ida, idb),-- stat_meta: STAT_META,-- };--}----/** Today's featured fight: a seeded pick of two fighters from the top of the ladder. */--async function getMainEvent({ generate = true } = {}) {-- const roster = loadRoster();-- if (roster.order.length < 2) return null;-- const pool = roster.order.slice(0, Math.min(8, roster.order.length));-- const rand = seededRandom(`main-event:${today()}`);-- const i = Math.floor(rand() * pool.length);-- let j = Math.floor(rand() * (pool.length - 1)); if (j >= i) j++;-- const battle = await getBattle(String(pool[i]), String(pool[j]), { generate });-- return battle && !battle.error ? battle : null;--}----function castVote(battleId, voterKey, side) {-- if (!['a', 'b'].includes(side)) throw new Error('side must be a or b');-- ensureTables();-- 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 };++ return { stat, label: STAT_META[stat].label, desc: STAT_META[stat].desc, unit, rating: entry.ratings[stat], value: Number(Number(shown(entry.raw)).toFixed(1)), position, roster_size: roster.order.length, weight: STAT_WEIGHTS[stat], series, top, voice: stat === 'mic' ? entry.raw.voice : undefined };}--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() {++/** Live fighters with what the transcript last heard — the "on the mic now" strip. */++function liveFighters() {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);-- }++ 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 board = require('./board'), beef = require('./beef');++ return [...byUser.values()].map(s => {const c = cardFor(s.user_id, roster, { includeRaw: false });-- // What they said most recently on this live stream — the arena "hot mic".-- let lastLine = null;-- try {-- const r = db.all(`SELECT text, start_sec FROM stream_timeline_events WHERE stream_id = ? AND kind = 'speech' AND LENGTH(text) > 15 ORDER BY start_sec DESC LIMIT 5`, [s.id])-- .find(row => !isBannedText(row.text));-- if (r) lastLine = { text: r.text, start_sec: Math.floor(r.start_sec) };-- } catch { /* */ }++ let hotMic = null;++ try { const r = db.all(`SELECT text, start_sec FROM stream_timeline_events WHERE stream_id = ? AND kind = 'speech' AND LENGTH(text) > 15 ORDER BY start_sec DESC LIMIT 5`, [s.id]).find(row => !isBannedText(row.text)); if (r) hotMic = { text: r.text, start_sec: Math.floor(r.start_sec) }; } catch { /* */ }++ const transcribed = !!db.get(`SELECT 1 FROM stream_timeline_events WHERE stream_id = ? AND kind = 'speech' AND created_at >= datetime('now', '-30 minutes') LIMIT 1`, [s.id]);++ const t = board.activeTopicFor(s.user_id);return {-- user: c.user, rank: c.rank, ratings: c.ratings, record: c.record, image_url: c.image_url,++ user: c.user, rank: c.rank, ratings: c.ratings, record: c.record, image_url: c.image_url, level: c.level.level,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 },++ stream: { id: s.id, title: s.title, category: s.category, viewer_count: s.viewer_count || 0, started_at: s.started_at },thumbnail_url: thumbs ? (thumbs.getCurrentLiveThumbnailUrl(s.id) || null) : null,-- hot_mic: lastLine,++ hot_mic: hotMic, transcribed, active_topic: t ? { id: t.id, text: t.text } : null, open_beefs: beef.openBeefsFor(s.user_id).length,};}).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 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)}`;}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, SUM(quotes_json IS NOT NULL) AS quotes FROM arena_profiles`) || {};-- const battles = db.get('SELECT COUNT(*) AS n FROM arena_battles')?.n || 0;-- const votes = db.get('SELECT COUNT(*) AS n FROM arena_votes')?.n || 0;-- const withVoice = roster.order.filter(id => roster.byId[id].raw.voice.has_data).length;++ let beefs = {}, topics = 0;++ try { beefs = db.get(`SELECT SUM(status = 'open') AS open, SUM(status = 'resolved') AS resolved FROM arena_beefs`) || {}; topics = db.get(`SELECT COUNT(*) AS n FROM arena_topics WHERE status = 'open'`)?.n || 0; } catch { /* */ }return {-- enabled: arenaEnabled(),-- ai: aiOn(),-- image_generation: imageGenAvailable(),-- image_model: imageGenAvailable() ? String(setting('ai_image_model', 'gpt-image-1')) : null,-- roster: roster.order.length,-- with_voice_data: withVoice,-- personas: counts.personas || 0,-- quotes: counts.quotes || 0,-- images: counts.images || 0,-- battles, votes,-- active_days: ACTIVE_DAYS,-- rounds: ROUNDS.map(r => r.label),++ enabled: arenaEnabled(), ai: aiOn(), image_generation: imageGenAvailable(), image_model: imageGenAvailable() ? String(setting('ai_image_model', 'gpt-image-1')) : null,++ roster: roster.order.length, with_voice_data: roster.order.filter(id => roster.byId[id].raw.voice.has_data).length,++ personas: counts.personas || 0, quotes: counts.quotes || 0, images: counts.images || 0,++ beefs_open: beefs.open || 0, beefs_resolved: beefs.resolved || 0, topics_open: topics, live_fighters: liveFighters().length,++ listener: (() => { try { return require('./listener').TICK_MS; } catch { return null; } })(), active_days: ACTIVE_DAYS,};}module.exports = {-- ensureTables,-- arenaEnabled,-- aiOn,-- imageGenAvailable,-- loadRoster,-- listFighters,-- getFighter,-- getStatDetail,-- generatePersona,-- generateQuotes,-- generateImage,-- getBattle,-- getMainEvent,-- castVote,-- voterKeyFor,-- getLiveMatchups,-- status,-- STAT_KEYS,-- STAT_META,-- STAT_WEIGHTS,-- ROUNDS,-- ARENA_DIR,++ ensureTables, arenaEnabled, aiOn, imageGenAvailable, loadRoster, listFighters, getFighter, getStatDetail, liveFighters,++ generatePersona, generateQuotes, generateImage, voterKeyFor, status, publicUser,getFighterImageUrl: (userId) => imageUrlFor(profileRow(userId)),-- // pure helpers (tests)-- _computeRatings: computeRatings,-- _simulateRounds: simulateRounds,-- _scoreBattle: scoreBattle,-- _seededRandom: seededRandom,-- _fallbackPersona: fallbackPersona,-- _voiceStatsFor: voiceStatsFor,-- _quoteCandidates: quoteCandidates,-- _isBannedText: isBannedText,++ STAT_KEYS, STAT_META, STAT_WEIGHTS, ARENA_DIR, TALK_BONUS_MAX,++ _computeRatings: computeRatings, _fallbackPersona: fallbackPersona, _voiceStatsFor: voiceStatsFor, _quoteCandidates: quoteCandidates, _isBannedText: isBannedText, _talkBonus: talkBonus,};nnew file mode 100644@@@ -0,0 +1,330 @@++/**++ * OpenVibe.Live — Arena Beefs (speech-driven streamer vs streamer)++ *++ * Nobody clicks "battle". The listener (listener.js) hears a live fighter talking trash++ * about ANOTHER fighter by name; the judge confirms it was aimed at them; a beef opens:++ *++ * score → every judged hit aimed at the other side adds its quality (0–10) to++ * your side of the tug-of-war. Chat adds crowd points with `!hype` in++ * your chat (one per person per beef, max CROWD_MAX).++ * clock → after every hit the OTHER side is on the clock: RESPONSE_LIVE_MIN++ * minutes if they are live, RESPONSE_OFFLINE_HOURS hours if not (the++ * clock tightens to the live window the moment they go live). Miss the++ * clock and you ducked — forfeit.++ * end → a beef also hard-ends MAX_BEEF_HOURS after it opened: higher score++ * wins, equal is a draw. Records (W–L) come from here.++ */++'use strict';++++const db = require('../db/database');++++const RESPONSE_LIVE_MIN = 15;++const RESPONSE_OFFLINE_HOURS = 24;++const MAX_BEEF_HOURS = 24;++const CROWD_MAX = 10;++const FEED_MAX = 40;++const XP_BEEF_HIT = 1.0; // × quality++const XP_BEEF_WIN = 40;++const XP_BEEF_OPEN = 5;++++let _ready = false;++function ensureTables() {++ if (_ready) return;++ db.run(`CREATE TABLE IF NOT EXISTS arena_beefs (++ id INTEGER PRIMARY KEY AUTOINCREMENT,++ a_user_id INTEGER NOT NULL,++ b_user_id INTEGER NOT NULL,++ status TEXT NOT NULL DEFAULT 'open',++ score_a REAL DEFAULT 0,++ score_b REAL DEFAULT 0,++ hits_a INTEGER DEFAULT 0,++ hits_b INTEGER DEFAULT 0,++ crowd_a INTEGER DEFAULT 0,++ crowd_b INTEGER DEFAULT 0,++ on_clock TEXT,++ clock_until DATETIME,++ responded INTEGER DEFAULT 0,++ last_a_at DATETIME,++ last_b_at DATETIME,++ feed_json TEXT,++ opener_line TEXT,++ winner_user_id INTEGER,++ resolution TEXT,++ opened_at DATETIME DEFAULT CURRENT_TIMESTAMP,++ ends_at DATETIME,++ resolved_at DATETIME++ )`);++ db.run(`CREATE TABLE IF NOT EXISTS arena_beef_hype (++ beef_id INTEGER NOT NULL,++ side TEXT NOT NULL,++ voter_key TEXT NOT NULL,++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,++ PRIMARY KEY (beef_id, voter_key)++ )`);++ for (const col of ['headline TEXT', 'result_headline TEXT', 'upset INTEGER DEFAULT 0', 'rematch INTEGER DEFAULT 0', 'bounty_topic_id INTEGER']) {++ try { db.run(`ALTER TABLE arena_beefs ADD COLUMN ${col}`); } catch { /* exists */ }++ }++ db.run(`CREATE TABLE IF NOT EXISTS arena_beef_sides (++ beef_id INTEGER NOT NULL,++ voter_key TEXT NOT NULL,++ side TEXT NOT NULL,++ user_id INTEGER,++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,++ PRIMARY KEY (beef_id, voter_key)++ )`);++ db.run('CREATE INDEX IF NOT EXISTS idx_arena_beefs_status ON arena_beefs (status, a_user_id, b_user_id)');++ _ready = true;++}++++function parseJson(t, f = null) { try { return t ? JSON.parse(t) : f; } catch { return f; } }++function arena() { return require('./arena-service'); }++function board() { return require('./board'); }++function isLive(userId) { return !!db.get('SELECT 1 FROM streams WHERE user_id = ? AND is_live = 1 LIMIT 1', [userId]); }++function nowIso() { return new Date().toISOString(); }++function nameOf(userId) {++ const p = parseJson(db.get('SELECT persona_json FROM arena_profiles WHERE user_id = ?', [userId])?.persona_json);++ const u = db.getUserById(userId);++ return p?.fighter_name || u?.display_name || u?.username || `user${userId}`;++}++const HEADLINE_SCHEMA = { name: 'arena_headline', schema: { type: 'object', additionalProperties: false, required: ['headline'], properties: { headline: { type: 'string', description: '≤ 100 chars, tabloid, inflammatory but funny, names both fighters' } } } };++async function headlineFor(kind, ctx) {++ try {++ const llm = require('../ai/llm');++ if (!(llm.isEnabled() && llm.withinBudget())) return null;++ const r = await llm.complete({ role: 'chat', kind: 'arena_headline', source: 'arena', system: 'You write tabloid headlines for a streaming site\'s trash-talk arena: inflammatory, funny, screenshot-able, names both fighters, ≤ 100 chars. Profanity and disrespect welcome; this platform does not censor language. Hard line: no direct threats of violence, no doxxing. Output only the JSON.', user: JSON.stringify({ kind, ...ctx }), json: HEADLINE_SCHEMA, maxTokens: 80, temperature: 1.0, timeoutMs: 15000 });++ return r && r.json && r.json.headline ? String(r.json.headline).slice(0, 120) : null;++ } catch { return null; }++}++function templateHeadline(kind, ctx) {++ if (kind === 'open') return `${ctx.a} has words for ${ctx.b}${ctx.rematch ? ' — REMATCH' : ''}${ctx.line ? `: “${String(ctx.line).slice(0, 60)}”` : ''}`;++ if (kind === 'forfeit') return `${ctx.loser} DUCKED. ${ctx.winner} wins by silence.`;++ if (kind === 'score') return ctx.winner ? `${ctx.winner} out-talks ${ctx.loser} ${ctx.score_a}–${ctx.score_b}${ctx.upset ? ' — UPSET' : ''}` : `${ctx.a} and ${ctx.b} talk themselves into a draw`;++ return null;++}++function sqlDate(ms) { return new Date(ms).toISOString().replace('T', ' ').slice(0, 19); }++++function openBeefBetween(u1, u2) {++ ensureTables();++ return db.get(`SELECT * FROM arena_beefs WHERE status = 'open' AND ((a_user_id = ? AND b_user_id = ?) OR (a_user_id = ? AND b_user_id = ?))`, [u1, u2, u2, u1]) || null;++}++function openBeefsFor(userId) {++ ensureTables();++ return db.all(`SELECT * FROM arena_beefs WHERE status = 'open' AND (a_user_id = ? OR b_user_id = ?) ORDER BY opened_at DESC`, [userId, userId]);++}++++function clockFor(targetId, fromMs = Date.now()) {++ return sqlDate(fromMs + (isLive(targetId) ? RESPONSE_LIVE_MIN * 60 * 1000 : RESPONSE_OFFLINE_HOURS * 3600 * 1000));++}++++function pushFeed(beef, event) {++ const feed = parseJson(beef.feed_json, []);++ feed.push({ at: nowIso(), ...event });++ return JSON.stringify(feed.slice(-FEED_MAX));++}++++/**++ * A judged hit: `speakerId` talked trash aimed at `targetId` (both on the roster).++ * Opens the beef if needed, scores it, and puts the other side on the clock.++ */++function recordHit(speakerId, targetId, hit) {++ ensureTables();++ if (speakerId === targetId) return null;++ let beef = openBeefBetween(speakerId, targetId);++ let opened = false;++ if (!beef) {++ const endsAt = sqlDate(Date.now() + MAX_BEEF_HOURS * 3600 * 1000);++ const history = rivalry(speakerId, targetId);++ const bounty = board().openBountyOn(targetId);++ db.run(`INSERT INTO arena_beefs (a_user_id, b_user_id, on_clock, clock_until, ends_at, feed_json, opener_line, rematch, bounty_topic_id, headline) VALUES (?, ?, 'b', ?, ?, '[]', ?, ?, ?, ?)`,++ [speakerId, targetId, clockFor(targetId), endsAt, String(hit.best_line || '').slice(0, 220), history.fights > 0 ? 1 : 0, bounty?.id || null, templateHeadline('open', { a: nameOf(speakerId), b: nameOf(targetId), line: hit.best_line, rematch: history.fights > 0 })]);++ beef = openBeefBetween(speakerId, targetId);++ opened = true;++ board().addXp(speakerId, XP_BEEF_OPEN, 'beef_open', beef.id);++ console.log(`[Arena] beef #${beef.id} opened: user ${speakerId} → user ${targetId}${history.fights ? ' (rematch)' : ''}${bounty ? ' (bounty!)' : ''}`);++ const id = beef.id;++ headlineFor('open', { a: nameOf(speakerId), b: nameOf(targetId), opening_line: hit.best_line, about: hit.about, rematch: history.fights > 0, record_between: history, bounty: !!bounty }).then(h => { if (h) db.run('UPDATE arena_beefs SET headline = ? WHERE id = ?', [h, id]); }).catch(() => {});++ }++ const side = beef.a_user_id === speakerId ? 'a' : 'b';++ const other = side === 'a' ? 'b' : 'a';++ const otherId = side === 'a' ? beef.b_user_id : beef.a_user_id;++ const quality = Math.max(0, Math.min(10, Number(hit.quality) || 0));++ const firstResponse = side === 'b' && !beef.responded;++ const feed = pushFeed(beef, { kind: opened ? 'open' : (firstResponse ? 'respond' : 'hit'), side, text: String(hit.best_line || '').slice(0, 220), quality, about: hit.about || null, announcer: hit.announcer || null, vod_id: hit.vod_id || null, sec: hit.sec ?? null });++ db.run(`UPDATE arena_beefs SET score_${side} = score_${side} + ?, hits_${side} = hits_${side} + 1, last_${side}_at = CURRENT_TIMESTAMP, responded = CASE WHEN ? THEN 1 ELSE responded END,++ on_clock = ?, clock_until = ?, feed_json = ? WHERE id = ?`,++ [quality, side === 'b' ? 1 : 0, other, clockFor(otherId), feed, beef.id]);++ // Bounty: anyone with a bounty on their head pays double to whoever collects.++ const bounty = board().openBountyOn(targetId);++ board().addXp(speakerId, quality * XP_BEEF_HIT * (bounty ? 2 : 1), 'beef_hit', beef.id, { beefHit: true, line: hit.best_line, lineScore: quality, lineVodId: hit.vod_id, lineSec: hit.sec });++ if (bounty) board().recordBountyHit(speakerId, bounty, quality);++ return { beef: db.get('SELECT * FROM arena_beefs WHERE id = ?', [beef.id]), opened, side, first_response: firstResponse, bounty: !!bounty };++}++++function hype(beefId, side, voterKey) {++ ensureTables();++ const beef = db.get('SELECT * FROM arena_beefs WHERE id = ? AND status = ?', [beefId, 'open']);++ if (!beef) throw new Error('No open beef with that id');++ if (!['a', 'b'].includes(side)) throw new Error('side must be a or b');++ const sideUser = side === 'a' ? beef.a_user_id : beef.b_user_id;++ if (voterKey === `user:${sideUser}`) throw new Error("You can't hype yourself");++ const ins = db.run('INSERT OR IGNORE INTO arena_beef_hype (beef_id, side, voter_key) VALUES (?, ?, ?)', [beefId, side, voterKey]);++ const n = db.get('SELECT COUNT(*) AS n FROM arena_beef_hype WHERE beef_id = ? AND side = ?', [beefId, side])?.n || 0;++ db.run(`UPDATE arena_beefs SET crowd_${side} = ? WHERE id = ?`, [Math.min(CROWD_MAX, n), beefId]);++ if (ins.changes) board().addXp(sideUser, board().XP_HYPE, 'hype', beefId);++ return { added: !!ins.changes, hypers: n, crowd: Math.min(CROWD_MAX, n) };++}++++function totals(beef) {++ return { a: Number(((beef.score_a || 0) + (beef.crowd_a || 0)).toFixed(1)), b: Number(((beef.score_b || 0) + (beef.crowd_b || 0)).toFixed(1)) };++}++++function resolve(beef, resolution, winnerId) {++ const loserId = winnerId == null ? null : (winnerId === beef.a_user_id ? beef.b_user_id : beef.a_user_id);++ let upset = 0;++ try { const roster = arena().loadRoster(); if (winnerId && loserId) { const rw = roster.order.indexOf(winnerId), rl = roster.order.indexOf(loserId); upset = rw >= 0 && rl >= 0 && rw - rl >= 4 ? 1 : 0; } } catch { /* */ }++ const t = totals(beef);++ const ctx = { a: nameOf(beef.a_user_id), b: nameOf(beef.b_user_id), winner: winnerId ? nameOf(winnerId) : null, loser: loserId ? nameOf(loserId) : null, score_a: t.a, score_b: t.b, upset: !!upset, streak: winnerId ? streakFor(winnerId) + 1 : 0 };++ const feed = pushFeed(beef, { kind: resolution === 'forfeit' ? 'forfeit' : 'end', side: winnerId == null ? null : (winnerId === beef.a_user_id ? 'a' : 'b'), text: null, upset: !!upset });++ db.run(`UPDATE arena_beefs SET status = 'resolved', resolution = ?, winner_user_id = ?, resolved_at = CURRENT_TIMESTAMP, on_clock = NULL, clock_until = NULL, feed_json = ?, upset = ?, result_headline = ? WHERE id = ? AND status = 'open'`, [resolution, winnerId, feed, upset, templateHeadline(resolution === 'forfeit' ? 'forfeit' : 'score', ctx), beef.id]);++ if (winnerId) board().addXp(winnerId, XP_BEEF_WIN + (upset ? 20 : 0), upset ? 'beef_upset_win' : 'beef_win', beef.id);++ // Viewers who picked a side: settle their clout.++ const voters = db.all('SELECT voter_key, side FROM arena_beef_sides WHERE beef_id = ?', [beef.id]);++ const winSide = winnerId == null ? null : (winnerId === beef.a_user_id ? 'a' : 'b');++ try { board().settleClout && board().settleClout(voters.map(v => v.voter_key), new Set(voters.filter(v => v.side === winSide).map(v => v.voter_key))); } catch { /* */ }++ console.log(`[Arena] beef #${beef.id} resolved: ${resolution}${winnerId ? ` winner user ${winnerId}` : ' draw'}${upset ? ' UPSET' : ''}`);++ const id = beef.id;++ headlineFor(resolution, ctx).then(h => { if (h) db.run('UPDATE arena_beefs SET result_headline = ? WHERE id = ?', [h, id]); }).catch(() => {});++ try { arena().loadRoster(true); } catch { /* */ }++}++++/** Sides: viewers back a fighter in an open beef (`!side <name>` in chat or the button). */++function pickSide(beefId, voterKey, side, userId = null) {++ ensureTables();++ const beef = db.get('SELECT * FROM arena_beefs WHERE id = ? AND status = ?', [beefId, 'open']);++ if (!beef) throw new Error('No open beef with that id');++ if (!['a', 'b'].includes(side)) throw new Error('side must be a or b');++ db.run('INSERT INTO arena_beef_sides (beef_id, voter_key, side, user_id) VALUES (?, ?, ?, ?) ON CONFLICT(beef_id, voter_key) DO UPDATE SET side = excluded.side, created_at = CURRENT_TIMESTAMP', [beefId, voterKey, side, userId]);++ return sidesTally(beefId);++}++function sidesTally(beefId) {++ const r = db.get(`SELECT SUM(side = 'a') AS a, SUM(side = 'b') AS b FROM arena_beef_sides WHERE beef_id = ?`, [beefId]) || {};++ const a = r.a || 0, b = r.b || 0;++ return { a, b, share_a: a + b ? Math.round((a / (a + b)) * 100) : 50 };++}++++/** Current win streak (consecutive resolved beefs won). */++function streakFor(userId) {++ ensureTables();++ let n = 0;++ for (const b of db.all(`SELECT winner_user_id FROM arena_beefs WHERE status = 'resolved' AND (a_user_id = ? OR b_user_id = ?) ORDER BY resolved_at DESC LIMIT 20`, [userId, userId])) { if (b.winner_user_id === userId) n++; else break; }++ return n;++}++++/** Rivalry between two fighters: fights, record, receipts (best lines from earlier beefs). */++function rivalry(u1, u2) {++ ensureTables();++ const rows = db.all(`SELECT * FROM arena_beefs WHERE status = 'resolved' AND ((a_user_id = ? AND b_user_id = ?) OR (a_user_id = ? AND b_user_id = ?)) ORDER BY resolved_at DESC LIMIT 10`, [u1, u2, u2, u1]);++ const wins1 = rows.filter(r => r.winner_user_id === u1).length, wins2 = rows.filter(r => r.winner_user_id === u2).length;++ const receipts = [];++ for (const r of rows.slice(0, 3)) for (const e of parseJson(r.feed_json, [])) if (e.text && e.quality >= 6) receipts.push({ beef_id: r.id, side_user_id: e.side === 'a' ? r.a_user_id : r.b_user_id, text: e.text, quality: e.quality, vod_id: e.vod_id, sec: e.sec, at: e.at });++ receipts.sort((x, y) => y.quality - x.quality);++ return { fights: rows.length, wins_1: wins1, wins_2: wins2, last: rows[0] ? { id: rows[0].id, winner_user_id: rows[0].winner_user_id, resolution: rows[0].resolution, at: rows[0].resolved_at } : null, receipts: receipts.slice(0, 4) };++}++++function rivalriesFor(userId, limit = 5) {++ ensureTables();++ const opps = db.all(`SELECT CASE WHEN a_user_id = ? THEN b_user_id ELSE a_user_id END AS opp, COUNT(*) AS n FROM arena_beefs WHERE (a_user_id = ? OR b_user_id = ?) GROUP BY opp HAVING n >= 2 ORDER BY n DESC LIMIT ?`, [userId, userId, userId, limit]);++ const b = board();++ const roster = arena().loadRoster();++ return opps.map(o => { const r = rivalry(userId, o.opp); return { opponent: b.fighterBrief(o.opp, roster), fights: r.fights + (openBeefBetween(userId, o.opp) ? 1 : 0), wins: r.wins_1, losses: r.wins_2, open: !!openBeefBetween(userId, o.opp), receipts: r.receipts.slice(0, 2) }; });++}++++/** Ticker: enforce clocks and hard ends; tighten an offline clock when the target goes live. */++function tick() {++ ensureTables();++ const now = Date.now();++ for (const beef of db.all(`SELECT * FROM arena_beefs WHERE status = 'open'`)) {++ const t = totals(beef);++ if (beef.ends_at && Date.parse(beef.ends_at + 'Z') <= now) {++ resolve(beef, 'score', t.a === t.b ? null : (t.a > t.b ? beef.a_user_id : beef.b_user_id));++ continue;++ }++ if (beef.on_clock && beef.clock_until) {++ const onId = beef.on_clock === 'a' ? beef.a_user_id : beef.b_user_id;++ const until = Date.parse(beef.clock_until + 'Z');++ // Tighten: if the side on the clock is live now, they get the live window at most.++ const liveCap = now + RESPONSE_LIVE_MIN * 60 * 1000;++ if (isLive(onId) && until > liveCap) db.run('UPDATE arena_beefs SET clock_until = ? WHERE id = ?', [sqlDate(liveCap), beef.id]);++ else if (until <= now) {++ const winner = beef.on_clock === 'a' ? beef.b_user_id : beef.a_user_id;++ resolve(beef, 'forfeit', winner);++ }++ }++ }++}++++// ── Records + views ──────────────────────────────────────────++++function recordFor(userId) {++ ensureTables();++ const r = db.get(`SELECT SUM(CASE WHEN winner_user_id = ? THEN 1 ELSE 0 END) AS wins,++ SUM(CASE WHEN status = 'resolved' AND winner_user_id IS NOT NULL AND winner_user_id != ? THEN 1 ELSE 0 END) AS losses,++ SUM(CASE WHEN status = 'resolved' AND winner_user_id IS NULL THEN 1 ELSE 0 END) AS draws++ FROM arena_beefs WHERE a_user_id = ? OR b_user_id = ?`, [userId, userId, userId, userId]) || {};++ return { wins: r.wins || 0, losses: r.losses || 0, draws: r.draws || 0 };++}++++function recentWins(userId, days = 7) {++ ensureTables();++ return db.get(`SELECT COUNT(*) AS n FROM arena_beefs WHERE winner_user_id = ? AND resolved_at >= datetime('now', ?)`, [userId, `-${days} days`])?.n || 0;++}++++function beefView(beef, roster) {++ const t = totals(beef);++ const b = board();++ const clockMs = beef.clock_until ? Date.parse(beef.clock_until + 'Z') - Date.now() : null;++ return {++ id: beef.id, status: beef.status, resolution: beef.resolution,++ a: { ...b.fighterBrief(beef.a_user_id, roster), score: Number((beef.score_a || 0).toFixed(1)), hits: beef.hits_a || 0, crowd: beef.crowd_a || 0, total: t.a, last_at: beef.last_a_at },++ b: { ...b.fighterBrief(beef.b_user_id, roster), score: Number((beef.score_b || 0).toFixed(1)), hits: beef.hits_b || 0, crowd: beef.crowd_b || 0, total: t.b, last_at: beef.last_b_at },++ share_a: t.a + t.b > 0 ? Math.round((t.a / (t.a + t.b)) * 100) : 50,++ on_clock: beef.on_clock, clock_until: beef.clock_until ? new Date(beef.clock_until + 'Z').toISOString() : null, clock_seconds_left: clockMs == null ? null : Math.max(0, Math.round(clockMs / 1000)),++ clock_is_live_window: beef.on_clock ? isLive(beef.on_clock === 'a' ? beef.a_user_id : beef.b_user_id) : false,++ responded: !!beef.responded,++ opener_line: beef.opener_line, headline: beef.headline, result_headline: beef.result_headline,++ rematch: !!beef.rematch, upset: !!beef.upset, bounty: !!beef.bounty_topic_id,++ sides: sidesTally(beef.id),++ history: rivalry(beef.a_user_id, beef.b_user_id),++ streaks: { a: streakFor(beef.a_user_id), b: streakFor(beef.b_user_id) },++ feed: parseJson(beef.feed_json, []).slice(-14),++ winner_user_id: beef.winner_user_id,++ opened_at: beef.opened_at, ends_at: beef.ends_at ? new Date(beef.ends_at + 'Z').toISOString() : null, resolved_at: beef.resolved_at,++ rules: { response_live_min: RESPONSE_LIVE_MIN, response_offline_hours: RESPONSE_OFFLINE_HOURS, max_hours: MAX_BEEF_HOURS, crowd_max: CROWD_MAX },++ };++}++++function list({ limitResolved = 12 } = {}) {++ ensureTables();++ const roster = arena().loadRoster();++ const open = db.all(`SELECT * FROM arena_beefs WHERE status = 'open' ORDER BY COALESCE(last_b_at, last_a_at, opened_at) DESC`).map(b => beefView(b, roster));++ const resolved = db.all(`SELECT * FROM arena_beefs WHERE status = 'resolved' ORDER BY resolved_at DESC LIMIT ?`, [limitResolved]).map(b => beefView(b, roster));++ return { open, resolved };++}++++function get(id) {++ ensureTables();++ const b = db.get('SELECT * FROM arena_beefs WHERE id = ?', [id]);++ return b ? beefView(b, arena().loadRoster()) : null;++}++++function forUser(userId, limit = 8) {++ ensureTables();++ const roster = arena().loadRoster();++ return db.all(`SELECT * FROM arena_beefs WHERE a_user_id = ? OR b_user_id = ? ORDER BY CASE WHEN status = 'open' THEN 0 ELSE 1 END, COALESCE(resolved_at, opened_at) DESC LIMIT ?`, [userId, userId, limit]).map(b => beefView(b, roster));++}++++module.exports = {++ ensureTables, recordHit, hype, tick, recordFor, recentWins, list, get, forUser, openBeefBetween, openBeefsFor, beefView, totals,++ pickSide, sidesTally, streakFor, rivalry, rivalriesFor, nameOf,++ RESPONSE_LIVE_MIN, RESPONSE_OFFLINE_HOURS, MAX_BEEF_HOURS, CROWD_MAX, XP_BEEF_WIN, XP_BEEF_OPEN, XP_BEEF_HIT,++};nnew file mode 100644@@@ -0,0 +1,579 @@++/**++ * OpenVibe.Live — Arena Board: topics, angles, trash levels++ *++ * topic → a thing to talk shit about. Made by chat (`!topic …`), by a streamer or a++ * viewer on the page, or seeded by the AI when the board runs dry. Lives on++ * the board for TOPIC_TTL_HOURS after its last activity.++ * angles → when a streamer picks a topic, the AI generates 3–4 angles inside it++ * ("defend your schedule", "roast the rival's setup"…). The listener judges++ * their live speech against the angles; each cleared angle gives XP, clearing++ * all of them conquers the topic (big XP).++ * level → Trash Level is persistent per streamer (XP_PER_LEVEL per level) and feeds++ * POWER (see arena-service.talkBonus): beefs, angles and chat hype all pay XP.++ */++'use strict';++++const db = require('../db/database');++const llm = require('../ai/llm');++++const TOPIC_TTL_HOURS = 36;++const TOPIC_MAX_LEN = 140;++const ANGLES_PER_TOPIC = 3;++const XP_PER_LEVEL = 50;++const XP_ANGLE_CLEARED = 25;++const XP_TOPIC_CONQUERED = 60;++const XP_HYPE = 2;++const MAX_OPEN_TOPICS = 24;++++const SEED_TOPICS = [++ { text: 'Whose chat is actually the smartest chat on this site?', hint: 'Bring receipts, or confidence. Confidence is cheaper.' },++ { text: 'Stream schedules are a lifestyle. Defend yours against the calendar.', hint: 'Sleep is for people with fewer viewers.' },++ { text: 'The last thing that went wrong on your stream was a strategic decision. Explain.', hint: 'The audio cutting out was a bit. Obviously.' },++ { text: 'Sell your category to someone who has never heard of it.', hint: 'You have the mic. They have their doubts.' },++ { text: 'The worst possible advice for a new streamer, delivered with total confidence.', hint: 'Every word must sound like wisdom.' },++ { text: 'Your setup vs everyone else\'s setup.', hint: 'Cables count. Cable management counts double.' },++ { text: 'Rank the top three fighters and explain why you\'re about to move up.', hint: 'A plan that cannot possibly work scores double.' },++ { text: 'What your chat says about you when you step away.', hint: 'They say worse. Get ahead of it.' },++];++++let _ready = false;++function ensureTables() {++ if (_ready) return;++ db.run(`CREATE TABLE IF NOT EXISTS arena_topics (++ id INTEGER PRIMARY KEY AUTOINCREMENT,++ text TEXT NOT NULL,++ hint TEXT,++ created_by TEXT NOT NULL,++ creator_user_id INTEGER,++ creator_name TEXT,++ status TEXT NOT NULL DEFAULT 'open',++ angles_json TEXT,++ joins INTEGER DEFAULT 0,++ hits INTEGER DEFAULT 0,++ conquered INTEGER DEFAULT 0,++ last_activity_at DATETIME DEFAULT CURRENT_TIMESTAMP,++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP++ )`);++ db.run(`CREATE TABLE IF NOT EXISTS arena_topic_progress (++ topic_id INTEGER NOT NULL,++ user_id INTEGER NOT NULL,++ angle_idx INTEGER NOT NULL,++ progress REAL DEFAULT 0,++ score REAL DEFAULT 0,++ hits INTEGER DEFAULT 0,++ best_line TEXT,++ best_vod_id INTEGER,++ best_sec INTEGER,++ cleared_at DATETIME,++ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,++ PRIMARY KEY (topic_id, user_id, angle_idx)++ )`);++ db.run(`CREATE TABLE IF NOT EXISTS arena_topic_members (++ topic_id INTEGER NOT NULL,++ user_id INTEGER NOT NULL,++ active INTEGER DEFAULT 1,++ conquered_at DATETIME,++ joined_at DATETIME DEFAULT CURRENT_TIMESTAMP,++ PRIMARY KEY (topic_id, user_id)++ )`);++ db.run(`CREATE TABLE IF NOT EXISTS arena_topic_hype (++ topic_id INTEGER NOT NULL,++ user_id INTEGER NOT NULL,++ voter_key TEXT NOT NULL,++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,++ PRIMARY KEY (topic_id, user_id, voter_key)++ )`);++ db.run(`CREATE TABLE IF NOT EXISTS arena_trash_levels (++ user_id INTEGER PRIMARY KEY,++ xp INTEGER DEFAULT 0,++ level INTEGER DEFAULT 1,++ angles_cleared INTEGER DEFAULT 0,++ topics_conquered INTEGER DEFAULT 0,++ beef_hits INTEGER DEFAULT 0,++ best_line TEXT,++ best_line_vod_id INTEGER,++ best_line_sec INTEGER,++ best_line_score REAL DEFAULT 0,++ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP++ )`);++ db.run(`CREATE TABLE IF NOT EXISTS arena_xp_log (++ id INTEGER PRIMARY KEY AUTOINCREMENT,++ user_id INTEGER NOT NULL,++ amount INTEGER NOT NULL,++ reason TEXT NOT NULL,++ ref_id INTEGER,++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP++ )`);++ for (const col of ["kind TEXT NOT NULL DEFAULT 'topic'", 'side_a TEXT', 'side_b TEXT', 'phrase TEXT', 'target_user_id INTEGER', 'headline TEXT', 'heat REAL DEFAULT 0', 'source_note TEXT', 'expires_at DATETIME', 'winner_side TEXT', 'resolved_json TEXT']) {++ try { db.run(`ALTER TABLE arena_topics ADD COLUMN ${col}`); } catch { /* exists */ }++ }++ db.run(`CREATE TABLE IF NOT EXISTS arena_topic_sides (++ topic_id INTEGER NOT NULL,++ voter_key TEXT NOT NULL,++ side TEXT NOT NULL,++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,++ PRIMARY KEY (topic_id, voter_key)++ )`);++ db.run(`CREATE TABLE IF NOT EXISTS arena_viewer_clout (++ voter_key TEXT PRIMARY KEY,++ user_id INTEGER,++ picks INTEGER DEFAULT 0,++ wins INTEGER DEFAULT 0,++ streak INTEGER DEFAULT 0,++ best_streak INTEGER DEFAULT 0,++ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP++ )`);++ db.run('CREATE INDEX IF NOT EXISTS idx_arena_xp_log_user ON arena_xp_log (user_id, created_at)');++ _ready = true;++}++++function aiOn() { try { return llm.isEnabled() && llm.withinBudget(); } catch { return false; } }++function parseJson(t, f = null) { try { return t ? JSON.parse(t) : f; } catch { return f; } }++function arena() { return require('./arena-service'); }++function levelFor(xp) { return 1 + Math.floor((Number(xp) || 0) / XP_PER_LEVEL); }++++// ── Levels ───────────────────────────────────────────────────++++function levelRow(userId) {++ ensureTables();++ return db.get('SELECT * FROM arena_trash_levels WHERE user_id = ?', [userId]) || { user_id: userId, xp: 0, level: 1, angles_cleared: 0, topics_conquered: 0, beef_hits: 0 };++}++++function addXp(userId, amount, reason, refId = null, extra = {}) {++ ensureTables();++ amount = Math.round(Number(amount) || 0);++ if (amount <= 0) return levelRow(userId);++ const before = levelRow(userId);++ db.run(`INSERT INTO arena_trash_levels (user_id, xp, level) VALUES (?, ?, ?)++ ON CONFLICT(user_id) DO UPDATE SET xp = xp + excluded.xp, level = ?, updated_at = CURRENT_TIMESTAMP`,++ [userId, amount, levelFor(amount), levelFor((before.xp || 0) + amount)]);++ db.run('INSERT INTO arena_xp_log (user_id, amount, reason, ref_id) VALUES (?, ?, ?, ?)', [userId, amount, reason, refId]);++ const sets = [];++ if (extra.angle) sets.push('angles_cleared = angles_cleared + 1');++ if (extra.topic) sets.push('topics_conquered = topics_conquered + 1');++ if (extra.beefHit) sets.push('beef_hits = beef_hits + 1');++ if (sets.length) db.run(`UPDATE arena_trash_levels SET ${sets.join(', ')} WHERE user_id = ?`, [userId]);++ if (extra.line && (extra.lineScore || 0) > (before.best_line_score || 0)) {++ db.run('UPDATE arena_trash_levels SET best_line = ?, best_line_vod_id = ?, best_line_sec = ?, best_line_score = ? WHERE user_id = ?', [String(extra.line).slice(0, 220), extra.lineVodId || null, extra.lineSec ?? null, extra.lineScore, userId]);++ }++ const after = levelRow(userId);++ if (after.level > before.level) console.log(`[Arena] user ${userId} → Trash Level ${after.level}`);++ try { arena().loadRoster(true); } catch { /* */ }++ return { ...after, leveled_up: after.level > before.level, gained: amount };++}++++/** XP earned in the last N days (POWER bonus input; recent effort matters more than history). */++function recentXp(userId, days = 7) {++ ensureTables();++ return db.get(`SELECT COALESCE(SUM(amount), 0) AS n FROM arena_xp_log WHERE user_id = ? AND created_at >= datetime('now', ?)`, [userId, `-${days} days`])?.n || 0;++}++++function levelView(userId) {++ const r = levelRow(userId);++ const xp = r.xp || 0;++ return {++ level: levelFor(xp), xp, xp_into_level: xp - (levelFor(xp) - 1) * XP_PER_LEVEL, xp_per_level: XP_PER_LEVEL, next_level_xp: levelFor(xp) * XP_PER_LEVEL,++ angles_cleared: r.angles_cleared || 0, topics_conquered: r.topics_conquered || 0, beef_hits: r.beef_hits || 0,++ recent_xp: recentXp(userId),++ best_line: r.best_line ? { text: r.best_line, vod_id: r.best_line_vod_id, sec: r.best_line_sec, score: r.best_line_score } : null,++ };++}++++// ── Topics ───────────────────────────────────────────────────++++function cleanTopicText(text) {++ let t = String(text || '').replace(/\s+/g, ' ').trim();++ if (t.length < 8) throw new Error('Give the topic a few more words');++ if (arena()._isBannedText(t)) throw new Error('That topic crosses the line (threats, minors, doxxing)');++ t = t.slice(0, TOPIC_MAX_LEN);++ if (!/[.?!]$/.test(t)) t += /^(who|what|why|how|when|where|which|is|are|does|do|can|should)\b/i.test(t) ? '?' : '.';++ return t.charAt(0).toUpperCase() + t.slice(1);++}++++const KIND_TTL_HOURS = { topic: TOPIC_TTL_HOURS, debate: 24, phrase: 6, bounty: 6 };++++function createTopic({ text, hint = null, createdBy, creatorUserId = null, creatorName = null, kind = 'topic', sideA = null, sideB = null, phrase = null, targetUserId = null, headline = null, sourceNote = null }) {++ ensureTables();++ const clean = cleanTopicText(text);++ kind = ['topic', 'debate', 'phrase', 'bounty'].includes(kind) ? kind : 'topic';++ if (kind === 'debate' && !(sideA && sideB)) throw new Error('A debate needs two sides');++ if (kind === 'phrase' && !phrase) throw new Error('A phrase challenge needs the phrase');++ if (kind === 'bounty' && !targetUserId) throw new Error('A bounty needs a target');++ if (kind === 'bounty' && db.get(`SELECT id FROM arena_topics WHERE status = 'open' AND kind = 'bounty' AND target_user_id = ?`, [targetUserId])) throw new Error('There is already a bounty on them');++ const dup = db.get(`SELECT id FROM arena_topics WHERE status = 'open' AND LOWER(text) = LOWER(?)`, [clean]);++ if (dup) throw new Error('That topic is already on the board');++ const open = db.get(`SELECT COUNT(*) AS n FROM arena_topics WHERE status = 'open'`)?.n || 0;++ if (open >= MAX_OPEN_TOPICS) archiveStale(true);++ const expires = new Date(Date.now() + (KIND_TTL_HOURS[kind] || TOPIC_TTL_HOURS) * 3600 * 1000).toISOString().replace('T', ' ').slice(0, 19);++ // Debates carry their sides as the first two angles so the judge can classify a rant.++ const angles = kind === 'debate' ? JSON.stringify([{ text: `FOR: ${sideA}`, hint: 'Argue this side like your channel depends on it.' }, { text: `AGAINST: ${sideB}`, hint: 'Tear the other side apart.' }, { text: 'Roast everyone who took this seriously', hint: 'The third option is always chaos.' }]) : null;++ db.run(`INSERT INTO arena_topics (text, hint, created_by, creator_user_id, creator_name, kind, side_a, side_b, phrase, target_user_id, headline, source_note, expires_at, angles_json)++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,++ [clean, hint ? String(hint).slice(0, 120) : null, createdBy, creatorUserId, creatorName ? String(creatorName).slice(0, 40) : null, kind, sideA ? String(sideA).slice(0, 80) : null, sideB ? String(sideB).slice(0, 80) : null, phrase ? String(phrase).slice(0, 60) : null, targetUserId, headline ? String(headline).slice(0, 160) : null, sourceNote ? String(sourceNote).slice(0, 80) : null, expires, angles]);++ const row = db.get('SELECT * FROM arena_topics ORDER BY id DESC LIMIT 1');++ console.log(`[Arena] ${kind} #${row.id} by ${createdBy}${creatorName ? ' ' + creatorName : ''}: "${clean}"`);++ return row;++}++++/** Heat = activity in the last hour (hits, joins, hype, side picks), decays with age. Hot things rise. */++function computeHeat(topicId) {++ const hits = db.get(`SELECT COUNT(*) AS n FROM arena_xp_log WHERE ref_id = ? AND reason IN ('topic_hit', 'angle_cleared', 'topic_conquered', 'phrase_hit', 'bounty_hit') AND created_at >= datetime('now', '-60 minutes')`, [topicId])?.n || 0;++ const hype = db.get(`SELECT COUNT(*) AS n FROM arena_topic_hype WHERE topic_id = ? AND created_at >= datetime('now', '-60 minutes')`, [topicId])?.n || 0;++ const sides = db.get(`SELECT COUNT(*) AS n FROM arena_topic_sides WHERE topic_id = ? AND created_at >= datetime('now', '-60 minutes')`, [topicId])?.n || 0;++ const talking = db.get(`SELECT COUNT(*) AS n FROM arena_topic_members WHERE topic_id = ? AND active = 1`, [topicId])?.n || 0;++ const heat = hits * 3 + hype + sides + talking * 4;++ db.run('UPDATE arena_topics SET heat = ? WHERE id = ?', [heat, topicId]);++ return heat;++}++const HOT_THRESHOLD = 12;++++/** Viewers pick a side on a debate (chat `!side a|b` or the page). Clout is settled at resolution. */++function pickSide(topicId, voterKey, side, userId = null) {++ ensureTables();++ const t = db.get(`SELECT * FROM arena_topics WHERE id = ? AND status = 'open' AND kind = 'debate'`, [topicId]);++ if (!t) throw new Error('No open debate with that id');++ if (!['a', 'b'].includes(side)) throw new Error('side must be a or b');++ db.run('INSERT INTO arena_topic_sides (topic_id, voter_key, side) VALUES (?, ?, ?) ON CONFLICT(topic_id, voter_key) DO UPDATE SET side = excluded.side, created_at = CURRENT_TIMESTAMP', [topicId, voterKey, side]);++ db.run('INSERT INTO arena_viewer_clout (voter_key, user_id, picks) VALUES (?, ?, 0) ON CONFLICT(voter_key) DO UPDATE SET user_id = COALESCE(excluded.user_id, arena_viewer_clout.user_id)', [voterKey, userId]);++ db.run('UPDATE arena_topics SET last_activity_at = CURRENT_TIMESTAMP WHERE id = ?', [topicId]);++ return sideTally(topicId);++}++function sideTally(topicId) {++ const r = db.get(`SELECT SUM(side = 'a') AS a, SUM(side = 'b') AS b FROM arena_topic_sides WHERE topic_id = ?`, [topicId]) || {};++ const a = r.a || 0, b = r.b || 0;++ return { a, b, share_a: a + b ? Math.round((a / (a + b)) * 100) : 50 };++}++function settleClout(voterKeys, wonKeys) {++ for (const k of voterKeys) {++ const won = wonKeys.has(k);++ db.run(`INSERT INTO arena_viewer_clout (voter_key, picks, wins, streak, best_streak) VALUES (?, 1, ?, ?, ?)++ ON CONFLICT(voter_key) DO UPDATE SET picks = picks + 1, wins = wins + ?, streak = CASE WHEN ? THEN streak + 1 ELSE 0 END, best_streak = MAX(best_streak, CASE WHEN ? THEN streak + 1 ELSE 0 END), updated_at = CURRENT_TIMESTAMP`,++ [k, won ? 1 : 0, won ? 1 : 0, won ? 1 : 0, won ? 1 : 0, won ? 1 : 0, won ? 1 : 0]);++ }++}++function cloutLeaderboard(limit = 10) {++ ensureTables();++ return db.all('SELECT * FROM arena_viewer_clout WHERE picks >= 2 ORDER BY wins DESC, streak DESC LIMIT ?', [limit]).map(r => {++ const u = r.user_id ? db.getUserById(r.user_id) : null;++ return { name: u ? (u.display_name || u.username) : 'anonymous', username: u ? u.username : null, picks: r.picks, wins: r.wins, streak: r.streak, best_streak: r.best_streak, accuracy: r.picks ? Math.round((r.wins / r.picks) * 100) : 0 };++ });++}++++/** Debates and challenges resolve when they expire: side with more talk + more chat wins; clout settles. */++function resolveExpired() {++ ensureTables();++ const due = db.all(`SELECT * FROM arena_topics WHERE status = 'open' AND expires_at IS NOT NULL AND expires_at <= datetime('now')`);++ const results = [];++ for (const t of due) {++ let resolved = { kind: t.kind };++ if (t.kind === 'debate') {++ const talk = db.get(`SELECT SUM(CASE WHEN angle_idx = 0 THEN score ELSE 0 END) AS a, SUM(CASE WHEN angle_idx = 1 THEN score ELSE 0 END) AS b FROM arena_topic_progress WHERE topic_id = ?`, [t.id]) || {};++ const tally = sideTally(t.id);++ const scoreA = (talk.a || 0) * 2 + tally.a, scoreB = (talk.b || 0) * 2 + tally.b;++ const winner = scoreA === scoreB ? null : (scoreA > scoreB ? 'a' : 'b');++ const mvp = db.get(`SELECT user_id, SUM(score) AS s FROM arena_topic_progress WHERE topic_id = ? AND angle_idx = ? GROUP BY user_id ORDER BY s DESC LIMIT 1`, [t.id, winner === 'b' ? 1 : 0]);++ resolved = { kind: 'debate', winner, score_a: Number(scoreA.toFixed(1)), score_b: Number(scoreB.toFixed(1)), chat: tally, mvp_user_id: mvp?.user_id || null };++ const voters = db.all('SELECT voter_key, side FROM arena_topic_sides WHERE topic_id = ?', [t.id]);++ settleClout(voters.map(v => v.voter_key), new Set(voters.filter(v => v.side === winner).map(v => v.voter_key)));++ if (mvp?.user_id) addXp(mvp.user_id, 30, 'debate_mvp', t.id);++ } else if (t.kind === 'bounty') {++ const top = db.get(`SELECT user_id, SUM(amount) AS s FROM arena_xp_log WHERE reason = 'bounty_hit' AND ref_id = ? GROUP BY user_id ORDER BY s DESC LIMIT 1`, [t.id]);++ resolved = { kind: 'bounty', claimed_by: top?.user_id || null, total: top?.s || 0 };++ if (top?.user_id) addXp(top.user_id, 40, 'bounty_claimed', t.id);++ } else if (t.kind === 'phrase') {++ const top = db.get(`SELECT user_id, SUM(amount) AS s FROM arena_xp_log WHERE reason = 'phrase_hit' AND ref_id = ? GROUP BY user_id ORDER BY s DESC LIMIT 1`, [t.id]);++ resolved = { kind: 'phrase', top_user_id: top?.user_id || null };++ }++ db.run(`UPDATE arena_topics SET status = 'resolved', winner_side = ?, resolved_json = ? WHERE id = ?`, [resolved.winner || null, JSON.stringify(resolved), t.id]);++ db.run('UPDATE arena_topic_members SET active = 0 WHERE topic_id = ?', [t.id]);++ results.push({ id: t.id, ...resolved });++ console.log(`[Arena] ${t.kind} #${t.id} resolved: ${JSON.stringify(resolved)}`);++ }++ return results;++}++++/** Bounty check for a beef hit: doubles XP while a bounty on the target is open. Returns the bounty topic or null. */++function openBountyOn(targetUserId) {++ ensureTables();++ return db.get(`SELECT * FROM arena_topics WHERE status = 'open' AND kind = 'bounty' AND target_user_id = ?`, [targetUserId]) || null;++}++function recordBountyHit(hitterId, bounty, quality) {++ addXp(hitterId, Math.round(quality), 'bounty_hit', bounty.id);++ db.run('UPDATE arena_topics SET hits = hits + 1, last_activity_at = CURRENT_TIMESTAMP WHERE id = ?', [bounty.id]);++}++/** Phrase challenges: no model needed — the phrase either came out of their mouth or it didn't. */++function checkPhrases(userId, text, lineRef) {++ ensureTables();++ const hits = [];++ for (const t of db.all(`SELECT * FROM arena_topics WHERE status = 'open' AND kind = 'phrase'`)) {++ if (!t.phrase || !String(text).toLowerCase().includes(String(t.phrase).toLowerCase())) continue;++ const already = db.get(`SELECT COUNT(*) AS n FROM arena_xp_log WHERE user_id = ? AND reason = 'phrase_hit' AND ref_id = ?`, [userId, t.id])?.n || 0;++ const xp = already === 0 ? 15 : 5;++ addXp(userId, xp, 'phrase_hit', t.id, { line: text.slice(0, 200), lineScore: 6, lineVodId: lineRef?.vod_id, lineSec: lineRef?.sec });++ db.run('UPDATE arena_topics SET hits = hits + 1, last_activity_at = CURRENT_TIMESTAMP WHERE id = ?', [t.id]);++ db.run('INSERT OR IGNORE INTO arena_topic_members (topic_id, user_id, active) VALUES (?, ?, 0)', [t.id, userId]);++ hits.push({ topic_id: t.id, phrase: t.phrase, xp });++ }++ return hits;++}++++function archiveStale(force = false) {++ ensureTables();++ db.run(`UPDATE arena_topics SET status = 'archived' WHERE status = 'open' AND last_activity_at < datetime('now', ?)`, [`-${TOPIC_TTL_HOURS} hours`]);++ if (force) {++ const extra = db.all(`SELECT id FROM arena_topics WHERE status = 'open' ORDER BY last_activity_at ASC`).slice(0, Math.max(0, (db.get(`SELECT COUNT(*) AS n FROM arena_topics WHERE status = 'open'`)?.n || 0) - MAX_OPEN_TOPICS + 1));++ for (const r of extra) db.run(`UPDATE arena_topics SET status = 'archived' WHERE id = ?`, [r.id]);++ }++}++++const TOPIC_SEED_SCHEMA = { name: 'arena_pulse', schema: { type: 'object', additionalProperties: false, required: ['pulse', 'events'], properties: {++ pulse: { type: 'string', description: 'One sentence, ≤ 140 chars: what the community is on about right now, hype-caster voice, name names' },++ events: { type: 'array', minItems: 2, maxItems: 5, items: { type: 'object', additionalProperties: false, required: ['kind', 'text', 'hint', 'source', 'headline', 'side_a', 'side_b', 'phrase', 'target_username'], properties: {++ kind: { type: 'string', enum: ['topic', 'debate', 'phrase', 'bounty'], description: 'topic = thing to rant on; debate = a take people are split on (fill side_a/side_b); phrase = a trending phrase/meme to work into your trash talk (fill phrase); bounty = someone the community keeps naming (fill target_username with an exact username from the snapshot)' },++ text: { type: 'string', description: '≤ 120 chars' }, hint: { type: 'string', description: '≤ 90 chars, cheeky' },++ source: { type: 'string', description: '≤ 6 words: where it came from, e.g. "global chat", "Goosely\'s stream", "the Maticus beef"' },++ headline: { type: 'string', description: 'Tabloid headline for the board, ≤ 90 chars, inflammatory but funny' },++ side_a: { type: 'string', description: 'debate only, else empty' }, side_b: { type: 'string', description: 'debate only, else empty' },++ phrase: { type: 'string', description: 'phrase only, else empty; 2–6 words exactly as people say it' }, target_username: { type: 'string', description: 'bounty only, else empty' },++ } } } } } };++++/**++ * What the site is talking about right now — the raw material for community topics.++ * Pulled from every AI/data source we have: the global chat AI summary + memory, the++ * chat timeline, the last hour of transcripts across live streams, recent stream++ * memories (what the vision model saw), streamer and VOD overviews, open beefs.++ */++function communityPulseInput() {++ const clip = (t, n) => String(t || '').replace(/\s+/g, ' ').trim().slice(0, n);++ const out = { at: new Date().toISOString() };++ try { const g = db.getChatAiSummary('global', 0, 'rolling'); if (g) { const ov = parseJson(g.overview, null); out.global_chat = clip(ov ? (ov.today || ov.alltime) : g.overview, 700); out.global_chat_memory = clip(g.memory_json, 500); } } catch { /* */ }++ try { out.chat_timeline = db.all(`SELECT label, detail FROM chat_timeline_events WHERE scope = 'global' AND created_at >= datetime('now', '-24 hours') ORDER BY id DESC LIMIT 12`).map(r => clip(`${r.label}: ${r.detail || ''}`, 140)); } catch { out.chat_timeline = []; }++ try { out.chat_now = db.all(`SELECT username, message FROM chat_messages WHERE COALESCE(is_deleted, 0) = 0 AND timestamp >= datetime('now', '-2 hours') AND LENGTH(message) BETWEEN 12 AND 160 ORDER BY id DESC LIMIT 40`).filter(r => !arena()._isBannedText(r.message)).slice(0, 25).map(r => clip(`${r.username}: ${r.message}`, 160)); } catch { out.chat_now = []; }++ try { out.on_stream_now = db.all(`SELECT u.username, e.text FROM stream_timeline_events e JOIN users u ON u.id = e.user_id WHERE e.kind = 'speech' AND e.created_at >= datetime('now', '-60 minutes') AND LENGTH(e.text) BETWEEN 30 AND 180 ORDER BY e.id DESC LIMIT 60`).filter(r => !arena()._isBannedText(r.text)).slice(0, 25).map(r => clip(`${r.username}: ${r.text}`, 180)); } catch { out.on_stream_now = []; }++ try { out.what_streams_looked_like = db.all(`SELECT u.username, m.description FROM stream_memories m JOIN users u ON u.id = m.user_id WHERE m.captured_at >= datetime('now', '-24 hours') ORDER BY m.id DESC LIMIT 10`).map(r => clip(`${r.username}: ${r.description}`, 200)); } catch { out.what_streams_looked_like = []; }++ try { out.streamer_overviews = db.all(`SELECT u.username, o.overview FROM streamer_overviews o JOIN users u ON u.id = o.user_id WHERE o.generated_at >= datetime('now', '-3 days') ORDER BY o.generated_at DESC LIMIT 6`).map(r => clip(`${r.username}: ${r.overview}`, 220)); } catch { out.streamer_overviews = []; }++ try { out.recent_vods = db.all(`SELECT v.title, va.ai_overview_short AS o FROM vods v LEFT JOIN vod_ai_state va ON va.vod_id = v.id WHERE v.is_public = 1 AND v.created_at >= datetime('now', '-3 days') ORDER BY v.created_at DESC LIMIT 6`).map(r => clip(`${r.title}: ${r.o || ''}`, 160)); } catch { out.recent_vods = []; }++ try { out.open_beefs = db.all(`SELECT a.username AS a, b.username AS b, x.opener_line FROM arena_beefs x JOIN users a ON a.id = x.a_user_id JOIN users b ON b.id = x.b_user_id WHERE x.status = 'open' ORDER BY x.opened_at DESC LIMIT 5`).map(r => clip(`${r.a} vs ${r.b}: ${r.opener_line || ''}`, 160)); } catch { out.open_beefs = []; }++ try { const roster = arena().loadRoster(); out.ladder_top = roster.order.slice(0, 6).map(id => (parseJson(db.get('SELECT persona_json FROM arena_profiles WHERE user_id = ?', [id])?.persona_json) || {}).fighter_name || roster.byId[id].user.display_name); } catch { out.ladder_top = []; }++ out.existing_topics = db.all(`SELECT text FROM arena_topics ORDER BY id DESC LIMIT 15`).map(r => r.text);++ return out;++}++++const PULSE_SYSTEM = `You are the community consciousness of a live-streaming site's Arena — part tabloid editor, part fight promoter, part ragebaiter. You get a snapshot of what the community is ACTUALLY talking about right now: global chat, what people said on their streams in the last hour, what the streams looked like, AI overviews, open beefs, the ladder. Turn it into (1) one hype-caster sentence about what everyone is on about (name names), and (2) 2–5 EVENTS for the board, specific to what is really going on — never generic:++- an argument or split opinion → a DEBATE with two sharp sides;++- a phrase, meme or running joke people keep saying → a PHRASE challenge (the exact words);++- someone the community keeps naming, dunking on or defending → a BOUNTY on that username;++- anything else spicy → a TOPIC to rant on.++Every event gets a tabloid HEADLINE that is inflammatory but funny and a source tag. Provoke: the point is to bait streamers into responding on stream and chat into picking sides. Profanity, disrespect, provocation and trolling are welcome; this platform does not censor language. Hard line: no direct threats of violence, nothing sexual about minors, no doxxing. Output only the JSON.`;++++let _pulse = { text: null, at: null, sources: [] };++const PULSE_REFRESH_MS = 30 * 60 * 1000;++const PULSE_MIN_OPEN = 6;++++/** Refresh the community pulse + add community topics. Called by the job; safe to call often. */++async function refreshPulse({ force = false } = {}) {++ ensureTables();++ archiveStale();++ if (!force && _pulse.at && Date.now() - _pulse.at < PULSE_REFRESH_MS) return { skipped: true };++ const open = db.get(`SELECT COUNT(*) AS n FROM arena_topics WHERE status = 'open'`)?.n || 0;++ let made = 0;++ if (aiOn()) {++ try {++ const input = communityPulseInput();++ const r = await llm.complete({ role: 'summary', kind: 'arena_pulse', source: 'arena', system: PULSE_SYSTEM, user: JSON.stringify(input), json: TOPIC_SEED_SCHEMA, maxTokens: 600, temperature: 0.95, timeoutMs: 30000 });++ if (r && r.json) {++ _pulse = { text: String(r.json.pulse || '').slice(0, 200), at: Date.now(), sources: Object.entries(input).filter(([k, v]) => Array.isArray(v) ? v.length : (v && k !== 'at' && k !== 'existing_topics')).map(([k]) => k) };++ const want = Math.max(2, PULSE_MIN_OPEN - open);++ for (const e of (r.json.events || []).slice(0, want)) {++ try {++ let targetUserId = null;++ if (e.kind === 'bounty') { const u = db.getUserByUsername(String(e.target_username || '').replace(/^@/, '')); if (!u || !arena().loadRoster().byId[u.id]) continue; targetUserId = u.id; }++ createTopic({ text: e.text, hint: e.hint, createdBy: 'community', creatorName: String(e.source || 'the community').slice(0, 40), kind: e.kind, sideA: e.side_a || null, sideB: e.side_b || null, phrase: e.phrase || null, targetUserId, headline: e.headline || null, sourceNote: e.source || null });++ made++;++ } catch { /* dup / invalid */ }++ }++ }++ } catch (e) { console.warn('[Arena] pulse:', e.message); }++ }++ if (!_pulse.at) _pulse = { text: null, at: Date.now(), sources: [] };++ if (open + made < 3) {++ for (const t of SEED_TOPICS.slice().sort(() => Math.random() - 0.5).slice(0, 3 - open - made)) { try { createTopic({ text: t.text, hint: t.hint, createdBy: 'ai' }); made++; } catch { /* dup */ } }++ }++ if (made) console.log(`[Arena] pulse refreshed — ${made} community topic(s) added`);++ return { made, pulse: _pulse.text };++}++function pulse() { return { text: _pulse.text, at: _pulse.at ? new Date(_pulse.at).toISOString() : null, sources: _pulse.sources }; }++async function seedIfEmpty() { return (await refreshPulse({ force: (db.get(`SELECT COUNT(*) AS n FROM arena_topics WHERE status = 'open'`)?.n || 0) < 3 })).made || 0; }++++const ANGLES_SCHEMA = { name: 'arena_topic_angles', schema: { type: 'object', additionalProperties: false, required: ['angles'], properties: { angles: { type: 'array', minItems: ANGLES_PER_TOPIC, maxItems: ANGLES_PER_TOPIC, items: { type: 'object', additionalProperties: false, required: ['text', 'hint'], properties: { text: { type: 'string', description: 'a specific angle inside the topic to talk shit about, ≤ 90 chars' }, hint: { type: 'string', description: '≤ 70 chars' } } } } } } };++++async function ensureAngles(topic) {++ let angles = parseJson(topic.angles_json, null);++ if (Array.isArray(angles) && angles.length) return angles;++ if (aiOn()) {++ try {++ const r = await llm.complete({++ role: 'summary', kind: 'arena_topic_angles', source: 'arena',++ system: 'Break a shit-talk topic into distinct ANGLES a live-streamer can rant on for 30–60 seconds each: one brag angle, one roast angle (name names), one bit/absurd/troll angle. Savage and funny; this platform does not censor language. Hard line only: no direct threats of violence, nothing sexual about minors, no doxxing. Output only the JSON.',++ user: JSON.stringify({ topic: topic.text, hint: topic.hint, created_by: topic.created_by }),++ json: ANGLES_SCHEMA, maxTokens: 300, temperature: 0.9, timeoutMs: 20000,++ });++ if (r && r.json && Array.isArray(r.json.angles)) angles = r.json.angles.map(a => ({ text: String(a.text).slice(0, 120), hint: String(a.hint || '').slice(0, 100) }));++ } catch (e) { console.warn('[Arena] angles:', e.message); }++ }++ if (!angles) angles = [++ { text: `Brag: why you win this — "${topic.text}"`, hint: 'Numbers optional, confidence mandatory.' },++ { text: `Roast: who is worst at this and why`, hint: 'Name a fighter. Lovingly.' },++ { text: `Bit: the most unhinged take on it`, hint: 'Commit to the bit.' },++ ];++ db.run('UPDATE arena_topics SET angles_json = ? WHERE id = ?', [JSON.stringify(angles), topic.id]);++ return angles;++}++++async function joinTopic(topicId, userId) {++ ensureTables();++ const topic = db.get(`SELECT * FROM arena_topics WHERE id = ? AND status = 'open'`, [topicId]);++ if (!topic) throw new Error('That topic is gone');++ if (!arena().loadRoster().byId[userId]) throw new Error('Only fighters on the roster can talk on a topic — stream once and come back');++ // One active topic per streamer: the listener judges their speech against it.++ db.run('UPDATE arena_topic_members SET active = 0 WHERE user_id = ? AND active = 1', [userId]);++ const existing = db.get('SELECT * FROM arena_topic_members WHERE topic_id = ? AND user_id = ?', [topicId, userId]);++ if (existing) db.run('UPDATE arena_topic_members SET active = 1 WHERE topic_id = ? AND user_id = ?', [topicId, userId]);++ else { db.run('INSERT INTO arena_topic_members (topic_id, user_id) VALUES (?, ?)', [topicId, userId]); db.run('UPDATE arena_topics SET joins = joins + 1 WHERE id = ?', [topicId]); }++ db.run('UPDATE arena_topics SET last_activity_at = CURRENT_TIMESTAMP WHERE id = ?', [topicId]);++ const angles = await ensureAngles(topic);++ return { topic: { ...topic, angles }, angles };++}++++function leaveTopic(userId) {++ ensureTables();++ db.run('UPDATE arena_topic_members SET active = 0 WHERE user_id = ? AND active = 1', [userId]);++}++++function activeTopicFor(userId) {++ ensureTables();++ const m = db.get(`SELECT t.* FROM arena_topic_members m JOIN arena_topics t ON t.id = m.topic_id WHERE m.user_id = ? AND m.active = 1 AND t.status = 'open' ORDER BY m.joined_at DESC LIMIT 1`, [userId]);++ return m || null;++}++++function progressFor(topicId, userId) {++ return db.all('SELECT * FROM arena_topic_progress WHERE topic_id = ? AND user_id = ? ORDER BY angle_idx ASC', [topicId, userId]);++}++++/**++ * Apply a judged chunk to the streamer's active topic. `judgement` is the listener's++ * topic judgement: { angle_idx (or -1), quality, progress_gain, best_line, about, flagged }.++ */++function applyTopicJudgement(userId, topic, judgement, lineRef) {++ if (judgement.flagged || judgement.angle_idx == null || judgement.angle_idx < 0) return { applied: false };++ const angles = parseJson(topic.angles_json, []);++ const idx = Math.max(0, Math.min(angles.length - 1, judgement.angle_idx));++ const cur = db.get('SELECT * FROM arena_topic_progress WHERE topic_id = ? AND user_id = ? AND angle_idx = ?', [topic.id, userId, idx]) || { progress: 0, score: 0, hits: 0, best_line: null, cleared_at: null };++ const wasCleared = !!cur.cleared_at;++ const progress = Math.min(100, (cur.progress || 0) + (judgement.progress_gain || 0));++ const better = judgement.best_line && (judgement.quality || 0) > (cur.best_score || 0);++ db.run(`INSERT INTO arena_topic_progress (topic_id, user_id, angle_idx, progress, score, hits, best_line, best_vod_id, best_sec, updated_at)++ VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, CURRENT_TIMESTAMP)++ ON CONFLICT(topic_id, user_id, angle_idx) DO UPDATE SET progress = ?, score = score + ?, hits = hits + 1,++ best_line = CASE WHEN ? THEN ? ELSE best_line END, best_vod_id = CASE WHEN ? THEN ? ELSE best_vod_id END, best_sec = CASE WHEN ? THEN ? ELSE best_sec END, updated_at = CURRENT_TIMESTAMP`,++ [topic.id, userId, idx, progress, judgement.quality || 0, judgement.best_line || null, lineRef?.vod_id || null, lineRef?.sec ?? null,++ progress, judgement.quality || 0, better ? 1 : 0, judgement.best_line || null, better ? 1 : 0, lineRef?.vod_id || null, better ? 1 : 0, lineRef?.sec ?? null]);++ db.run('UPDATE arena_topics SET hits = hits + 1, last_activity_at = CURRENT_TIMESTAMP WHERE id = ?', [topic.id]);++ let xp = Math.round((judgement.quality || 0) * 0.8);++ let clearedAngle = false, conquered = false;++ if (progress >= 100 && !wasCleared) {++ db.run('UPDATE arena_topic_progress SET cleared_at = CURRENT_TIMESTAMP WHERE topic_id = ? AND user_id = ? AND angle_idx = ?', [topic.id, userId, idx]);++ xp += XP_ANGLE_CLEARED; clearedAngle = true;++ const clearedCount = db.get('SELECT COUNT(*) AS n FROM arena_topic_progress WHERE topic_id = ? AND user_id = ? AND cleared_at IS NOT NULL', [topic.id, userId])?.n || 0;++ if (clearedCount >= angles.length) {++ const m = db.get('SELECT conquered_at FROM arena_topic_members WHERE topic_id = ? AND user_id = ?', [topic.id, userId]);++ if (!m?.conquered_at) {++ db.run('UPDATE arena_topic_members SET conquered_at = CURRENT_TIMESTAMP, active = 0 WHERE topic_id = ? AND user_id = ?', [topic.id, userId]);++ db.run('UPDATE arena_topics SET conquered = conquered + 1 WHERE id = ?', [topic.id]);++ xp += XP_TOPIC_CONQUERED; conquered = true;++ }++ }++ }++ const lvl = addXp(userId, xp, clearedAngle ? (conquered ? 'topic_conquered' : 'angle_cleared') : 'topic_hit', topic.id, { angle: clearedAngle, topic: conquered, line: judgement.best_line, lineScore: judgement.quality, lineVodId: lineRef?.vod_id, lineSec: lineRef?.sec });++ return { applied: true, angle_idx: idx, progress, cleared_angle: clearedAngle, conquered, xp, level: lvl };++}++++function hypeTopic(topicId, userId, voterKey) {++ ensureTables();++ if (voterKey === `user:${userId}`) throw new Error("You can't hype yourself");++ const ins = db.run('INSERT OR IGNORE INTO arena_topic_hype (topic_id, user_id, voter_key) VALUES (?, ?, ?)', [topicId, userId, voterKey]);++ if (ins.changes) addXp(userId, XP_HYPE, 'hype', topicId);++ const n = db.get('SELECT COUNT(*) AS n FROM arena_topic_hype WHERE topic_id = ? AND user_id = ?', [topicId, userId])?.n || 0;++ return { added: !!ins.changes, hypers: n };++}++++// ── Views ────────────────────────────────────────────────────++++function fighterBrief(userId, roster) {++ const f = roster.byId[userId];++ const persona = parseJson(db.get('SELECT persona_json FROM arena_profiles WHERE user_id = ?', [userId])?.persona_json);++ return {++ user: f ? f.user : (db.getUserById(userId) ? arena().publicUser(db.getUserById(userId)) : { id: userId, username: `user${userId}`, display_name: `user${userId}` }),++ fighter_name: persona?.fighter_name || f?.user?.display_name || `user${userId}`,++ rank: f ? roster.order.indexOf(userId) + 1 : null,++ image_url: (() => { try { return arena().getFighterImageUrl(userId); } catch { return null; } })(),++ live: !!db.get('SELECT 1 FROM streams WHERE user_id = ? AND is_live = 1 LIMIT 1', [userId]),++ level: levelFor(levelRow(userId).xp || 0),++ };++}++++function topicView(topic, roster, { detail = false } = {}) {++ const angles = parseJson(topic.angles_json, []);++ const members = db.all(`SELECT m.*, COALESCE((SELECT COUNT(*) FROM arena_topic_progress p WHERE p.topic_id = m.topic_id AND p.user_id = m.user_id AND p.cleared_at IS NOT NULL), 0) AS cleared, COALESCE((SELECT SUM(score) FROM arena_topic_progress p WHERE p.topic_id = m.topic_id AND p.user_id = m.user_id), 0) AS score FROM arena_topic_members m WHERE m.topic_id = ? ORDER BY score DESC`, [topic.id]);++ const heat = topic.heat || 0;++ const out = {++ id: topic.id, text: topic.text, hint: topic.hint, created_by: topic.created_by, creator_name: topic.creator_name, status: topic.status,++ kind: topic.kind || 'topic', side_a: topic.side_a, side_b: topic.side_b, phrase: topic.phrase, target: topic.target_user_id ? fighterBrief(topic.target_user_id, roster) : null,++ headline: topic.headline, source_note: topic.source_note, heat: Math.round(heat), hot: heat >= HOT_THRESHOLD,++ expires_at: topic.expires_at ? new Date(topic.expires_at + 'Z').toISOString() : null, winner_side: topic.winner_side, resolved: parseJson(topic.resolved_json),++ sides: topic.kind === 'debate' ? sideTally(topic.id) : null,++ angles: angles.map((a, i) => ({ idx: i, text: a.text, hint: a.hint })),++ joins: topic.joins || 0, hits: topic.hits || 0, conquered: topic.conquered || 0,++ talking_now: members.filter(m => m.active).map(m => fighterBrief(m.user_id, roster)),++ members: members.slice(0, 12).map(m => ({ ...fighterBrief(m.user_id, roster), active: !!m.active, cleared: m.cleared, score: Number((m.score || 0).toFixed(1)), conquered_at: m.conquered_at })),++ created_at: topic.created_at, last_activity_at: topic.last_activity_at,++ };++ if (detail) {++ out.progress = {};++ for (const m of members) out.progress[m.user_id] = progressFor(topic.id, m.user_id).map(p => ({ angle_idx: p.angle_idx, progress: Math.round(p.progress || 0), score: Number((p.score || 0).toFixed(1)), hits: p.hits, best_line: p.best_line, best_vod_id: p.best_vod_id, best_sec: p.best_sec, cleared: !!p.cleared_at }));++ out.best_lines = db.all(`SELECT p.*, p.user_id FROM arena_topic_progress p WHERE p.topic_id = ? AND p.best_line IS NOT NULL ORDER BY p.score DESC LIMIT 8`, [topic.id]).map(p => ({ ...fighterBrief(p.user_id, roster), text: p.best_line, vod_id: p.best_vod_id, sec: p.best_sec, angle_idx: p.angle_idx }));++ }++ return out;++}++++function boardView() {++ ensureTables();++ archiveStale();++ resolveExpired();++ const roster = arena().loadRoster();++ const topics = db.all(`SELECT * FROM arena_topics WHERE status = 'open' ORDER BY id DESC LIMIT ?`, [MAX_OPEN_TOPICS]);++ for (const t of topics) t.heat = computeHeat(t.id);++ topics.sort((x, y) => (y.heat - x.heat) || (Date.parse(y.last_activity_at) - Date.parse(x.last_activity_at)));++ const recent = db.all(`SELECT * FROM arena_topics WHERE status = 'resolved' AND kind != 'topic' ORDER BY id DESC LIMIT 6`).map(t => topicView(t, roster));++ return { open: topics.map(t => topicView(t, roster)), resolved: recent, pulse: pulse(), hot_threshold: HOT_THRESHOLD };++}++++function topicDetail(topicId) {++ ensureTables();++ const topic = db.get('SELECT * FROM arena_topics WHERE id = ?', [topicId]);++ if (!topic) return null;++ return topicView(topic, arena().loadRoster(), { detail: true });++}++++function levelsLeaderboard(limit = 10) {++ ensureTables();++ const roster = arena().loadRoster();++ return db.all('SELECT * FROM arena_trash_levels ORDER BY xp DESC LIMIT ?', [limit]).map(r => ({ ...fighterBrief(r.user_id, roster), xp: r.xp, level: levelFor(r.xp), angles_cleared: r.angles_cleared, topics_conquered: r.topics_conquered, beef_hits: r.beef_hits, best_line: r.best_line ? { text: r.best_line, vod_id: r.best_line_vod_id, sec: r.best_line_sec } : null }));++}++++module.exports = {++ ensureTables, createTopic, seedIfEmpty, ensureAngles, joinTopic, leaveTopic, activeTopicFor, progressFor, applyTopicJudgement, hypeTopic,++ addXp, levelRow, levelView, levelFor, recentXp, boardView, topicDetail, levelsLeaderboard, fighterBrief, archiveStale, refreshPulse, pulse, communityPulseInput,++ computeHeat, pickSide, sideTally, cloutLeaderboard, resolveExpired, openBountyOn, recordBountyHit, checkPhrases, HOT_THRESHOLD, KIND_TTL_HOURS,++ XP_PER_LEVEL, XP_ANGLE_CLEARED, XP_TOPIC_CONQUERED, XP_HYPE, TOPIC_TTL_HOURS, SEED_TOPICS,++};nnew file mode 100644@@@ -0,0 +1,273 @@++/**++ * OpenVibe.Live — Arena Listener (the ears)++ *++ * Every TICK_MS, for every LIVE fighter whose stream is being transcribed, read the new++ * speech lines and route them:++ *++ * mentions → a line that names another fighter (username, display name, fighter name,++ * "@name") starts/extends a MENTION BUFFER for that target; the next few++ * lines without a name stay in it (people keep ranting after the name drop).++ * Once the buffer has ≥ JUDGE_MIN_WORDS and ≥ JUDGE_MIN_INTERVAL_MS passed,++ * the beef judge decides whether it was trash talk AIMED AT that fighter.++ * Yes → beef.recordHit() (opens the beef, scores it, starts the other side's++ * clock). See beef.js.++ * topic → everything else goes to the streamer's ACTIVE BOARD TOPIC (if they picked++ * one): the topic judge says which angle (if any) the chunk addressed and how++ * well → board.applyTopicJudgement() (progress, XP, levels).++ *++ * Bounded: at most one judge call per stream per JUDGE_MIN_INTERVAL_MS; nothing happens++ * for streams nobody is talking on; the slur filter voids chunks before any model call.++ * State lives in memory (offsets are re-seeded from "now" on restart, so a restart never++ * replays old speech).++ */++'use strict';++++const db = require('../db/database');++const llm = require('../ai/llm');++++const TICK_MS = 15 * 1000;++const JUDGE_MIN_WORDS = 20;++const JUDGE_MIN_INTERVAL_MS = 30 * 1000;++const MENTION_TAIL_SEC = 45; // lines within this many seconds after a name-drop stay in the buffer++const BUFFER_MAX_CHARS = 1400;++const STALE_BUFFER_MS = 3 * 60 * 1000;++++const state = new Map(); // streamId → { userId, lastOffset, lastJudgeAt, mention: { [targetId]: { lines, lastAt } }, topic: { lines } }++++function aiOn() { try { return llm.isEnabled() && llm.withinBudget(); } catch { return false; } }++function arena() { return require('./arena-service'); }++function beef() { return require('./beef'); }++function board() { return require('./board'); }++function parseJson(t, f = null) { try { return t ? JSON.parse(t) : f; } catch { return f; } }++function words(t) { return String(t || '').split(/\s+/).filter(Boolean).length; }++++// ── Aliases: who can be called out, by which names ───────────++++let _aliasCache = { at: 0, list: [] };++function aliases(roster) {++ if (Date.now() - _aliasCache.at < 60 * 1000) return _aliasCache.list;++ const list = [];++ for (const id of roster.order) {++ const f = roster.byId[id];++ const persona = parseJson(db.get('SELECT persona_json FROM arena_profiles WHERE user_id = ?', [id])?.persona_json);++ const names = new Set([f.user.username, f.user.display_name, persona?.fighter_name].filter(Boolean).map(s => String(s).toLowerCase().trim()));++ for (const n of names) {++ if (n.length < 3) continue;++ // "@name", "name", and names with underscores/dots spoken as spaces.++ const spoken = n.replace(/[_.-]+/g, ' ');++ const variants = new Set([n, spoken]);++ for (const v of variants) list.push({ userId: id, name: v, re: new RegExp(`(?:^|[^a-z0-9])@?${v.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![a-z0-9])`, 'i') });++ }++ }++ // Longer names first so "goosely" beats "goose" style prefixes.++ list.sort((a, b) => b.name.length - a.name.length);++ _aliasCache = { at: Date.now(), list };++ return list;++}++++function mentionsIn(text, speakerId, roster) {++ const found = new Set();++ for (const a of aliases(roster)) {++ if (a.userId === speakerId || found.has(a.userId)) continue;++ if (a.re.test(text)) found.add(a.userId);++ }++ return [...found];++}++++// ── Judges ───────────────────────────────────────────────────++++const BEEF_SCHEMA = {++ name: 'arena_beef_judgement',++ schema: {++ type: 'object', additionalProperties: false,++ required: ['aimed_at_target', 'quality', 'best_line', 'about', 'announcer', 'flagged'],++ properties: {++ aimed_at_target: { type: 'boolean', description: 'true if the speaker is trash-talking, roasting, calling out or bragging AT/ABOUT the named target (in good fun)' },++ announcer: { type: 'string', description: 'one-line ring-announcer call of this moment, ≤ 110 chars, hype and funny (empty if not aimed at the target)' },++ quality: { type: 'integer', minimum: 0, maximum: 10, description: 'how good the trash talk is: spice, wit, specificity, quotability' },++ best_line: { type: 'string', description: 'the single best line, VERBATIM from the speech (empty if none)' },++ about: { type: 'string', description: '≤ 10 words: what they said about the target' },++ flagged: { type: 'boolean', description: 'ONLY for direct threats of violence, sexual content about minors, or doxxing — never for offensive language' },++ },++ },++};++const BEEF_SYSTEM = `You judge live streamer-vs-streamer shit talk. You get a target fighter's name(s) and the last ~60 seconds of a streamer's speech-to-text in which the target was named. Decide if the speaker is talking shit AT or ABOUT the target (roast, callout, disrespect, bragging over them, trolling them) versus just mentioning them neutrally (shout-out, thanks, gameplay chatter). Savage, profane, petty and personal is GOOD and scores high — creativity, specificity and quotability score highest; lazy generic insults score low. Pick the best verbatim line, summarise what was said about the target in ≤ 10 words. Forgive transcription noise. Offensive, provocative and taboo language — slurs included — is allowed on this platform and is NOT a reason to flag or to score low; judge the craft. Flag ONLY behavior: direct threats of violence, sexual content about minors, doxxing (addresses, phone numbers, private info). Output only the JSON.`;++++const TOPIC_SCHEMA = {++ name: 'arena_topic_judgement',++ schema: {++ type: 'object', additionalProperties: false,++ required: ['angle_idx', 'quality', 'progress_gain', 'best_line', 'about', 'flagged'],++ properties: {++ angle_idx: { type: 'integer', minimum: -1, maximum: 5, description: 'which angle (0-based) this chunk addressed, -1 if none / not trash talk' },++ quality: { type: 'integer', minimum: 0, maximum: 10 },++ progress_gain: { type: 'integer', minimum: 0, maximum: 60, description: 'how much of that angle this chunk covered' },++ best_line: { type: 'string' },++ about: { type: 'string', description: '≤ 10 words: what they talked about' },++ flagged: { type: 'boolean' },++ },++ },++};++const TOPIC_SYSTEM = `You judge a live streamer talking shit on a BOARD TOPIC that has several angles. You get the topic, its angles (0-based) and the last ~60 seconds of speech-to-text. Say which angle (if any) the speech addressed with actual shit talk / bragging / roasting / trolling / a committed bit, score its quality (savage, profane, petty, specific and quotable = high; generic = low), how much of that angle it covered, the best verbatim line, and a ≤ 10-word summary. Ordinary gameplay chatter or off-topic talk → angle_idx -1, progress 0. Forgive transcription noise. Offensive, provocative and taboo language — slurs included — is allowed on this platform and is NOT a reason to flag or to score low; judge the craft. Flag ONLY behavior: direct threats of violence, sexual content about minors, doxxing (addresses, phone numbers, private info). Output only the JSON.`;++++function heuristicBeef(text, targetNames) {++ const t = text.toLowerCase();++ const spicy = /\b(clown|weak|scared|duck|ducking|trash|garbage|mid|washed|bum|ratio|cook|cooked|better than|can't|cannot|never|nobody|beat|fraud|ass|bet|catch (these|this)|come see|fight me|square up|run it|talk (that|your))\b/.test(t);++ const excl = (text.match(/!/g) || []).length;++ const quality = Math.min(10, (spicy ? 5 : 1) + excl + (words(text) > 30 ? 1 : 0));++ const sentences = text.split(/(?<=[.!?])\s+/).filter(Boolean);++ const named = sentences.filter(l => targetNames.some(n => l.toLowerCase().includes(n)));++ const pick = (named.length ? named : sentences).sort((a, b) => b.length - a.length)[0] || text;++ return { aimed_at_target: spicy, quality, best_line: pick.trim().slice(0, 200), about: text.split(/\s+/).slice(0, 8).join(' '), flagged: false, _fallback: true };++}++function heuristicTopic(text, angles) {++ const t = text.toLowerCase();++ let best = -1, bestHits = 0;++ angles.forEach((a, i) => { const stems = (String(a.text).toLowerCase().match(/[a-z]{4,}/g) || []).map(w => w.slice(0, 5)); const hits = stems.filter(s => t.includes(s)).length; if (hits > bestHits) { bestHits = hits; best = i; } });++ const excl = (text.match(/!/g) || []).length;++ const quality = Math.min(10, 3 + excl + Math.min(3, bestHits));++ return { angle_idx: bestHits ? best : -1, quality, progress_gain: bestHits ? Math.min(60, 12 + bestHits * 10) : 0, best_line: (text.split(/(?<=[.!?])\s+/).sort((a, b) => b.length - a.length)[0] || '').slice(0, 200), about: text.split(/\s+/).slice(0, 8).join(' '), flagged: false, _fallback: true };++}++++async function judgeBeef(speakerId, targetId, text, roster) {++ if (arena()._isBannedText(text)) return { aimed_at_target: false, quality: 0, best_line: '', about: 'voided', flagged: true };++ const tf = roster.byId[targetId];++ const targetNames = [tf.user.username, tf.user.display_name, (parseJson(db.get('SELECT persona_json FROM arena_profiles WHERE user_id = ?', [targetId])?.persona_json) || {}).fighter_name].filter(Boolean);++ let j = null;++ if (aiOn()) {++ try {++ const r = await llm.complete({ role: 'chat', kind: 'arena_beef_judge', source: 'arena', ownerUserId: speakerId, system: BEEF_SYSTEM, user: JSON.stringify({ target_names: targetNames, speech: text }), json: BEEF_SCHEMA, maxTokens: 220, temperature: 0.4, timeoutMs: 25000 });++ if (r && r.json && typeof r.json.quality === 'number') j = r.json;++ } catch (e) { console.warn('[Arena] beef judge:', e.message); }++ }++ if (!j) j = heuristicBeef(text, targetNames.map(n => n.toLowerCase()));++ return { aimed_at_target: !!j.aimed_at_target && !j.flagged, quality: Math.max(0, Math.min(10, Math.round(Number(j.quality) || 0))), best_line: String(j.best_line || '').slice(0, 220), about: String(j.about || '').slice(0, 80), announcer: String(j.announcer || '').slice(0, 140), flagged: !!j.flagged, fallback: !!j._fallback };++}++++async function judgeTopic(speakerId, topic, text) {++ if (arena()._isBannedText(text)) return { angle_idx: -1, quality: 0, progress_gain: 0, best_line: '', about: 'voided', flagged: true };++ const angles = parseJson(topic.angles_json, []);++ let j = null;++ if (aiOn()) {++ try {++ const r = await llm.complete({ role: 'chat', kind: 'arena_topic_judge', source: 'arena', ownerUserId: speakerId, system: TOPIC_SYSTEM, user: JSON.stringify({ topic: topic.text, hint: topic.hint, angles: angles.map((a, i) => `${i}: ${a.text}`), speech: text }), json: TOPIC_SCHEMA, maxTokens: 220, temperature: 0.4, timeoutMs: 25000 });++ if (r && r.json && typeof r.json.quality === 'number') j = r.json;++ } catch (e) { console.warn('[Arena] topic judge:', e.message); }++ }++ if (!j) j = heuristicTopic(text, angles);++ const idx = Number.isInteger(j.angle_idx) && j.angle_idx >= 0 && j.angle_idx < angles.length ? j.angle_idx : -1;++ return { angle_idx: j.flagged ? -1 : idx, quality: Math.max(0, Math.min(10, Math.round(Number(j.quality) || 0))), progress_gain: Math.max(0, Math.min(60, Math.round(Number(j.progress_gain) || 0))), best_line: String(j.best_line || '').slice(0, 220), about: String(j.about || '').slice(0, 80), flagged: !!j.flagged, fallback: !!j._fallback };++}++++// ── Tick ─────────────────────────────────────────────────────++++function liveTranscribedStreams(roster) {++ return db.all(`SELECT s.id, s.user_id, s.started_at FROM streams s WHERE s.is_live = 1 AND EXISTS (SELECT 1 FROM stream_timeline_events e WHERE e.stream_id = s.id AND e.kind = 'speech' AND e.created_at >= datetime('now', '-30 minutes'))`)++ .filter(s => roster.byId[s.user_id]);++}++++function streamOffsetNow(stream) {++ const startedMs = stream.started_at ? Date.parse(String(stream.started_at).replace(' ', 'T') + 'Z') : Date.now();++ return Math.max(0, (Date.now() - startedMs) / 1000);++}++++function bufferText(lines) { return lines.map(l => l.t.replace(/^\s*(?:>>|--?)\s*/, '').trim()).filter(Boolean).join(' ').replace(/\s+/g, ' ').slice(-BUFFER_MAX_CHARS); }++function lineRefFor(lines, bestLine) {++ const needle = String(bestLine || '').toLowerCase().slice(0, 30);++ const hit = (needle && lines.find(l => l.t.toLowerCase().includes(needle))) || lines[0];++ return hit ? { vod_id: hit.v || null, sec: Math.max(0, hit.s - 2) } : { vod_id: null, sec: null };++}++++async function tickStream(stream, roster, events) {++ let st = state.get(stream.id);++ if (!st) { st = { userId: stream.user_id, lastOffset: streamOffsetNow(stream) - 20, lastJudgeAt: 0, mention: {}, topic: { lines: [] } }; state.set(stream.id, st); }++ const rows = db.all(`SELECT text, start_sec, vod_id FROM stream_timeline_events WHERE stream_id = ? AND kind = 'speech' AND start_sec > ? ORDER BY start_sec ASC LIMIT 100`, [stream.id, st.lastOffset]);++ if (rows.length) st.lastOffset = rows[rows.length - 1].start_sec;++ const now = Date.now();++ for (const r of rows) {++ const line = { t: String(r.text || ''), s: Math.floor(r.start_sec), v: r.vod_id || null };++ try { const ph = board().checkPhrases(stream.user_id, line.t, { vod_id: line.v, sec: Math.max(0, line.s - 2) }); if (ph.length) events.push({ kind: 'phrase_hit', streamId: stream.id, speakerId: stream.user_id, hits: ph }); } catch { /* */ }++ const targets = mentionsIn(line.t, stream.user_id, roster);++ if (targets.length) {++ for (const tid of targets) { const m = st.mention[tid] || (st.mention[tid] = { lines: [], lastAt: 0, lastSec: 0 }); m.lines.push(line); m.lastAt = now; m.lastSec = line.s; }++ } else {++ // Tail: keep feeding the most recent mention buffer for a bit after the name drop.++ const recent = Object.entries(st.mention).filter(([, m]) => line.s - m.lastSec <= MENTION_TAIL_SEC && now - m.lastAt < STALE_BUFFER_MS).sort((x, y) => y[1].lastSec - x[1].lastSec)[0];++ if (recent) recent[1].lines.push(line);++ else st.topic.lines.push(line);++ }++ }++ // Drop stale mention buffers that never reached the judge.++ for (const [tid, m] of Object.entries(st.mention)) if (now - m.lastAt > STALE_BUFFER_MS) delete st.mention[tid];++ if (st.topic.lines.length > 60) st.topic.lines = st.topic.lines.slice(-60);++++ if (now - st.lastJudgeAt < JUDGE_MIN_INTERVAL_MS) return;++++ // 1) Mention buffers first (a callout is the interesting thing).++ for (const [tidStr, m] of Object.entries(st.mention)) {++ const tid = Number(tidStr);++ if (m.lines.reduce((n, l) => n + words(l.t), 0) < JUDGE_MIN_WORDS) continue;++ const text = bufferText(m.lines);++ const lines = m.lines; delete st.mention[tidStr];++ st.lastJudgeAt = Date.now();++ const j = await judgeBeef(stream.user_id, tid, text, roster);++ if (j.aimed_at_target) {++ const ref = lineRefFor(lines, j.best_line);++ const res = beef().recordHit(stream.user_id, tid, { quality: j.quality, best_line: j.best_line, about: j.about, announcer: j.announcer, vod_id: ref.vod_id, sec: ref.sec });++ st.lastBeefJudgement = { at: new Date().toISOString(), target_id: tid, ...j, opened: res?.opened, bounty: res?.bounty };++ events.push({ kind: 'beef_hit', streamId: stream.id, speakerId: stream.user_id, targetId: tid, opened: res?.opened, quality: j.quality, line: j.best_line });++ } else {++ events.push({ kind: 'beef_miss', streamId: stream.id, speakerId: stream.user_id, targetId: tid, about: j.about });++ }++ return; // one judge call per stream per tick++ }++++ // 2) Active board topic.++ const topic = board().activeTopicFor(stream.user_id);++ if (topic && st.topic.lines.reduce((n, l) => n + words(l.t), 0) >= JUDGE_MIN_WORDS) {++ const lines = st.topic.lines; st.topic.lines = [];++ const text = bufferText(lines);++ st.lastJudgeAt = Date.now();++ if (!parseJson(topic.angles_json, null)) await board().ensureAngles(topic);++ const fresh = db.get('SELECT * FROM arena_topics WHERE id = ?', [topic.id]);++ const j = await judgeTopic(stream.user_id, fresh, text);++ const ref = lineRefFor(lines, j.best_line);++ const res = board().applyTopicJudgement(stream.user_id, fresh, j, ref);++ st.lastTopicJudgement = { at: new Date().toISOString(), ...j, applied: res.applied, angle_idx: res.applied ? res.angle_idx : -1, progress: res.progress, cleared_angle: res.cleared_angle, conquered: res.conquered };++ events.push({ kind: res.applied ? 'topic_hit' : 'topic_miss', streamId: stream.id, speakerId: stream.user_id, topicId: topic.id, ...res });++ } else if (!topic && st.topic.lines.length > 80) {++ st.topic.lines = st.topic.lines.slice(-40);++ }++}++++let _timer = null, _busy = false;++async function tick() {++ if (_busy) return [];++ _busy = true;++ const events = [];++ try {++ const roster = arena().loadRoster();++ const streams = liveTranscribedStreams(roster);++ for (const s of streams) { try { await tickStream(s, roster, events); } catch (e) { console.warn(`[Arena] listener stream ${s.id}:`, e.message); } }++ for (const id of [...state.keys()]) if (!streams.find(s => s.id === id)) state.delete(id);++ try { beef().tick(); } catch (e) { console.warn('[Arena] beef tick:', e.message); }++ } finally { _busy = false; }++ return events;++}++++function consoleState(userId) {++ for (const [streamId, st] of state) if (st.userId === userId) {++ return { stream_id: streamId, listening: true, mention_buffers: Object.keys(st.mention).length, pending_topic_words: st.topic.lines.reduce((n, l) => n + words(l.t), 0), last_topic_judgement: st.lastTopicJudgement || null, last_beef_judgement: st.lastBeefJudgement || null, last_judge_at: st.lastJudgeAt ? new Date(st.lastJudgeAt).toISOString() : null };++ }++ return { listening: false };++}++++function start() {++ if (_timer) return;++ _timer = setInterval(() => tick().catch(e => console.warn('[Arena] listener:', e.message)), TICK_MS);++ if (_timer.unref) _timer.unref();++ console.log('[Arena] listener started (every 15 s)');++}++function stop() { if (_timer) { clearInterval(_timer); _timer = null; } }++++module.exports = { start, stop, tick, consoleState, TICK_MS, JUDGE_MIN_WORDS, JUDGE_MIN_INTERVAL_MS, _mentionsIn: mentionsIn, _aliases: aliases, _heuristicBeef: heuristicBeef, _heuristicTopic: heuristicTopic, _state: state };@@@ -1,218 +1,163 @@/*** 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)++ * GET /status · /fighters · /fighters/:user · /fighters/:user/stat/:stat · /live++ * POST /fighters/:user/refresh admin: regenerate persona (+ portrait)++ * GET /console/:user what the listener hears for a live fighter++ * GET /board pulse + open events (topics/debates/phrases/bounties) + leaderboards++ * POST /board/topics {text} anyone signed in starts a topic++ * GET /board/topics/:id · POST …/join · …/leave · …/side {side} · …/hype {user_id}++ * POST /board/bounty {username} put a bounty on a fighter++ * GET /beefs · /beefs/:id · POST /beefs/:id/hype {side} · POST /beefs/:id/side {side}++ * GET /levels · /clout Trash Level ladder · viewers with the best picks*/'use strict';const express = require('express');const { requireAuth, optionalAuth } = require('../auth/auth');const permissions = require('../auth/permissions');++const db = require('../db/database');const arena = require('./arena-service');++const board = require('./board');++const beef = require('./beef');++const listener = require('./listener');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.use((req, res, next) => { if (!arena.arenaEnabled()) return res.status(404).json({ error: 'Arena is disabled' }); next(); });++const fail = (res, err, msg) => { console.error('[Arena]', msg, err.message); res.status(500).json({ error: msg }); };++const userFrom = (param) => (/^\d+$/.test(String(param)) ? db.getUserById(Number(param)) : db.getUserByUsername(String(param).replace(/^@/, '')));--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('/status', (req, res) => { try { res.json(arena.status()); } catch (err) { fail(res, err, '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' });-- }--});----// ── Trash Talk (server/arena/trash-talk.js) ──--const talk = require('./trash-talk');----router.get('/talk', optionalAuth, async (req, res) => {-- try {-- res.set('Cache-Control', 'no-store');-- res.json(await talk.board({ userId: req.user?.id || null, generate: req.query.generate !== '0' }));-- } catch (err) {-- console.error('[Arena] talk board:', err.message);-- res.status(500).json({ error: 'Failed to load Trash Talk' });-- }--});----router.post('/talk/mic/start', requireAuth, (req, res) => {-- try { res.json(talk.startMic(req.user.id)); }-- catch (err) { res.status(400).json({ error: err.message }); }--});----router.get('/talk/mic/feed', requireAuth, (req, res) => {-- try { res.set('Cache-Control', 'no-store'); res.json(talk.micFeed(req.user.id)); }-- catch (err) { res.status(400).json({ error: err.message }); }--});----router.post('/talk/submit', requireAuth, async (req, res) => {-- try {-- const mode = req.body?.mode === 'mic' ? 'mic' : 'text';-- res.json({ ok: true, entry: await talk.submit(req.user.id, { mode, text: req.body?.text }) });-- } catch (err) {-- res.status(400).json({ error: err.message });-- }--});----// ── Live trash-talk sessions (server/arena/talk-session.js) ──--const sessions = require('./talk-session');----router.get('/talk/sessions', (req, res) => {-- try { res.set('Cache-Control', 'no-store'); res.json({ sessions: sessions.liveSessionSummaries() }); }-- catch (err) { res.status(500).json({ error: err.message }); }--});--router.get('/talk/session/:user', (req, res) => {-- try {-- const v = sessions.viewFor(req.params.user);-- if (!v) return res.status(404).json({ error: 'No such user' });-- res.set('Cache-Control', 'no-store');-- res.json(v);-- } catch (err) { res.status(500).json({ error: err.message }); }--});--router.post('/talk/session/start', requireAuth, async (req, res) => {-- try { const s = await sessions.startSession(req.user.id); res.json({ ok: true, session: sessions.sessionView(s) }); }-- catch (err) { res.status(400).json({ error: err.message }); }--});--router.post('/talk/session/stop', requireAuth, (req, res) => {-- try { const s = sessions.stopSession(req.user.id); res.json({ ok: true, session: sessions.sessionView(s) }); }-- catch (err) { res.status(400).json({ error: err.message }); }--});--router.post('/talk/session/skip', requireAuth, async (req, res) => {-- try { const t = await sessions.skipTopic(req.user.id); res.json({ ok: true, topic: t ? { topic: t.topic, hint: t.hint, idx: t.idx } : null }); }-- catch (err) { res.status(400).json({ error: err.message }); }--});--router.post('/talk/session/:user/hype', optionalAuth, (req, res) => {-- try {-- const db = require('../db/database');-- const user = /^\d+$/.test(req.params.user) ? db.getUserById(Number(req.params.user)) : db.getUserByUsername(req.params.user);-- if (!user) return res.status(404).json({ error: 'No such user' });-- const r = sessions.hypeSession(user.id, arena.voterKeyFor(req));-- if (!r) return res.status(404).json({ error: 'No live session' });-- res.json(r);-- } catch (err) { res.status(400).json({ error: err.message }); }--});----router.post('/talk/:id/hype', optionalAuth, (req, res) => {-- try { res.json(talk.hype(Number(req.params.id), arena.voterKeyFor(req))); }-- catch (err) { res.status(400).json({ error: err.message }); }--});----router.get('/main-event', optionalAuth, async (req, res) => {-- try {-- const battle = await arena.getMainEvent({ generate: req.query.generate !== '0' });-- if (!battle) return res.status(404).json({ error: 'No main event yet — need at least two fighters' });-- let yourVote = null;-- try {-- const row = require('../db/database').get('SELECT side FROM arena_votes WHERE battle_id = ? AND voter_key = ?', [battle.id, arena.voterKeyFor(req)]);-- yourVote = row ? row.side : null;-- } catch { /* */ }-- res.set('Cache-Control', 'no-store');-- res.json({ ...battle, your_vote: yourVote });-- } catch (err) {-- console.error('[Arena] main event:', err.message);-- res.status(500).json({ error: 'Failed to load the main event' });-- }++ try { res.set('Cache-Control', 'public, max-age=30'); res.json({ fighters: arena.listFighters(), ai: arena.aiOn(), image_generation: arena.imageGenAvailable() }); }++ catch (err) { fail(res, err, 'Failed to load the roster'); }});++router.get('/live', (req, res) => { try { res.set('Cache-Control', 'no-store'); res.json({ live: arena.liveFighters() }); } catch (err) { fail(res, err, 'Failed to load live fighters'); } });router.get('/fighters/:user/stat/:stat', (req, res) => {try {-- const card = arena.loadRoster();-- const db = require('../db/database');-- const user = /^\d+$/.test(req.params.user) ? db.getUserById(Number(req.params.user)) : db.getUserByUsername(req.params.user);-- if (!user || !card.byId[user.id]) return res.status(404).json({ error: 'No such fighter on the roster' });++ const user = userFrom(req.params.user);++ if (!user || !arena.loadRoster().byId[user.id]) return res.status(404).json({ error: 'No such fighter on the roster' });const detail = arena.getStatDetail(user.id, String(req.params.stat));if (!detail) return res.status(404).json({ error: 'Unknown stat' });res.set('Cache-Control', 'public, max-age=120');res.json(detail);-- } catch (err) {-- console.error('[Arena] stat detail:', err.message);-- res.status(500).json({ error: 'Failed to load stat detail' });-- }++ } catch (err) { fail(res, err, 'Failed to load stat detail'); }});--router.get('/fighters/:user', async (req, res) => {try {-- const generate = req.query.generate !== '0';-- const card = await arena.getFighter(req.params.user, { generate });++ const card = await arena.getFighter(req.params.user, { generate: req.query.generate !== '0' });if (!card) return res.status(404).json({ error: 'No such fighter' });++ if (!card.not_on_roster) { try { card.rivalries = beef.rivalriesFor(card.user.id); } catch { card.rivalries = []; } }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' });-- }++ } catch (err) { fail(res, err, '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 });++ const image = req.body?.image !== false && arena.imageGenAvailable() ? await arena.generateImage(card.user.id, { force: true }) : null;res.json({ ok: true, persona, image_url: image });-- } catch (err) {-- console.error('[Arena] refresh:', err.message);-- res.status(500).json({ error: 'Refresh failed' });-- }++ } catch (err) { fail(res, err, 'Refresh failed'); }});--router.get('/battle/:a/:b', optionalAuth, async (req, res) => {++// ── Live console: what the ears hear for one fighter ──++router.get('/console/:user', (req, res) => {++ try {++ const user = userFrom(req.params.user);++ if (!user) return res.status(404).json({ error: 'No such user' });++ const roster = arena.loadRoster();++ const brief = board.fighterBrief(user.id, roster);++ const liveStream = db.get('SELECT id, title, started_at, viewer_count FROM streams WHERE user_id = ? AND is_live = 1 ORDER BY started_at DESC LIMIT 1', [user.id]);++ const active = board.activeTopicFor(user.id);++ let lines = [];++ if (liveStream) {++ lines = db.all(`SELECT text, start_sec, vod_id FROM stream_timeline_events WHERE stream_id = ? AND kind = 'speech' ORDER BY start_sec DESC LIMIT 14`, [liveStream.id]).reverse()++ .filter(l => !arena._isBannedText(l.text)).map(l => ({ text: l.text, sec: Math.floor(l.start_sec), vod_id: l.vod_id }));++ }++ res.set('Cache-Control', 'no-store');++ res.json({++ fighter: brief, on_roster: !!roster.byId[user.id], live: !!liveStream, stream: liveStream || null,++ transcribed: liveStream ? !!db.get(`SELECT 1 FROM stream_timeline_events WHERE stream_id = ? AND kind = 'speech' AND created_at >= datetime('now', '-30 minutes') LIMIT 1`, [liveStream.id]) : false,++ listener: listener.consoleState(user.id),++ level: board.levelView(user.id),++ active_topic: active ? { ...board.topicDetail(active.id), my_progress: board.progressFor(active.id, user.id) } : null,++ open_beefs: beef.openBeefsFor(user.id).map(b => beef.beefView(b, roster)),++ hot_mic: lines,++ bounty_on_me: board.openBountyOn(user.id) ? board.topicDetail(board.openBountyOn(user.id).id) : null,++ });++ } catch (err) { fail(res, err, 'Failed to load console'); }++});++++// ── Board ──++router.get('/board', (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 { /* */ }++ const v = board.boardView();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.' });-- }++ res.json({ ...v, levels: board.levelsLeaderboard(8), clout: board.cloutLeaderboard(8), ai: arena.aiOn() });++ } catch (err) { fail(res, err, 'Failed to load the board'); }});----router.post('/battle/:a/:b/vote', optionalAuth, async (req, res) => {++router.post('/board/topics', requireAuth, async (req, res) => {++ try {++ const onRoster = !!arena.loadRoster().byId[req.user.id];++ const t = board.createTopic({ text: req.body?.text, hint: req.body?.hint || null, createdBy: onRoster ? 'streamer' : 'viewer', creatorUserId: req.user.id, creatorName: req.user.display_name || req.user.username });++ board.ensureAngles(t).catch(() => {});++ res.json({ ok: true, topic: board.topicDetail(t.id) });++ } catch (err) { res.status(400).json({ error: err.message }); }++});++router.post('/board/bounty', requireAuth, (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 });-- }++ const target = userFrom(String(req.body?.username || ''));++ if (!target || !arena.loadRoster().byId[target.id]) return res.status(404).json({ error: 'No such fighter on the roster' });++ if (target.id === req.user.id) return res.status(400).json({ error: "You can't put a bounty on yourself" });++ const name = board.fighterBrief(target.id, arena.loadRoster()).fighter_name;++ const t = board.createTopic({ text: `Bounty: ${name}. Anyone who talks shit about them on stream gets double XP.`, hint: `Put up by ${req.user.display_name || req.user.username}. Say the name, collect the bag.`, createdBy: 'viewer', creatorUserId: req.user.id, creatorName: req.user.display_name || req.user.username, kind: 'bounty', targetUserId: target.id, headline: `WANTED: ${name} — chat wants smoke` });++ res.json({ ok: true, topic: board.topicDetail(t.id) });++ } catch (err) { res.status(400).json({ error: err.message }); }++});++router.get('/board/topics/:id', (req, res) => {++ try {++ const t = board.topicDetail(Number(req.params.id));++ if (!t) return res.status(404).json({ error: 'No such topic' });++ res.set('Cache-Control', 'no-store');++ res.json(t);++ } catch (err) { fail(res, err, 'Failed to load topic'); }++});++router.post('/board/topics/:id/join', requireAuth, async (req, res) => {++ try { const r = await board.joinTopic(Number(req.params.id), req.user.id); res.json({ ok: true, topic: board.topicDetail(r.topic.id) }); }++ catch (err) { res.status(400).json({ error: err.message }); }++});++router.post('/board/topics/:id/leave', requireAuth, (req, res) => { try { board.leaveTopic(req.user.id); res.json({ ok: true }); } catch (err) { res.status(400).json({ error: err.message }); } });++router.post('/board/topics/:id/side', optionalAuth, (req, res) => {++ try { res.json(board.pickSide(Number(req.params.id), arena.voterKeyFor(req), String(req.body?.side || ''), req.user?.id || null)); }++ catch (err) { res.status(400).json({ error: err.message }); }});++router.post('/board/topics/:id/hype', optionalAuth, (req, res) => {++ try { res.json(board.hypeTopic(Number(req.params.id), Number(req.body?.user_id), arena.voterKeyFor(req))); }++ catch (err) { res.status(400).json({ error: err.message }); }++});++router.post('/pulse/refresh', requireAuth, permissions.requireAdmin, async (req, res) => {++ try { res.json(await board.refreshPulse({ force: true })); } catch (err) { fail(res, err, 'Pulse refresh failed'); }++});++++// ── Beefs ──++router.get('/beefs', (req, res) => { try { res.set('Cache-Control', 'no-store'); res.json(beef.list()); } catch (err) { fail(res, err, 'Failed to load beefs'); } });++router.get('/beefs/:id', (req, res) => {++ try { const b = beef.get(Number(req.params.id)); if (!b) return res.status(404).json({ error: 'No such beef' }); res.set('Cache-Control', 'no-store'); res.json(b); }++ catch (err) { fail(res, err, 'Failed to load beef'); }++});++router.post('/beefs/:id/hype', optionalAuth, (req, res) => {++ try { res.json(beef.hype(Number(req.params.id), String(req.body?.side || ''), arena.voterKeyFor(req))); } catch (err) { res.status(400).json({ error: err.message }); }++});++router.post('/beefs/:id/side', optionalAuth, (req, res) => {++ try { res.json(beef.pickSide(Number(req.params.id), arena.voterKeyFor(req), String(req.body?.side || ''), req.user?.id || null)); } catch (err) { res.status(400).json({ error: err.message }); }++});++++router.get('/levels', (req, res) => { try { res.json({ levels: board.levelsLeaderboard(20) }); } catch (err) { fail(res, err, 'Failed'); } });++router.get('/clout', (req, res) => { try { res.json({ clout: board.cloutLeaderboard(20) }); } catch (err) { fail(res, err, 'Failed'); } });module.exports = router;ddeleted file mode 100644@@@ -1,434 +0,0 @@--/**-- * OpenVibe.Live — Arena Trash Talk SESSIONS (live, continuous, transcription-driven)-- *-- * A streamer who is live with the timeline transcription running starts a session. From-- * then on, every TICK_MS the session reads the new speech lines off the transcript,-- * buffers them against the CURRENT TOPIC, and — once enough new words have arrived —-- * asks the judge whether that chunk was trash talk on the topic and how good it was.-- *-- * progress → each judged chunk adds `progress_gain` (0–60) to the topic; at 100 the-- * topic is CLEARED: it becomes a regular Trash Talk entry (so it counts for-- * the POWER bonus and the Hall of Trash) and the NEXT topic is generated,-- * shaped by what they have already cleared. Talk enough → the topic changes.-- * xp/level → every judged chunk adds XP (quality × trash-talkiness); levels every-- * XP_PER_LEVEL. Viewers add XP + progress with `!hype` (one per person per-- * topic).-- * about → each chunk also yields a ≤ 10-word "what they talked about" tag, so the-- * page shows what kind of trash was talked during the session.-- *-- * Everything is visible live at /arena/talk/<username> (public) and polled every few-- * seconds; the streamer sees the same console with start / skip / stop controls.-- * Sessions end when the stream ends, after IDLE_END_MIN minutes without speech, or at-- * MAX_SESSION_HOURS. AI use is bounded: one judge call per ≥ JUDGE_MIN_INTERVAL_MS and-- * ≥ JUDGE_MIN_WORDS new words, a few hundred per session at most.-- */--'use strict';----const db = require('../db/database');--const llm = require('../ai/llm');----const TICK_MS = 15 * 1000;--const JUDGE_MIN_INTERVAL_MS = 30 * 1000;--const JUDGE_MIN_WORDS = 25;--const JUDGE_MAX_CALLS = 300;--const IDLE_END_MIN = 20;--const MAX_SESSION_HOURS = 3;--const XP_PER_LEVEL = 40;--const HYPE_XP = 2;--const HYPE_PROGRESS = 4;--const CHUNK_MAX_CHARS = 1400;----let _ready = false;--function ensureTables() {-- if (_ready) return;-- db.run(`CREATE TABLE IF NOT EXISTS arena_talk_sessions (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- user_id INTEGER NOT NULL,-- stream_id INTEGER NOT NULL,-- status TEXT NOT NULL DEFAULT 'live',-- level INTEGER DEFAULT 1,-- xp INTEGER DEFAULT 0,-- topics_cleared INTEGER DEFAULT 0,-- lines_seen INTEGER DEFAULT 0,-- words_seen INTEGER DEFAULT 0,-- judge_calls INTEGER DEFAULT 0,-- last_offset_sec REAL DEFAULT 0,-- offset_start_sec REAL DEFAULT 0,-- last_judge_at DATETIME,-- last_speech_at DATETIME,-- about_json TEXT,-- started_at DATETIME DEFAULT CURRENT_TIMESTAMP,-- ended_at DATETIME,-- end_reason TEXT-- )`);-- db.run(`CREATE TABLE IF NOT EXISTS arena_talk_session_topics (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- session_id INTEGER NOT NULL,-- idx INTEGER NOT NULL,-- topic TEXT NOT NULL,-- hint TEXT,-- tone TEXT,-- status TEXT NOT NULL DEFAULT 'active',-- progress REAL DEFAULT 0,-- score REAL DEFAULT 0,-- chunks INTEGER DEFAULT 0,-- best_line TEXT,-- best_line_score REAL DEFAULT 0,-- best_line_sec INTEGER,-- best_vod_id INTEGER,-- pending_json TEXT,-- judged_json TEXT,-- entry_id INTEGER,-- started_at DATETIME DEFAULT CURRENT_TIMESTAMP,-- cleared_at DATETIME-- )`);-- db.run(`CREATE TABLE IF NOT EXISTS arena_talk_session_hype (-- topic_id INTEGER NOT NULL,-- voter_key TEXT NOT NULL,-- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,-- UNIQUE(topic_id, voter_key)-- )`);-- db.run('CREATE INDEX IF NOT EXISTS idx_arena_talk_sessions_live ON arena_talk_sessions (status, user_id)');-- _ready = true;--}----function aiOn() { try { return llm.isEnabled() && llm.withinBudget(); } catch { return false; } }--function parseJson(t, f = null) { try { return t ? JSON.parse(t) : f; } catch { return f; } }--function arena() { return require('./arena-service'); }--function talk() { return require('./trash-talk'); }--function levelFor(xp) { return 1 + Math.floor((Number(xp) || 0) / XP_PER_LEVEL); }--function words(t) { return String(t || '').split(/\s+/).filter(Boolean).length; }----// ── Lookups ──────────────────────────────────────────────────----function liveSessionFor(userId) {-- ensureTables();-- return db.get(`SELECT * FROM arena_talk_sessions WHERE user_id = ? AND status = 'live' ORDER BY id DESC LIMIT 1`, [userId]) || null;--}--function activeTopic(sessionId) {-- return db.get(`SELECT * FROM arena_talk_session_topics WHERE session_id = ? AND status = 'active' ORDER BY idx DESC LIMIT 1`, [sessionId]) || null;--}--function liveSessions() {-- ensureTables();-- return db.all(`SELECT * FROM arena_talk_sessions WHERE status = 'live'`);--}----// ── Topics for a session ─────────────────────────────────────----const TOPIC_SCHEMA = {-- name: 'arena_session_topic',-- schema: { type: 'object', additionalProperties: false, required: ['topic', 'hint', 'tone'], properties: { topic: { type: 'string' }, hint: { type: 'string' }, tone: { type: 'string', enum: ['brag', 'roast', 'spin', 'bit'] } } },--};--const TOPIC_SYSTEM = `You feed topics to a live-streamer who is trash-talking on stream in real time, one topic at a time. Each topic must be answerable in 30–60 seconds of talking, funny, PG-13, and about streaming life, their chat, the Arena ladder, their category, or the day's Main Event — never appearance, identity, health or money. Vary tone and angle from the topics they already cleared. Output only the JSON.`;----async function nextTopic(session, idx) {-- const cleared = db.all('SELECT topic FROM arena_talk_session_topics WHERE session_id = ? ORDER BY idx ASC', [session.id]).map(r => r.topic);-- let t = null;-- if (aiOn()) {-- try {-- const roster = arena().loadRoster();-- const me = roster.byId[session.user_id];-- const persona = parseJson(db.get('SELECT persona_json FROM arena_profiles WHERE user_id = ?', [session.user_id])?.persona_json);-- const rivals = roster.order.filter(id => id !== session.user_id).slice(0, 4).map(id => (parseJson(db.get('SELECT persona_json FROM arena_profiles WHERE user_id = ?', [id])?.persona_json) || {}).fighter_name || roster.byId[id].user.display_name);-- const r = await llm.complete({-- role: 'summary', kind: 'arena_session_topic', source: 'arena', ownerUserId: session.user_id,-- system: TOPIC_SYSTEM,-- user: JSON.stringify({ fighter: persona?.fighter_name || me?.user?.display_name, category: me?.raw?.category, rank: roster.order.indexOf(session.user_id) + 1, rivals, already_cleared: cleared, topic_number: idx + 1 }),-- json: TOPIC_SCHEMA, maxTokens: 200, temperature: 1.05, timeoutMs: 20000,-- });-- if (r && r.json && r.json.topic) t = r.json;-- } catch (e) { console.warn('[Arena] session topic:', e.message); }-- }-- if (!t) {-- const pool = talk().FALLBACK_TOPICS || [];-- const seed = (session.id * 7 + idx * 13) % Math.max(1, pool.length);-- t = pool[seed] || { topic: 'Talk your talk. Anything goes, PG-13.', hint: 'Chat is listening.', tone: 'bit' };-- }-- db.run('INSERT INTO arena_talk_session_topics (session_id, idx, topic, hint, tone) VALUES (?, ?, ?, ?, ?)', [session.id, idx, String(t.topic).slice(0, 200), String(t.hint || '').slice(0, 140), t.tone || 'bit']);-- return activeTopic(session.id);--}----// ── Lifecycle ────────────────────────────────────────────────----async function startSession(userId) {-- ensureTables();-- const existing = liveSessionFor(userId);-- if (existing) return existing;-- const roster = arena().loadRoster();-- if (!roster.byId[userId]) throw new Error('Only fighters on the roster can run a session — stream once and come back');-- const mic = talk().micAvailable(userId);-- if (!mic.available) throw new Error(mic.reason === 'not_live' ? 'Go live first — sessions run off your live transcription' : 'You are live, but no transcription has come through yet. Talk for a few seconds and try again.');-- const stream = db.get('SELECT id, started_at FROM streams WHERE id = ?', [mic.stream_id]);-- const startedMs = stream?.started_at ? Date.parse(String(stream.started_at).replace(' ', 'T') + 'Z') : Date.now();-- const offset = Math.max(0, (Date.now() - startedMs) / 1000);-- db.run(`INSERT INTO arena_talk_sessions (user_id, stream_id, last_offset_sec, offset_start_sec, last_speech_at, about_json) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, '[]')`, [userId, mic.stream_id, offset, offset]);-- const session = liveSessionFor(userId);-- await nextTopic(session, 0);-- console.log(`[Arena] trash-talk session ${session.id} started for user ${userId} on stream ${mic.stream_id}`);-- return session;--}----function endSession(session, reason = 'stopped') {-- db.run(`UPDATE arena_talk_sessions SET status = 'ended', ended_at = CURRENT_TIMESTAMP, end_reason = ? WHERE id = ? AND status = 'live'`, [reason, session.id]);-- db.run(`UPDATE arena_talk_session_topics SET status = 'skipped' WHERE session_id = ? AND status = 'active'`, [session.id]);-- console.log(`[Arena] trash-talk session ${session.id} ended (${reason})`);--}----function stopSession(userId) {-- const s = liveSessionFor(userId);-- if (!s) throw new Error('No live session');-- endSession(s, 'stopped');-- return db.get('SELECT * FROM arena_talk_sessions WHERE id = ?', [s.id]);--}----async function skipTopic(userId) {-- const s = liveSessionFor(userId);-- if (!s) throw new Error('No live session');-- const t = activeTopic(s.id);-- if (t) db.run(`UPDATE arena_talk_session_topics SET status = 'skipped' WHERE id = ?`, [t.id]);-- return nextTopic(s, (t ? t.idx : -1) + 1);--}----// ── Judge (chunk mode) ───────────────────────────────────────----const CHUNK_SCHEMA = {-- name: 'arena_session_chunk',-- schema: {-- type: 'object', additionalProperties: false,-- required: ['is_trash_talk', 'spice', 'wit', 'on_topic', 'delivery', 'progress_gain', 'best_line', 'about', 'flagged'],-- properties: {-- is_trash_talk: { type: 'boolean', description: 'true if a meaningful part of this chunk is trash talk / bragging / roasting aimed at the topic' },-- spice: { type: 'integer', minimum: 0, maximum: 10 },-- wit: { type: 'integer', minimum: 0, maximum: 10 },-- on_topic: { type: 'integer', minimum: 0, maximum: 10 },-- delivery: { type: 'integer', minimum: 0, maximum: 10 },-- progress_gain: { type: 'integer', minimum: 0, maximum: 60, description: 'how much of the topic this chunk covered: 0 = not about it, 60 = nailed it completely' },-- best_line: { type: 'string', description: 'the single best line, copied VERBATIM from the chunk (empty if none)' },-- about: { type: 'string', description: '≤ 10 words: what they were actually talking about in this chunk' },-- flagged: { type: 'boolean', description: 'slurs / hate / threats / attacks on appearance, identity, health or money' },-- },-- },--};--const CHUNK_SYSTEM = `You judge a LIVE trash-talk session. You get the current topic and the newest ~60 seconds of a streamer's speech-to-text. Decide whether they were trash-talking (bragging, roasting, hyping themselves, calling people out — in good fun) ON that topic, score it, say how much of the topic they covered, pick the best verbatim line, and summarise what they talked about in ≤ 10 words. Ordinary gameplay chatter, silence or off-topic talk → is_trash_talk false, low progress. Forgive transcription noise. Flag cruelty (appearance, identity, health, money, threats, hate). Output only the JSON.`;----function heuristicChunk(text, topic) {-- const h = talk()._heuristicJudge(text, topic.topic);-- const trash = h.spice >= 5 || /\b(chat|champ|bet|weak|scared|better|best|nobody|clown|cook|ratio)\b/i.test(text);-- const lines = String(text).split(/(?<=[.!?])\s+/).filter(l => l.length > 12);-- const best = lines.sort((a, b) => (b.match(/[!?]/g) || []).length - (a.match(/[!?]/g) || []).length)[0] || '';-- return { is_trash_talk: trash, spice: h.spice, wit: h.wit, on_topic: h.on_topic, delivery: h.delivery, progress_gain: trash ? Math.min(60, 10 + h.on_topic * 4) : Math.min(15, h.on_topic * 2), best_line: best.slice(0, 200), about: text.split(/\s+/).slice(0, 8).join(' '), flagged: false, _fallback: true };--}----async function judgeChunk(session, topic, text) {-- if (arena()._isBannedText(text)) return { is_trash_talk: false, spice: 0, wit: 0, on_topic: 0, delivery: 0, progress_gain: 0, best_line: '', about: 'crossed the line — voided', flagged: true };-- let s = null;-- if (aiOn()) {-- try {-- const r = await llm.complete({-- role: 'chat', kind: 'arena_session_judge', source: 'arena', ownerUserId: session.user_id,-- system: CHUNK_SYSTEM,-- user: JSON.stringify({ topic: topic.topic, hint: topic.hint, tone: topic.tone, progress_so_far: Math.round(topic.progress), speech: text }),-- json: CHUNK_SCHEMA, maxTokens: 260, temperature: 0.5, timeoutMs: 25000,-- });-- if (r && r.json && typeof r.json.spice === 'number') s = r.json;-- } catch (e) { console.warn('[Arena] session judge:', e.message); }-- }-- if (!s) s = heuristicChunk(text, topic);-- const c = (v, max = 10) => Math.max(0, Math.min(max, Math.round(Number(v) || 0)));-- return { is_trash_talk: !!s.is_trash_talk && !s.flagged, spice: c(s.spice), wit: c(s.wit), on_topic: c(s.on_topic), delivery: c(s.delivery), progress_gain: s.flagged ? 0 : c(s.progress_gain, 60), best_line: String(s.best_line || '').slice(0, 220), about: String(s.about || '').slice(0, 80), flagged: !!s.flagged, fallback: !!s._fallback };--}----// ── Tick ─────────────────────────────────────────────────────----async function clearTopic(session, topic) {-- const judged = parseJson(topic.judged_json, []);-- const hits = judged.filter(j => j.is_trash_talk);-- const avg = (k) => hits.length ? hits.reduce((a, j) => a + j[k], 0) / hits.length : 0;-- const scores = { spice: Math.round(avg('spice')), wit: Math.round(avg('wit')), on_topic: Math.round(avg('on_topic')), delivery: Math.round(avg('delivery')), verdict: `Cleared live on stream — ${hits.length} chunk${hits.length === 1 ? '' : 's'} of trash talk`, note: `Best line: “${topic.best_line || '—'}”`, flagged: false };-- const text = (hits.map(j => j.best_line).filter(Boolean).join(' … ') || topic.best_line || '').slice(0, 400) || 'Cleared on the live mic.';-- let entryId = null;-- try {-- const t = talk();-- const topicRow = await t.getTopic({ generate: false });-- // Session clears live outside the 6-hour slot topic, so they are stored against their-- // own topic text (in the note) but under the current slot id for the board.-- const total = t._totalFor(scores, 0);-- db.run(`INSERT INTO arena_talk (topic_id, user_id, stream_id, source, text, vod_id, start_sec, scores_json, total, flagged, verdict, note, stamp, judge_model, judged_at)-- VALUES (?, ?, ?, 'session', ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, CURRENT_TIMESTAMP)-- ON CONFLICT(topic_id, user_id) DO UPDATE SET text = CASE WHEN excluded.total > arena_talk.total THEN excluded.text ELSE arena_talk.text END,-- scores_json = CASE WHEN excluded.total > arena_talk.total THEN excluded.scores_json ELSE arena_talk.scores_json END,-- total = MAX(arena_talk.total, excluded.total), stamp = CASE WHEN excluded.total > arena_talk.total THEN excluded.stamp ELSE arena_talk.stamp END,-- verdict = excluded.verdict, note = excluded.note, source = 'session', vod_id = COALESCE(excluded.vod_id, arena_talk.vod_id), start_sec = COALESCE(excluded.start_sec, arena_talk.start_sec)`,-- [topicRow.id, session.user_id, session.stream_id, text, topic.best_vod_id, topic.best_line_sec, JSON.stringify(scores), total, scores.verdict, `${topic.topic} — ${scores.note}`, t._stampFor(total), hits[0]?.fallback ? null : 'session']);-- entryId = db.get('SELECT id FROM arena_talk WHERE topic_id = ? AND user_id = ?', [topicRow.id, session.user_id])?.id || null;-- } catch (e) { console.warn('[Arena] session clear → entry:', e.message); }-- db.run(`UPDATE arena_talk_session_topics SET status = 'cleared', progress = 100, cleared_at = CURRENT_TIMESTAMP, entry_id = ? WHERE id = ?`, [entryId, topic.id]);-- db.run(`UPDATE arena_talk_sessions SET topics_cleared = topics_cleared + 1 WHERE id = ?`, [session.id]);-- console.log(`[Arena] session ${session.id}: topic ${topic.idx + 1} cleared (“${topic.topic.slice(0, 50)}…”)`);-- try { arena().loadRoster(true); } catch { /* */ }-- return nextTopic(session, topic.idx + 1);--}----async function tickSession(session) {-- const stream = db.get('SELECT id, is_live, started_at FROM streams WHERE id = ?', [session.stream_id]);-- if (!stream || !stream.is_live) return endSession(session, 'stream_ended');-- if (Date.now() - Date.parse(session.started_at + 'Z') > MAX_SESSION_HOURS * 3600 * 1000) return endSession(session, 'max_duration');-- const lastSpeech = session.last_speech_at ? Date.parse(session.last_speech_at + 'Z') : Date.parse(session.started_at + 'Z');-- if (Date.now() - lastSpeech > IDLE_END_MIN * 60 * 1000) return endSession(session, 'idle');-- if (session.judge_calls >= JUDGE_MAX_CALLS) return endSession(session, 'judge_cap');---- let topic = activeTopic(session.id);-- if (!topic) topic = await nextTopic(session, 0);---- const rows = db.all(`SELECT text, start_sec, end_sec, vod_id FROM stream_timeline_events WHERE stream_id = ? AND kind = 'speech' AND start_sec > ? ORDER BY start_sec ASC LIMIT 120`, [session.stream_id, session.last_offset_sec]);-- if (rows.length) {-- const pending = parseJson(topic.pending_json, []);-- for (const r of rows) pending.push({ t: r.text, s: Math.floor(r.start_sec), v: r.vod_id || null });-- const newWords = rows.reduce((n, r) => n + words(r.text), 0);-- db.run(`UPDATE arena_talk_sessions SET last_offset_sec = ?, lines_seen = lines_seen + ?, words_seen = words_seen + ?, last_speech_at = CURRENT_TIMESTAMP WHERE id = ?`, [rows[rows.length - 1].start_sec, rows.length, newWords, session.id]);-- db.run('UPDATE arena_talk_session_topics SET pending_json = ? WHERE id = ?', [JSON.stringify(pending.slice(-60)), topic.id]);-- topic.pending_json = JSON.stringify(pending.slice(-60));-- }---- const pending = parseJson(topic.pending_json, []);-- const pendingWords = pending.reduce((n, l) => n + words(l.t), 0);-- const sinceJudge = session.last_judge_at ? Date.now() - Date.parse(session.last_judge_at + 'Z') : Infinity;-- if (pendingWords < JUDGE_MIN_WORDS || sinceJudge < JUDGE_MIN_INTERVAL_MS) return;---- const text = pending.map(l => l.t.replace(/^\s*(?:>>|--?)\s*/, '').trim()).filter(Boolean).join(' ').replace(/\s+/g, ' ').slice(-CHUNK_MAX_CHARS);-- const j = await judgeChunk(session, topic, text);-- const quality = (j.spice + j.wit + j.on_topic + j.delivery) / 4;-- const xpGain = j.flagged ? 0 : Math.round(quality * (j.is_trash_talk ? 1 : 0.15));-- const judged = parseJson(topic.judged_json, []);-- judged.push({ at: new Date().toISOString(), ...j, words: pendingWords, quality: Number(quality.toFixed(1)), xp: xpGain });-- // Locate the best line in the pending buffer for a VOD link.-- let bestSec = topic.best_line_sec, bestVod = topic.best_vod_id, bestLine = topic.best_line, bestScore = topic.best_line_score || 0;-- if (j.is_trash_talk && j.best_line && quality > bestScore) {-- const needle = j.best_line.toLowerCase().slice(0, 30);-- const hit = pending.find(l => l.t.toLowerCase().includes(needle)) || pending[0];-- bestLine = j.best_line; bestScore = quality; bestSec = hit ? Math.max(0, hit.s - 2) : null; bestVod = hit ? hit.v : null;-- }-- const about = parseJson(session.about_json, []);-- if (j.about && !j.flagged) { about.push({ text: j.about, topic_idx: topic.idx, hit: j.is_trash_talk, at: new Date().toISOString() }); }-- const newProgress = Math.min(100, (topic.progress || 0) + (j.is_trash_talk ? j.progress_gain : Math.min(5, j.progress_gain)));-- const newScore = (topic.score || 0) + (j.is_trash_talk ? quality : 0);-- const newXp = (session.xp || 0) + xpGain;-- const newLevel = levelFor(newXp);-- if (newLevel > (session.level || 1)) console.log(`[Arena] session ${session.id}: level ${newLevel}`);-- db.run(`UPDATE arena_talk_session_topics SET progress = ?, score = ?, chunks = chunks + 1, judged_json = ?, pending_json = '[]', best_line = ?, best_line_score = ?, best_line_sec = ?, best_vod_id = ? WHERE id = ?`,-- [newProgress, newScore, JSON.stringify(judged.slice(-40)), bestLine, bestScore, bestSec, bestVod, topic.id]);-- db.run(`UPDATE arena_talk_sessions SET xp = ?, level = ?, judge_calls = judge_calls + 1, last_judge_at = CURRENT_TIMESTAMP, about_json = ? WHERE id = ?`, [newXp, newLevel, JSON.stringify(about.slice(-60)), session.id]);-- if (newProgress >= 100) {-- const fresh = db.get('SELECT * FROM arena_talk_session_topics WHERE id = ?', [topic.id]);-- await clearTopic({ ...session, xp: newXp }, fresh);-- }--}----let _timer = null, _busy = false;--async function tick() {-- if (_busy) return;-- _busy = true;-- try {-- for (const s of liveSessions()) {-- try { await tickSession(s); } catch (e) { console.warn(`[Arena] session ${s.id} tick:`, e.message); }-- }-- } finally { _busy = false; }--}--function start() {-- if (_timer) return;-- ensureTables();-- _timer = setInterval(() => tick().catch(() => {}), TICK_MS);-- if (_timer.unref) _timer.unref();--}--function stop() { if (_timer) { clearInterval(_timer); _timer = null; } }----// ── Hype (chat / button) ─────────────────────────────────────----function hypeSession(userId, voterKey) {-- const s = liveSessionFor(userId);-- if (!s) return null;-- const t = activeTopic(s.id);-- if (!t) return null;-- if (voterKey === `user:${userId}`) throw new Error("You can't hype yourself");-- const ins = db.run('INSERT OR IGNORE INTO arena_talk_session_hype (topic_id, voter_key) VALUES (?, ?)', [t.id, voterKey]);-- const uniques = db.get('SELECT COUNT(*) AS n FROM arena_talk_session_hype WHERE topic_id = ?', [t.id])?.n || 0;-- if (ins.changes) {-- const xp = (s.xp || 0) + HYPE_XP;-- db.run('UPDATE arena_talk_sessions SET xp = ?, level = ? WHERE id = ?', [xp, levelFor(xp), s.id]);-- db.run('UPDATE arena_talk_session_topics SET progress = MIN(100, progress + ?) WHERE id = ?', [HYPE_PROGRESS, t.id]);-- }-- return { added: !!ins.changes, hypers: uniques, session_id: s.id, level: levelFor((s.xp || 0) + (ins.changes ? HYPE_XP : 0)), xp: (s.xp || 0) + (ins.changes ? HYPE_XP : 0), progress: Math.min(100, (t.progress || 0) + (ins.changes ? HYPE_PROGRESS : 0)) };--}----// ── Views ────────────────────────────────────────────────────----function formatTopic(t) {-- if (!t) return null;-- const judged = parseJson(t.judged_json, []);-- const hypers = db.get('SELECT COUNT(*) AS n FROM arena_talk_session_hype WHERE topic_id = ?', [t.id])?.n || 0;-- return {-- id: t.id, idx: t.idx, topic: t.topic, hint: t.hint, tone: t.tone, status: t.status,-- progress: Math.round(t.progress || 0), score: Number((t.score || 0).toFixed(1)), chunks: t.chunks || 0,-- best_line: t.best_line || null, best_line_sec: t.best_line_sec, best_vod_id: t.best_vod_id,-- hits: judged.filter(j => j.is_trash_talk).length, hypers,-- last_judgement: judged.length ? { at: judged[judged.length - 1].at, is_trash_talk: judged[judged.length - 1].is_trash_talk, quality: judged[judged.length - 1].quality, about: judged[judged.length - 1].about, fallback: !!judged[judged.length - 1].fallback } : null,-- entry_id: t.entry_id || null, started_at: t.started_at, cleared_at: t.cleared_at,-- };--}----function sessionView(session, { includeLines = true } = {}) {-- if (!session) return null;-- const topics = db.all('SELECT * FROM arena_talk_session_topics WHERE session_id = ? ORDER BY idx ASC', [session.id]);-- const active = topics.find(t => t.status === 'active') || null;-- const lines = includeLines ? db.all(`SELECT text, start_sec, vod_id FROM stream_timeline_events WHERE stream_id = ? AND kind = 'speech' AND start_sec >= ? ORDER BY start_sec DESC LIMIT 14`, [session.stream_id, session.offset_start_sec])-- .reverse().filter(l => !arena()._isBannedText(l.text)).map(l => ({ text: l.text, at: Math.max(0, Math.round(l.start_sec - session.offset_start_sec)), sec: Math.floor(l.start_sec), vod_id: l.vod_id })) : [];-- const about = parseJson(session.about_json, []);-- return {-- id: session.id, status: session.status, started_at: session.started_at, ended_at: session.ended_at, end_reason: session.end_reason,-- level: session.level || 1, xp: session.xp || 0, next_level_xp: levelFor(session.xp || 0) * XP_PER_LEVEL, xp_per_level: XP_PER_LEVEL,-- topics_cleared: session.topics_cleared || 0, lines_seen: session.lines_seen || 0, words_seen: session.words_seen || 0,-- active_topic: formatTopic(active),-- cleared_topics: topics.filter(t => t.status !== 'active').map(formatTopic),-- talked_about: about.slice(-20).reverse(),-- recent_lines: lines,-- stream_id: session.stream_id,-- judge_is_ai: aiOn(),-- };--}----function viewFor(usernameOrId) {-- ensureTables();-- const user = /^\d+$/.test(String(usernameOrId)) ? db.getUserById(Number(usernameOrId)) : db.getUserByUsername(String(usernameOrId));-- if (!user) return null;-- const session = liveSessionFor(user.id) || db.get(`SELECT * FROM arena_talk_sessions WHERE user_id = ? ORDER BY id DESC LIMIT 1`, [user.id]);-- const roster = arena().loadRoster();-- const f = roster.byId[user.id];-- const persona = parseJson(db.get('SELECT persona_json FROM arena_profiles WHERE user_id = ?', [user.id])?.persona_json);-- return {-- user: f ? f.user : { id: user.id, username: user.username, display_name: user.display_name || user.username, avatar_url: user.avatar_url, profile_color: user.profile_color },-- fighter_name: persona?.fighter_name || user.display_name || user.username,-- rank: f ? roster.order.indexOf(user.id) + 1 : null,-- power: f ? f.ratings.power : null,-- talk_bonus: f ? f.ratings.talk_bonus : 0,-- image_url: (() => { try { return arena().getFighterImageUrl ? arena().getFighterImageUrl(user.id) : null; } catch { return null; } })(),-- session: sessionView(session),-- };--}----function liveSessionSummaries() {-- ensureTables();-- const roster = arena().loadRoster();-- return liveSessions().map(s => {-- const f = roster.byId[s.user_id];-- const t = activeTopic(s.id);-- const persona = parseJson(db.get('SELECT persona_json FROM arena_profiles WHERE user_id = ?', [s.user_id])?.persona_json);-- return { user: f ? f.user : { id: s.user_id, username: `user${s.user_id}`, display_name: `user${s.user_id}` }, fighter_name: persona?.fighter_name || f?.user?.display_name, level: s.level, xp: s.xp, topics_cleared: s.topics_cleared, topic: t ? t.topic : null, progress: t ? Math.round(t.progress || 0) : 0, started_at: s.started_at };-- });--}----module.exports = {-- ensureTables, start, stop, tick, tickSession,-- startSession, stopSession, skipTopic, hypeSession, liveSessionFor, viewFor, sessionView, liveSessionSummaries,-- TICK_MS, XP_PER_LEVEL, JUDGE_MIN_WORDS, JUDGE_MIN_INTERVAL_MS, IDLE_END_MIN,-- _judgeChunk: judgeChunk, _heuristicChunk: heuristicChunk, _levelFor: levelFor, _activeTopic: activeTopic, _nextTopic: nextTopic,--};ddeleted file mode 100644@@@ -1,398 +0,0 @@--/**-- * OpenVibe.Live — Arena Trash Talk-- *-- * The part of the Arena streamers and viewers play themselves:-- *-- * topic → every TOPIC_SLOT_HOURS the Arena posts a topic ("tonight's beef"), AI-written-- * from what is going on (main event, top of the ladder), with a templated-- * fallback. Seeded per slot so every server/restart agrees on it.-- * entry → a streamer on the roster enters once per topic, either from the LIVE MIC-- * (we read what they said on stream from the transcription timeline between-- * "start" and "drop the mic") or by typing it.-- * judge → the AI scores Spice / Wit / On-topic / Delivery (0–10 each) with a verdict,-- * a note and a stamp; slurs / harassment are zeroed and hidden (hard filter-- * before the model, plus the model's own `flagged`). Heuristic when AI is off.-- * crowd → viewers add the fifth score: `!hype` in that streamer's chat (or the Hype-- * button) — one per person, Crowd = min(10, uniques / 3).-- * power → an entry's total (/50) becomes a Trash Talk bonus on POWER (up to-- * TALK_BONUS_MAX), decaying linearly over TALK_BONUS_DAYS.-- */--'use strict';----const crypto = require('crypto');--const db = require('../db/database');--const llm = require('../ai/llm');----const TOPIC_SLOT_HOURS = 6;--const MIC_WINDOW_SEC = 60; // how long a live-mic session may run--const MIC_GRACE_SEC = 8; // transcript lag — lines a few seconds after "drop" still count--const TEXT_MAX = 280;--const TALK_BONUS_MAX = 12;--const TALK_BONUS_DAYS = 7;--const HYPE_WINDOW_MS = 24 * 60 * 60 * 1000; // an entry can be hyped for a day--const STAMPS = [[42, 'COOKED'], [30, 'SOLID'], [18, 'MID'], [0, 'FLOP']];----const FALLBACK_TOPICS = [-- { topic: 'Explain why your chat is the smartest chat on this site. Lie if you must.', hint: 'Bring receipts, or confidence. Confidence is cheaper.', tone: 'brag' },-- { topic: 'Your stream schedule is a lifestyle. Defend it against the calendar.', hint: 'Sleep is for people with fewer viewers.', tone: 'brag' },-- { topic: 'The Main Event has two fighters in it. Tell us why neither is the real champion.', hint: 'Name names. Lovingly.', tone: 'roast' },-- { topic: 'Describe the last thing that went wrong on your stream as if it was a strategic decision.', hint: 'The audio cutting out was a bit. Obviously.', tone: 'spin' },-- { topic: 'Your signature move vs. the #1 fighter\'s. Who taps first?', hint: 'Fighting-game energy. Frame data optional.', tone: 'roast' },-- { topic: 'Sell your stream category to someone who has never heard of it, in one breath.', hint: 'You have the mic. They have their doubts.', tone: 'brag' },-- { topic: 'What does your chat say about you when you step away? Set the record straight.', hint: 'They say worse things. Get ahead of it.', tone: 'spin' },-- { topic: 'Rank the top three fighters and explain why you\'re about to move up.', hint: 'Bonus points for a plan that could not possibly work.', tone: 'roast' },-- { topic: 'Your setup cost more than their setup. Or it cost less and it\'s better. Pick one.', hint: 'Cables count. Cable management counts double.', tone: 'brag' },-- { topic: 'A new streamer just joined the site. Give them the worst possible advice with total confidence.', hint: 'Every word must sound like wisdom.', tone: 'bit' },-- { topic: 'Your entrance music just played. Narrate your walk to the ring.', hint: 'Slow-motion. Pyro. A confused sound guy.', tone: 'bit' },-- { topic: 'Defend the most controversial opinion you have ever said on stream.', hint: 'Keep it PG-13, keep it petty.', tone: 'spin' },--];----// ── Tables ───────────────────────────────────────────────────----let _ready = false;--function ensureTables() {-- if (_ready) return;-- db.run(`CREATE TABLE IF NOT EXISTS arena_talk_topics (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- slot TEXT NOT NULL UNIQUE,-- topic TEXT NOT NULL,-- hint TEXT,-- tone TEXT,-- source TEXT,-- generated_at DATETIME DEFAULT CURRENT_TIMESTAMP-- )`);-- db.run(`CREATE TABLE IF NOT EXISTS arena_talk (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- topic_id INTEGER NOT NULL,-- user_id INTEGER NOT NULL,-- stream_id INTEGER,-- source TEXT NOT NULL,-- text TEXT NOT NULL,-- vod_id INTEGER,-- start_sec INTEGER,-- scores_json TEXT,-- total REAL DEFAULT 0,-- crowd_uniques INTEGER DEFAULT 0,-- flagged INTEGER DEFAULT 0,-- verdict TEXT,-- note TEXT,-- stamp TEXT,-- judge_model TEXT,-- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,-- judged_at DATETIME,-- UNIQUE(topic_id, user_id)-- )`);-- db.run(`CREATE TABLE IF NOT EXISTS arena_talk_hype (-- talk_id INTEGER NOT NULL,-- voter_key TEXT NOT NULL,-- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,-- UNIQUE(talk_id, voter_key)-- )`);-- db.run('CREATE INDEX IF NOT EXISTS idx_arena_talk_user ON arena_talk (user_id, created_at)');-- _ready = true;--}----function aiOn() { try { return llm.isEnabled() && llm.withinBudget(); } catch { return false; } }--function parseJson(t, f = null) { try { return t ? JSON.parse(t) : f; } catch { return f; } }--function arena() { return require('./arena-service'); }----// ── Topic ────────────────────────────────────────────────────----function currentSlot(now = Date.now()) {-- const d = new Date(now);-- const slotIndex = Math.floor(d.getUTCHours() / TOPIC_SLOT_HOURS);-- return `${d.toISOString().slice(0, 10)}/${slotIndex}`;--}--function slotEndsAt(slot) {-- const [day, idx] = slot.split('/');-- return new Date(`${day}T00:00:00Z`).getTime() + (Number(idx) + 1) * TOPIC_SLOT_HOURS * 3600 * 1000;--}----function seededIndex(seedText, n) {-- const h = parseInt(crypto.createHash('sha256').update(seedText).digest('hex').slice(0, 8), 16);-- return h % n;--}----const TOPIC_SCHEMA = {-- name: 'arena_talk_topic',-- schema: {-- type: 'object', additionalProperties: false, required: ['topic', 'hint', 'tone'],-- properties: {-- topic: { type: 'string', description: 'One-sentence prompt a streamer answers on the mic, ≤ 140 chars' },-- hint: { type: 'string', description: 'A cheeky one-line tip, ≤ 90 chars' },-- tone: { type: 'string', enum: ['brag', 'roast', 'spin', 'bit'] },-- },-- },--};--const TOPIC_SYSTEM = `You write prompts for a live-streaming site's Trash Talk contest: a streamer answers the prompt out loud on stream, an AI judge scores spice, wit, relevance and delivery, and chat hypes them. Prompts must be answerable in 30 seconds, funny, PG-13, and about streaming life, chat, the fighters on the Arena ladder, or the day's Main Event — never about appearance, identity, health or money. Output only the JSON.`;----async function getTopic({ generate = true } = {}) {-- ensureTables();-- const slot = currentSlot();-- let row = db.get('SELECT * FROM arena_talk_topics WHERE slot = ?', [slot]);-- if (row) return { ...row, ends_at: new Date(slotEndsAt(slot)).toISOString() };-- let topic = null;-- if (generate && aiOn()) {-- try {-- const roster = arena().loadRoster();-- const names = roster.order.slice(0, 5).map(id => {-- const p = parseJson(db.get('SELECT persona_json FROM arena_profiles WHERE user_id = ?', [id])?.persona_json);-- return p?.fighter_name || roster.byId[id].user.display_name;-- });-- let mainEvent = null;-- try { const me = await arena().getMainEvent({ generate: false }); if (me) mainEvent = `${me.a.persona.fighter_name} vs ${me.b.persona.fighter_name}`; } catch { /* */ }-- const r = await llm.complete({-- role: 'summary', kind: 'arena_talk_topic', source: 'arena',-- system: TOPIC_SYSTEM,-- user: JSON.stringify({ slot, top_fighters: names, main_event: mainEvent, previous_topics: db.all('SELECT topic FROM arena_talk_topics ORDER BY id DESC LIMIT 8').map(r => r.topic) }),-- json: TOPIC_SCHEMA, maxTokens: 200, temperature: 1.0, timeoutMs: 20000,-- });-- if (r && r.json && r.json.topic) topic = { ...r.json, source: 'ai' };-- } catch (e) { console.warn('[Arena] talk topic:', e.message); }-- }-- if (!topic) topic = { ...FALLBACK_TOPICS[seededIndex(slot, FALLBACK_TOPICS.length)], source: 'template' };-- try {-- db.run('INSERT OR IGNORE INTO arena_talk_topics (slot, topic, hint, tone, source) VALUES (?, ?, ?, ?, ?)', [slot, topic.topic.slice(0, 200), (topic.hint || '').slice(0, 140), topic.tone || 'brag', topic.source]);-- } catch { /* */ }-- row = db.get('SELECT * FROM arena_talk_topics WHERE slot = ?', [slot]);-- return { ...row, ends_at: new Date(slotEndsAt(slot)).toISOString() };--}----// ── Live mic sessions (in memory — a session is a minute long) ──----const sessions = new Map(); // userId → { streamId, startedAt, streamStartedMs }----function liveStreamFor(userId) {-- return db.get('SELECT id, started_at FROM streams WHERE user_id = ? AND is_live = 1 ORDER BY started_at DESC LIMIT 1', [userId]) || null;--}--function streamOffsetNow(stream) {-- const startedMs = stream?.started_at ? Date.parse(String(stream.started_at).replace(' ', 'T') + 'Z') : Date.now();-- return Math.max(0, (Date.now() - startedMs) / 1000);--}--function micAvailable(userId) {-- const live = liveStreamFor(userId);-- if (!live) return { available: false, reason: 'not_live' };-- const recent = db.get(`SELECT COUNT(*) AS n FROM stream_timeline_events WHERE stream_id = ? AND kind = 'speech' AND created_at >= datetime('now', '-15 minutes')`, [live.id])?.n || 0;-- if (!recent) return { available: false, reason: 'no_transcription', stream_id: live.id };-- return { available: true, stream_id: live.id };--}----function startMic(userId) {-- const live = liveStreamFor(userId);-- if (!live) throw new Error('You need to be live for the live mic — type your entry instead');-- const s = { streamId: live.id, startedAt: Date.now(), offsetStart: streamOffsetNow(live) };-- sessions.set(userId, s);-- return { stream_id: live.id, started_at: new Date(s.startedAt).toISOString(), window_sec: MIC_WINDOW_SEC };--}----function micFeed(userId) {-- const s = sessions.get(userId);-- if (!s) return { active: false, lines: [] };-- const elapsed = (Date.now() - s.startedAt) / 1000;-- const rows = db.all(`SELECT text, start_sec FROM stream_timeline_events WHERE stream_id = ? AND kind = 'speech' AND start_sec >= ? ORDER BY start_sec ASC LIMIT 60`, [s.streamId, Math.floor(s.offsetStart) - 2]);-- return {-- active: true,-- elapsed_sec: Math.round(elapsed),-- remaining_sec: Math.max(0, Math.round(MIC_WINDOW_SEC - elapsed)),-- lines: rows.map(r => ({ text: r.text, at: Math.max(0, Math.round(r.start_sec - s.offsetStart)) })),-- };--}----function collectMic(userId) {-- const s = sessions.get(userId);-- if (!s) throw new Error('No live-mic session — press "I\'m about to cook" first');-- sessions.delete(userId);-- const elapsed = Math.min(MIC_WINDOW_SEC, (Date.now() - s.startedAt) / 1000) + MIC_GRACE_SEC;-- const rows = db.all(`SELECT text, start_sec, vod_id FROM stream_timeline_events WHERE stream_id = ? AND kind = 'speech' AND start_sec >= ? AND start_sec <= ? ORDER BY start_sec ASC LIMIT 80`,-- [s.streamId, Math.floor(s.offsetStart) - 2, Math.ceil(s.offsetStart + elapsed)]);-- const text = rows.map(r => String(r.text || '').replace(/^\s*(?:>>|--?)\s*/, '').trim()).filter(Boolean).join(' ').replace(/\s+/g, ' ').trim();-- return { text: text.slice(0, 1200), stream_id: s.streamId, vod_id: rows[0]?.vod_id || null, start_sec: rows.length ? Math.max(0, Math.floor(rows[0].start_sec) - 2) : Math.floor(s.offsetStart), lines: rows.length };--}----// ── Judge ────────────────────────────────────────────────────----const JUDGE_SCHEMA = {-- name: 'arena_talk_judgement',-- schema: {-- type: 'object', additionalProperties: false,-- required: ['spice', 'wit', 'on_topic', 'delivery', 'verdict', 'note', 'flagged'],-- properties: {-- spice: { type: 'integer', minimum: 0, maximum: 10, description: 'attitude and bite' },-- wit: { type: 'integer', minimum: 0, maximum: 10, description: 'cleverness, jokes, wordplay' },-- on_topic: { type: 'integer', minimum: 0, maximum: 10, description: 'actually answers the topic' },-- delivery: { type: 'integer', minimum: 0, maximum: 10, description: 'rhythm, quotability, confidence (read as a transcript — forgive filler words)' },-- verdict: { type: 'string', description: 'One-line ring-announcer verdict, ≤ 120 chars' },-- note: { type: 'string', description: "Judge's note: what landed and what to do better, ≤ 160 chars" },-- flagged: { type: 'boolean', description: 'true if it attacks appearance/identity/health/money, threatens, or is hateful — then the entry is void' },-- },-- },--};--const JUDGE_SYSTEM = `You judge a wholesome trash-talk contest between live streamers. Score the entry against the topic. Reward attitude, jokes, specificity about streaming life and the Arena, and lines chat would clip. Punish generic filler and anything cruel. Entries arrive as speech-to-text: forgive "um", repeats and odd punctuation — judge the content. Flag (and it becomes void) anything targeting appearance, body, disability, ethnicity, gender, religion, health or money, or containing threats or hate. Output only the JSON.`;----function heuristicJudge(text, topic) {-- const t = String(text || '');-- const words = t.split(/\s+/).filter(Boolean).length;-- const excl = (t.match(/!/g) || []).length;-- // Stem-ish match so "smartest" in the topic counts "smarter" in the entry.-- const stems = [...new Set((String(topic || '').toLowerCase().match(/[a-z]{4,}/g) || []).map(w => w.slice(0, 5)))].filter(w => !['your', 'this', 'that', 'with', 'what', 'from', 'they', 'them', 'have', 'must', 'about', 'which', 'their'].includes(w));-- const hits = stems.filter(w => t.toLowerCase().includes(w)).length;-- const spice = Math.min(10, 3 + excl + (/\b(you|your|chat|arena|champ|weak|scared|bet)\b/i.test(t) ? 2 : 0));-- const wit = Math.min(10, Math.round(Math.min(words, 60) / 8) + (/\b(like|than|because|but)\b/i.test(t) ? 2 : 0));-- const on_topic = Math.min(10, 2 + hits * 2);-- const delivery = Math.min(10, words < 8 ? 2 : words > 120 ? 5 : 7);-- return { spice, wit, on_topic, delivery, verdict: 'The judge is out — scored on instinct.', note: 'AI judging is off; this is a rough heuristic score.', flagged: false, _fallback: true };--}----function stampFor(total) { return STAMPS.find(([min]) => total >= min)[1]; }--function crowdScore(uniques) { return Math.min(10, Math.round((Number(uniques) || 0) / 3 * 10) / 10); }----async function judge(text, topic, context) {-- const banned = arena()._isBannedText(text);-- let s = null;-- if (!banned && aiOn()) {-- try {-- const r = await llm.complete({-- role: 'chat', kind: 'arena_talk_judge', source: 'arena', ownerUserId: context.userId,-- system: JUDGE_SYSTEM,-- user: JSON.stringify({ topic: topic.topic, tone: topic.tone, fighter: context.fighter, entry: text, source: context.source }),-- json: JUDGE_SCHEMA, maxTokens: 300, temperature: 0.6, timeoutMs: 25000,-- });-- if (r && r.json && typeof r.json.spice === 'number') s = { ...r.json, _model: r.model };-- } catch (e) { console.warn('[Arena] talk judge:', e.message); }-- }-- if (!s) s = heuristicJudge(text, topic.topic);-- if (banned) s = { ...s, spice: 0, wit: 0, on_topic: 0, delivery: 0, flagged: true, verdict: 'Void — that crossed the line.', note: 'Slurs, hate or threats void an entry. Keep it petty, not cruel.' };-- const clamp = (v) => Math.max(0, Math.min(10, Math.round(Number(v) || 0)));-- return { spice: clamp(s.spice), wit: clamp(s.wit), on_topic: clamp(s.on_topic), delivery: clamp(s.delivery), verdict: String(s.verdict || '').slice(0, 200), note: String(s.note || '').slice(0, 240), flagged: !!s.flagged, model: s._model || null, fallback: !!s._fallback };--}----// ── Entries ──────────────────────────────────────────────────----function totalFor(scores, crowdUniques) {-- if (!scores || scores.flagged) return 0;-- return Number((scores.spice + scores.wit + scores.on_topic + scores.delivery + crowdScore(crowdUniques)).toFixed(1));--}----async function submit(userId, { mode, text }) {-- ensureTables();-- const roster = arena().loadRoster();-- const entry = roster.byId[userId];-- if (!entry) throw new Error('Only fighters on the roster can enter — stream once and come back');-- const topic = await getTopic();-- if (db.get('SELECT id FROM arena_talk WHERE topic_id = ? AND user_id = ?', [topic.id, userId])) throw new Error('You already entered this topic — wait for the next one');-- let source = 'text', streamId = null, vodId = null, startSec = null, body = String(text || '').trim();-- if (mode === 'mic') {-- const c = collectMic(userId);-- if (!c.text || c.lines < 1) throw new Error("The mic didn't catch anything — the transcription needs a few seconds after you speak. Try again, or type it.");-- source = 'mic'; body = c.text; streamId = c.stream_id; vodId = c.vod_id; startSec = c.start_sec;-- } else {-- if (body.length < 12) throw new Error('Say a little more than that');-- body = body.slice(0, TEXT_MAX);-- const live = liveStreamFor(userId); if (live) streamId = live.id;-- }-- const fighter = parseJson(db.get('SELECT persona_json FROM arena_profiles WHERE user_id = ?', [userId])?.persona_json)?.fighter_name || entry.user.display_name;-- const scores = await judge(body, topic, { userId, fighter, source });-- const total = totalFor(scores, 0);-- db.run(`INSERT INTO arena_talk (topic_id, user_id, stream_id, source, text, vod_id, start_sec, scores_json, total, flagged, verdict, note, stamp, judge_model, judged_at)-- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)`,-- [topic.id, userId, streamId, source, body, vodId, startSec, JSON.stringify(scores), total, scores.flagged ? 1 : 0, scores.verdict, scores.note, stampFor(total), scores.model]);-- try { arena().loadRoster(true); } catch { /* */ }-- const row = db.get('SELECT * FROM arena_talk WHERE topic_id = ? AND user_id = ?', [topic.id, userId]);-- console.log(`[Arena] trash talk by user ${userId} (${source}): ${total}/50 ${stampFor(total)}${scores.flagged ? ' FLAGGED' : ''}`);-- return formatEntry(row, roster, { own: true });--}----function hype(talkId, voterKey) {-- ensureTables();-- const row = db.get('SELECT * FROM arena_talk WHERE id = ?', [talkId]);-- if (!row) throw new Error('No such entry');-- if (row.flagged) throw new Error('That entry is void');-- if (Date.now() - Date.parse(row.created_at + 'Z') > HYPE_WINDOW_MS) throw new Error('Hype window closed');-- if (voterKey === `user:${row.user_id}`) throw new Error("You can't hype yourself");-- const ins = db.run('INSERT OR IGNORE INTO arena_talk_hype (talk_id, voter_key) VALUES (?, ?)', [talkId, voterKey]);-- const uniques = db.get('SELECT COUNT(*) AS n FROM arena_talk_hype WHERE talk_id = ?', [talkId])?.n || 0;-- const scores = parseJson(row.scores_json);-- const total = totalFor(scores, uniques);-- db.run('UPDATE arena_talk SET crowd_uniques = ?, total = ?, stamp = ? WHERE id = ?', [uniques, total, stampFor(total), talkId]);-- if (ins.changes) { try { arena().loadRoster(true); } catch { /* */ } }-- return { added: !!ins.changes, crowd_uniques: uniques, crowd: crowdScore(uniques), total, stamp: stampFor(total) };--}----/** Latest hypeable entry by a streamer (for `!hype` in their chat). */--function latestEntryFor(userId) {-- ensureTables();-- return db.get(`SELECT * FROM arena_talk WHERE user_id = ? AND flagged = 0 AND created_at >= datetime('now', '-1 day') ORDER BY id DESC LIMIT 1`, [userId]) || null;--}----function formatEntry(row, roster, { own = false } = {}) {-- const f = roster.byId[row.user_id];-- const persona = parseJson(db.get('SELECT persona_json FROM arena_profiles WHERE user_id = ?', [row.user_id])?.persona_json);-- const scores = parseJson(row.scores_json) || {};-- const flagged = !!row.flagged;-- return {-- id: row.id,-- user: f ? f.user : { id: row.user_id, username: `user${row.user_id}`, display_name: `user${row.user_id}` },-- fighter_name: persona?.fighter_name || f?.user?.display_name || `user${row.user_id}`,-- rank: f ? roster.order.indexOf(row.user_id) + 1 : null,-- source: row.source,-- text: flagged && !own ? null : row.text,-- vod_id: row.vod_id, start_sec: row.start_sec,-- scores: flagged ? null : { spice: scores.spice, wit: scores.wit, on_topic: scores.on_topic, delivery: scores.delivery, crowd: crowdScore(row.crowd_uniques) },-- crowd_uniques: row.crowd_uniques || 0,-- total: row.total || 0,-- stamp: row.stamp,-- verdict: row.verdict, note: own || !flagged ? row.note : null,-- flagged,-- judged_by_ai: !!row.judge_model,-- created_at: row.created_at,-- hype_open: Date.now() - Date.parse(row.created_at + 'Z') <= HYPE_WINDOW_MS,-- };--}----/** POWER bonus per user from entries in the last TALK_BONUS_DAYS, decaying linearly. */--function talkBonuses() {-- ensureTables();-- const rows = db.all(`SELECT user_id, total, created_at FROM arena_talk WHERE flagged = 0 AND created_at >= datetime('now', ?)`, [`-${TALK_BONUS_DAYS} days`]);-- const out = {};-- for (const r of rows) {-- const ageDays = (Date.now() - Date.parse(r.created_at + 'Z')) / 86400000;-- const decay = Math.max(0, 1 - ageDays / TALK_BONUS_DAYS);-- out[r.user_id] = (out[r.user_id] || 0) + (r.total / 50) * TALK_BONUS_MAX * decay;-- }-- for (const id of Object.keys(out)) out[id] = Math.min(TALK_BONUS_MAX, Math.round(out[id]));-- return out;--}----async function board({ userId = null, generate = true } = {}) {-- ensureTables();-- const roster = arena().loadRoster();-- const topic = await getTopic({ generate });-- const entries = db.all('SELECT * FROM arena_talk WHERE topic_id = ? ORDER BY total DESC, id ASC', [topic.id]).map(r => formatEntry(r, roster, { own: r.user_id === userId }));-- const hall = db.all(`SELECT t.*, tp.topic AS topic_text FROM arena_talk t JOIN arena_talk_topics tp ON tp.id = t.topic_id WHERE t.flagged = 0 AND t.created_at >= datetime('now', '-30 days') ORDER BY t.total DESC, t.id ASC LIMIT 10`)-- .map(r => ({ ...formatEntry(r, roster), topic: r.topic_text }));-- const my = userId ? entries.find(e => e.user.id === userId) || null : null;-- const onRoster = !!(userId && roster.byId[userId]);-- return {-- topic, entries, hall_of_trash: hall,-- my_entry: my,-- can_enter: onRoster && !my,-- on_roster: onRoster,-- mic: userId ? micAvailable(userId) : { available: false, reason: 'anon' },-- mic_session: userId && sessions.has(userId) ? micFeed(userId) : null,-- rules: { text_max: TEXT_MAX, mic_window_sec: MIC_WINDOW_SEC, bonus_max: TALK_BONUS_MAX, bonus_days: TALK_BONUS_DAYS, topic_slot_hours: TOPIC_SLOT_HOURS, crowd_per_point: 3 },-- ai: aiOn(),-- };--}----function entriesFor(userId, limit = 6) {-- ensureTables();-- const roster = arena().loadRoster();-- return db.all(`SELECT t.*, tp.topic AS topic_text FROM arena_talk t JOIN arena_talk_topics tp ON tp.id = t.topic_id WHERE t.user_id = ? ORDER BY t.id DESC LIMIT ?`, [userId, limit])-- .map(r => ({ ...formatEntry(r, roster, { own: true }), topic: r.topic_text }));--}----module.exports = {-- ensureTables, getTopic, currentSlot, startMic, micFeed, micAvailable, submit, hype, latestEntryFor, board, entriesFor, talkBonuses,-- TALK_BONUS_MAX, TALK_BONUS_DAYS, TEXT_MAX, MIC_WINDOW_SEC, FALLBACK_TOPICS,-- _judge: judge, _heuristicJudge: heuristicJudge, _totalFor: totalFor, _crowdScore: crowdScore, _stampFor: stampFor, _sessions: sessions,--};nnew file mode 100644@@@ -0,0 +1,274 @@++'use strict';++++// Arena v2 — beefs + the board + the listener + chat commands, end-to-end on a temp DB with no AI++// configured (template headlines, fallback angles, heuristic judges — no LLM calls).++++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++++const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-arena2-'));++process.env.DB_PATH = path.join(tmp, 'live.db');++process.env.ARENA_IMAGE_PATH = path.join(tmp, 'arena');++++const db = require('../server/db/database');++db.initDb();++const arena = require('../server/arena/arena-service');++const board = require('../server/arena/board');++const beef = require('../server/arena/beef');++const listener = require('../server/arena/listener');++const chat = require('../server/arena/arena-chat');++++const mk = (username) => Number(db.createUser({ username, email: `${username}@x`, password_hash: 'x', display_name: username[0].toUpperCase() + username.slice(1), stream_key: username.padEnd(32, '0') }).lastInsertRowid);++const u1 = mk('nova'), u2 = mk('grizzly_bear'), u3 = mk('pixelqueen'), viewer = mk('viewer');++for (const uid of [u1, u2, u3, viewer]) 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(u2, 60, 5, 1); stream(u3, 20, 2, 3);++const roster = arena.loadRoster(true);++assert.deepStrictEqual(roster.order, [u1, u2, u3], 'roster by power');++board.ensureTables(); beef.ensureTables();++++(async () => {++ // ── Board: topics from chat, fallback angles, one active topic per streamer ──++ const t1 = board.createTopic({ text: 'Streamers who read donations in a baby voice', createdBy: 'chat', creatorUserId: viewer, creatorName: 'Viewer' });++ assert.strictEqual(t1.kind, 'topic');++ assert.throws(() => board.createTopic({ text: 'streamers who read donations in a baby voice', createdBy: 'chat' }), /already on the board/);++ assert.throws(() => board.createTopic({ text: 'ok', createdBy: 'chat' }), /short|characters|topic/i);++ const angles = await board.ensureAngles(t1);++ assert.strictEqual(angles.length, 3, 'fallback angles without AI');++ await assert.rejects(board.joinTopic(t1.id, viewer), /roster/, 'viewers cannot talk on a topic');++ const j = await board.joinTopic(t1.id, u1);++ assert.strictEqual(j.topic.id, t1.id);++ assert.strictEqual(board.activeTopicFor(u1).id, t1.id);++ const t2 = board.createTopic({ text: 'Whose chat is the dumbest chat on the site', createdBy: 'streamer', creatorUserId: u2, creatorName: 'Grizzly' });++ await board.joinTopic(t2.id, u1);++ assert.strictEqual(board.activeTopicFor(u1).id, t2.id, 'joining another topic switches the active one');++ await board.joinTopic(t1.id, u1);++++ // Judged chunks: progress → angle cleared (+25) → all angles → conquered (+60).++ const fresh = () => db.get('SELECT * FROM arena_topics WHERE id = ?', [t1.id]);++ let r = board.applyTopicJudgement(u1, fresh(), { angle_idx: 0, quality: 6, progress_gain: 60, best_line: 'Your baby voice sounds like a fax machine', about: 'baby voice' }, { vod_id: null, sec: 10 });++ assert.deepStrictEqual([r.applied, r.progress, r.cleared_angle], [true, 60, false]);++ assert.strictEqual(board.applyTopicJudgement(u1, fresh(), { angle_idx: -1, quality: 0, progress_gain: 0 }).applied, false, 'off-topic chunk does nothing');++ assert.strictEqual(board.applyTopicJudgement(u1, fresh(), { angle_idx: 0, quality: 9, progress_gain: 50, flagged: true }).applied, false, 'flagged chunk (threat/minor/dox) is ignored');++ r = board.applyTopicJudgement(u1, fresh(), { angle_idx: 0, quality: 8, progress_gain: 50, best_line: 'Read my dono in your real voice, coward', about: 'dono voice' }, { vod_id: null, sec: 40 });++ assert.ok(r.cleared_angle && r.progress === 100 && !r.conquered);++ assert.ok(r.xp >= board.XP_ANGLE_CLEARED, `angle clear pays: ${r.xp}`);++ for (const idx of [1, 2]) r = board.applyTopicJudgement(u1, fresh(), { angle_idx: idx, quality: 7, progress_gain: 60, best_line: `line ${idx}`, about: 'x' }, {});++ for (const idx of [1, 2]) r = board.applyTopicJudgement(u1, fresh(), { angle_idx: idx, quality: 7, progress_gain: 60, best_line: `line ${idx}b`, about: 'x' }, {});++ assert.strictEqual(r.conquered, true, 'all angles cleared → topic conquered');++ assert.strictEqual(board.activeTopicFor(u1), null, 'conquering ends the active topic');++ const lv = board.levelView(u1);++ assert.ok(lv.xp >= board.XP_ANGLE_CLEARED * 3 + board.XP_TOPIC_CONQUERED, `xp accrued: ${lv.xp}`);++ assert.strictEqual(lv.level, board.levelFor(lv.xp));++ assert.ok(lv.level >= 3, `Trash Level climbed: ${lv.level}`);++ assert.strictEqual(lv.angles_cleared, 3); assert.strictEqual(lv.topics_conquered, 1);++ assert.strictEqual(lv.best_line.text, 'Read my dono in your real voice, coward', 'best line is the highest-quality one');++ const detail = board.topicDetail(t1.id);++ assert.strictEqual(detail.conquered, 1); assert.strictEqual(detail.members[0].cleared, 3);++ assert.ok(detail.best_lines.length >= 1 && detail.progress[u1].every(p => p.cleared));++ console.log('✅ board topics: angles, progress, clears, conquest, Trash Level');++++ // Hype + heat.++ assert.throws(() => board.hypeTopic(t2.id, u2, `user:${u2}`), /yourself/);++ await board.joinTopic(t2.id, u2);++ assert.deepStrictEqual(board.hypeTopic(t2.id, u2, 'anon:abc'), { added: true, hypers: 1 });++ assert.deepStrictEqual(board.hypeTopic(t2.id, u2, 'anon:abc'), { added: false, hypers: 1 }, 'one hype per person');++ assert.ok(board.computeHeat(t2.id) >= 5, 'talking + hype makes heat');++ const view = board.boardView();++ assert.strictEqual(view.open[0].id, t1.id, 'hottest topic first (7 judged hits in the last hour beat 1 talker + 1 hype)');++ assert.ok(view.open[0].heat > view.open[1].heat && view.open[0].hot, `heat sorted: ${view.open.map(t => t.heat)}`);++ assert.ok(view.open.find(t => t.id === t2.id).talking_now.some(f => f.user.id === u2));++ console.log('✅ hype + heat ordering');++++ // Debate: sides from chat + talk, resolves at expiry, clout for right picks.++ const d = board.createTopic({ text: 'Face cam or no face cam', createdBy: 'ai', kind: 'debate', sideA: 'Face cam', sideB: 'No cam', headline: 'THE CAM WAR' });++ assert.strictEqual(JSON.parse(d.angles_json).length, 3, 'debate carries its sides as angles');++ assert.deepStrictEqual(board.pickSide(d.id, 'anon:v1', 'a', null), { a: 1, b: 0, share_a: 100 });++ board.pickSide(d.id, 'anon:v2', 'b'); board.pickSide(d.id, 'anon:v1', 'b');++ assert.deepStrictEqual(board.sideTally(d.id), { a: 0, b: 2, share_a: 0 }, 'changing sides never double-counts');++ await board.joinTopic(d.id, u3);++ board.applyTopicJudgement(u3, db.get('SELECT * FROM arena_topics WHERE id = ?', [d.id]), { angle_idx: 0, quality: 9, progress_gain: 30, best_line: 'cams are for cowards', about: 'x' }, {});++ db.run(`UPDATE arena_topics SET expires_at = datetime('now', '-1 minute') WHERE id = ?`, [d.id]);++ const res = board.resolveExpired();++ assert.strictEqual(res.length, 1);++ assert.strictEqual(res[0].winner, 'a', 'talk (9×2=18) beats chat (2 for b)');++ assert.strictEqual(res[0].mvp_user_id, u3);++ assert.strictEqual(board.topicDetail(d.id).status, 'resolved');++ // second debate so the voter has ≥ 2 picks and shows on the clout board++ const d2 = board.createTopic({ text: 'Keyboard sounds on stream', createdBy: 'ai', kind: 'debate', sideA: 'ASMR', sideB: 'Mute it' });++ board.pickSide(d2.id, 'anon:v1', 'a'); board.pickSide(d2.id, 'anon:v2', 'a');++ db.run(`UPDATE arena_topics SET expires_at = datetime('now', '-1 minute') WHERE id = ?`, [d2.id]);++ board.resolveExpired();++ const clout = board.cloutLeaderboard();++ assert.ok(clout.length >= 1 && clout[0].picks === 2, `clout board: ${JSON.stringify(clout)}`);++ console.log('✅ debates: sides, resolution, clout');++++ // Phrase challenge: no model needed.++ const ph = board.createTopic({ text: 'Say "certified yapper" on stream', createdBy: 'ai', kind: 'phrase', phrase: 'certified yapper' });++ assert.deepStrictEqual(board.checkPhrases(u2, 'we are just a certified yapper household', { vod_id: null, sec: 1 }).map(h => h.xp), [15]);++ assert.deepStrictEqual(board.checkPhrases(u2, 'CERTIFIED YAPPER again', {}).map(h => h.xp), [5], 'repeats pay less');++ assert.deepStrictEqual(board.checkPhrases(u2, 'nothing here', {}), []);++ assert.strictEqual(board.topicDetail(ph.id).hits, 2);++ console.log('✅ phrase challenges');++++ // ── Beefs: a name on mic opens it, the target goes on the clock, silence forfeits ──++ const h1 = beef.recordHit(u1, u2, { quality: 7, best_line: 'Grizzly streams to 12 people and 9 are his alts', about: 'his viewers are alts', announcer: 'Nova swings first!' });++ assert.strictEqual(h1.opened, true); assert.strictEqual(h1.side, 'a');++ assert.strictEqual(h1.beef.on_clock, 'b', 'the target is on the clock');++ let v = beef.get(h1.beef.id);++ assert.ok(v.clock_seconds_left > 23 * 3600 && v.clock_seconds_left <= 24 * 3600, `offline clock is 24 h: ${v.clock_seconds_left}`);++ assert.ok(v.headline && v.headline.includes('Nova'), `template headline: ${v.headline}`);++ assert.strictEqual(v.share_a, 100);++ assert.strictEqual(beef.recordHit(u1, u1, { quality: 5 }), null, 'no beef with yourself');++ const h2 = beef.recordHit(u2, u1, { quality: 9, best_line: 'Nova needed a bit to get views, I just exist', about: 'clout chasing' });++ assert.strictEqual(h2.opened, false); assert.strictEqual(h2.beef.id, h1.beef.id, 'same pair → same beef');++ assert.strictEqual(h2.first_response, true);++ assert.strictEqual(h2.beef.on_clock, 'a', 'answering flips the clock');++ v = beef.get(h1.beef.id);++ assert.strictEqual(v.feed.length, 2);++ assert.strictEqual(v.feed[1].kind, 'respond');++ assert.ok(v.share_a < 50, 'quality 9 beats quality 7');++ assert.throws(() => beef.hype(v.id, 'a', `user:${u1}`), /yourself/);++ assert.deepStrictEqual(beef.hype(v.id, 'a', 'anon:x'), { added: true, hypers: 1, crowd: 1 });++ assert.deepStrictEqual(beef.hype(v.id, 'a', 'anon:x').added, false);++ assert.deepStrictEqual(beef.pickSide(v.id, 'anon:x', 'b'), { a: 0, b: 1, share_a: 0 });++ assert.throws(() => beef.hype(999, 'a', 'anon:x'), /No open beef/);++ beef.tick();++ assert.strictEqual(beef.get(v.id).status, 'open', 'clock still running → stays open');++ db.run(`UPDATE arena_beefs SET clock_until = datetime('now', '-1 minute') WHERE id = ?`, [v.id]);++ beef.tick();++ v = beef.get(v.id);++ assert.strictEqual(v.status, 'resolved'); assert.strictEqual(v.resolution, 'forfeit');++ assert.strictEqual(v.winner_user_id, u2, 'the side on the clock (a) went silent → b wins');++ assert.ok(v.result_headline, 'result headline set');++ assert.deepStrictEqual(beef.recordFor(u2), { wins: 1, losses: 0, draws: 0 });++ assert.deepStrictEqual(beef.recordFor(u1), { wins: 0, losses: 1, draws: 0 });++ assert.strictEqual(beef.streakFor(u2), 1);++ assert.ok(board.levelView(u2).xp >= beef.XP_BEEF_WIN, 'winner gets beef XP');++ assert.ok(arena.listFighters().find(f => f.user.id === u2).ratings.talk_bonus > 0, 'recent win → mouth bonus on POWER');++ console.log('✅ beef: open on a name-drop, clock flips on answer, forfeit on silence');++++ // Rematch + hard end on points + upset + rivalry receipts.++ const h3 = beef.recordHit(u1, u2, { quality: 8, best_line: 'Round two, and this time bring your real viewers', about: 'rematch' });++ assert.strictEqual(h3.opened, true);++ let v2 = beef.get(h3.beef.id);++ assert.strictEqual(v2.rematch, true, 'second beef between the pair is a rematch');++ assert.strictEqual(v2.history.fights, 1);++ beef.recordHit(u2, u1, { quality: 3, best_line: 'ok', about: 'weak' });++ db.run(`UPDATE arena_beefs SET ends_at = datetime('now', '-1 minute') WHERE id = ?`, [v2.id]);++ beef.tick();++ v2 = beef.get(v2.id);++ assert.strictEqual(v2.resolution, 'score'); assert.strictEqual(v2.winner_user_id, u1, 'higher total on the hard end wins');++ assert.strictEqual(v2.upset, false, 'the #1 beating #2 is no upset');++ const riv = beef.rivalry(u1, u2);++ assert.deepStrictEqual([riv.fights, riv.wins_1, riv.wins_2], [2, 1, 1]);++ assert.ok(riv.receipts.length >= 1 && riv.receipts[0].quality >= 6, 'receipts are the quotable lines from earlier beefs');++ assert.strictEqual(beef.rivalriesFor(u1)[0].opponent.user.id, u2);++ // Upset: #3 beats #1 on a forfeit.++ const h4 = beef.recordHit(u3, u1, { quality: 6, best_line: 'Nova is a lobby simulator with a face cam', about: 'x' });++ db.run(`UPDATE arena_beefs SET clock_until = datetime('now', '-1 minute') WHERE id = ?`, [h4.beef.id]);++ beef.tick();++ const up = beef.get(h4.beef.id);++ assert.strictEqual(up.winner_user_id, u3);++ assert.strictEqual(up.upset, false, 'rank gap of 2 is not an upset (needs ≥ 4)');++ const L = beef.list();++ assert.strictEqual(L.open.length, 0); assert.strictEqual(L.resolved.length, 3);++ console.log('✅ rematches, hard end on points, rivalry receipts');++++ // Bounty doubles beef XP for whoever collects.++ const bty = board.createTopic({ text: 'Bounty: Pixelqueen', createdBy: 'chat', creatorUserId: viewer, kind: 'bounty', targetUserId: u3 });++ assert.strictEqual(board.openBountyOn(u3).id, bty.id);++ assert.throws(() => board.createTopic({ text: 'Bounty again', createdBy: 'chat', kind: 'bounty', targetUserId: u3 }), /already a bounty/);++ const before = board.levelView(u2).xp;++ const hb = beef.recordHit(u2, u3, { quality: 8, best_line: 'Pixelqueen plays on a calculator', about: 'setup' });++ assert.strictEqual(hb.bounty, true);++ const gained = board.levelView(u2).xp - before;++ assert.ok(gained >= 8 * 2 + beef.XP_BEEF_OPEN, `bounty doubles the hit XP (+open bonus): ${gained}`);++ assert.strictEqual(board.topicDetail(bty.id).hits, 1);++ console.log('✅ bounties');++++ // ── Listener: aliases + heuristics + a real tick over a live transcribed stream ──++ const fresh2 = arena.loadRoster(true);++ assert.deepStrictEqual(listener._mentionsIn('honestly grizzly bear is washed', u1, fresh2), [u2], 'underscored username spoken with a space');++ assert.deepStrictEqual(listener._mentionsIn('shoutout @pixelqueen for the raid', u1, fresh2), [u3]);++ assert.deepStrictEqual(listener._mentionsIn('nova is the best, nova nova', u1, fresh2), [], 'a speaker never mentions themselves');++ assert.deepStrictEqual(listener._mentionsIn('the supernova exploded', u2, fresh2), [], 'no match inside other words');++ const hb1 = listener._heuristicBeef('Grizzly is trash and washed, he could never beat me!', ['grizzly']);++ assert.strictEqual(hb1.aimed_at_target, true); assert.ok(hb1.quality >= 6);++ assert.strictEqual(listener._heuristicBeef('thanks grizzly for the raid, love you', ['grizzly']).aimed_at_target, false);++ const ht = listener._heuristicTopic('the roast angle: worst at this is grizzly because reasons', [{ text: 'Brag: why you win' }, { text: 'Roast: who is worst at this and why' }, { text: 'Bit: unhinged take' }]);++ assert.strictEqual(ht.angle_idx, 1);++++ // Nova goes live with transcription; says pixelqueen's name while talking shit → beef opens without anyone clicking.++ const liveId = Number(db.createStream({ user_id: u1, title: 'live', category: 'irl', protocol: 'rtmp' }).lastInsertRowid);++ db.run(`UPDATE streams SET is_live = 1, started_at = datetime('now', '-600 seconds') WHERE id = ?`, [liveId]);++ db.addTimelineEvents([++ { stream_id: liveId, user_id: u1, vod_id: null, kind: 'speech', start_sec: 586, end_sec: 590, text: 'Chat, pixelqueen is trash, she is washed and could never beat me in anything, she is scared of the smoke.', label: null, confidence: 0.9 },++ { stream_id: liveId, user_id: u1, vod_id: null, kind: 'speech', start_sec: 591, end_sec: 596, text: 'Nobody watches that stream, it is mid, it is garbage, I would cook her in a second, bet!', label: null, confidence: 0.9 },++ ]);++ assert.strictEqual(arena.liveFighters()[0].user.id, u1);++ const ev = await listener.tick();++ assert.ok(ev.some(e => e.kind === 'beef_hit' && e.targetId === u3 && e.opened), `listener opened the beef: ${JSON.stringify(ev)}`);++ assert.strictEqual(listener.consoleState(u1).listening, true);++ assert.ok(listener.consoleState(u1).last_beef_judgement.aimed_at_target);++ const opened = beef.openBeefsFor(u1);++ assert.strictEqual(opened.length, 1); assert.strictEqual(opened[0].b_user_id, u3);++ assert.strictEqual(opened[0].on_clock, 'b');++ assert.strictEqual(arena.liveFighters()[0].open_beefs, 1);++ console.log('✅ listener: name-drop on a live stream opens the beef');++++ // ── Chat commands ──++ const sent = [], room = [];++ const fakeChat = { sendTo: (ws, m) => sent.push(m.message), broadcastToStream: (sid, m) => room.push(m.message) };++ const run = async (client, line) => { const parts = line.split(' '); const handled = chat.handle(fakeChat, {}, client, parts[0], parts); await new Promise(r => setTimeout(r, 30)); return handled; };++ const vc = { user: { id: viewer, username: 'viewer', display_name: 'Viewer' }, streamId: liveId, ip: '1.1.1.1' };++ assert.strictEqual(await run(vc, '!nope'), false);++ assert.strictEqual(await run(vc, '!topic Streamers who blame lag for everything'), true);++ assert.ok(sent.pop().includes('/arena/topic/'), 'topic posted with a link');++ assert.ok(room.pop().includes('put a topic on the Arena board'));++ assert.ok(db.get(`SELECT id FROM arena_topics WHERE text LIKE 'Streamers who blame lag%' AND created_by = 'chat'`));++ await new Promise(r => setTimeout(r, 4100)); // per-person command rate limit++ await run(vc, '!hype');++ assert.ok(sent.pop().includes('Hyped Nova in their beef'), 'hype goes to the streamer\'s open beef');++ assert.strictEqual(beef.get(opened[0].id).a.crowd, 1);++ await new Promise(r => setTimeout(r, 4100));++ await run(vc, '!side pixelqueen');++ assert.ok(sent.pop().includes("You're with"));++ assert.deepStrictEqual(beef.sidesTally(opened[0].id), { a: 0, b: 1, share_a: 0 });++ await new Promise(r => setTimeout(r, 4100));++ await run({ anonId: 'zz', streamId: liveId }, '!beef');++ assert.ok(sent.pop().includes('on the clock'), 'beef summary shows the clock');++ await run({ anonId: 'zz2', streamId: liveId }, '!board');++ assert.ok(sent.pop().includes('Hottest'), 'board summary');++ await run({ anonId: 'zz3', streamId: liveId }, '!topic no sign in');++ assert.ok(sent.pop().includes('Sign in'));++ console.log('✅ chat: !topic !hype !side !beef !board');++++ // ── Public API smoke ──++ const express = require('express');++ const app = express(); app.use(express.json()); app.use('/api/arena', require('../server/arena/routes'));++ const srv = await new Promise(r => { const s = app.listen(0, () => r(s)); });++ const get = async (p) => { const res = await fetch(`http://127.0.0.1:${srv.address().port}/api/arena${p}`); return { status: res.status, body: await res.json() }; };++ const bd = await get('/board');++ assert.strictEqual(bd.status, 200); assert.ok(Array.isArray(bd.body.open) && bd.body.levels.length >= 1 && bd.body.clout.length >= 1);++ const bf = await get('/beefs'); assert.strictEqual(bf.body.open.length, 2, 'nova→pixelqueen (listener) + grizzly→pixelqueen (bounty)'); assert.strictEqual(bf.body.resolved.length, 3);++ const one = await get(`/beefs/${opened[0].id}`); assert.strictEqual(one.body.a.user.username, 'nova'); assert.ok(one.body.rules.response_live_min === 15);++ const con = await get('/console/nova'); assert.strictEqual(con.body.listener.listening, true); assert.strictEqual(con.body.open_beefs.length, 1); assert.ok(con.body.hot_mic.length === 2);++ const lv2 = await get('/live'); assert.strictEqual(lv2.body.live[0].open_beefs, 1);++ const tp = await get(`/board/topics/${t1.id}`); assert.strictEqual(tp.body.conquered, 1);++ assert.strictEqual((await get('/beefs/999')).status, 404);++ assert.strictEqual((await get('/console/nobody')).status, 404);++ srv.close();++ console.log('✅ public API');++++ console.log('\n✅ All Arena beef/board tests passed');++ process.exit(0);++})().catch(err => { console.error(err); process.exit(1); });ddeleted file mode 100644@@@ -1,120 +0,0 @@--'use strict';----// Live trash-talk sessions: start needs a live stream with transcription, the ticker--// judges new transcript chunks against the current topic, progress clears topics and--// generates the next one, XP levels up, hype adds XP/progress, and the session ends when--// the stream ends. No AI configured → heuristic chunk judge, templated topics.----const assert = require('assert');--const fs = require('fs');--const os = require('os');--const path = require('path');----const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-session-'));--process.env.DB_PATH = path.join(tmp, 'live.db');--process.env.ARENA_IMAGE_PATH = path.join(tmp, 'arena');--const db = require('../server/db/database');--db.initDb();--const arena = require('../server/arena/arena-service');--const sessions = require('../server/arena/talk-session');----const mk = (u, key) => Number(db.createUser({ username: u, email: `${u}@x`, password_hash: 'x', display_name: u.toUpperCase(), stream_key: key }).lastInsertRowid);--const u1 = mk('alpha', 'a'.repeat(32));--db.ensureChannel(u1);--const old = Number(db.createStream({ user_id: u1, title: 'old', category: 'irl', protocol: 'rtmp' }).lastInsertRowid);--db.run(`UPDATE streams SET is_live = 0, started_at = datetime('now', '-2 days'), ended_at = datetime('now', '-2 days'), duration_seconds = 3600, peak_viewers = 20 WHERE id = ?`, [old]);----assert.strictEqual(sessions._levelFor(0), 1);--assert.strictEqual(sessions._levelFor(39), 1);--assert.strictEqual(sessions._levelFor(40), 2);--assert.strictEqual(sessions._levelFor(125), 4);--const hc = sessions._heuristicChunk("Chat, your chat couldn't read a menu. I'm the smartest streamer on this whole site, bet!", { topic: 'Explain why your chat is the smartest chat on this site.', hint: '', tone: 'brag' });--assert.strictEqual(hc.is_trash_talk, true);--assert.ok(hc.progress_gain > 10 && hc.best_line.length > 5, JSON.stringify(hc));--const quiet = sessions._heuristicChunk('okay let me open the inventory and craft the pickaxe then we go mining', { topic: 'Explain why your chat is the smartest chat on this site.', hint: '', tone: 'brag' });--assert.ok(quiet.progress_gain <= 15, 'gameplay chatter barely moves the topic');--console.log('✅ levels + heuristic chunk judge');----(async () => {-- await assert.rejects(() => sessions.startSession(u1), /Go live/, 'needs a live stream');-- const live = Number(db.createStream({ user_id: u1, title: 'live', category: 'irl', protocol: 'rtmp' }).lastInsertRowid);-- db.run(`UPDATE streams SET is_live = 1, started_at = datetime('now', '-10 minutes') WHERE id = ?`, [live]);-- await assert.rejects(() => sessions.startSession(u1), /transcription/, 'needs transcript rows');-- db.addTimelineEvents([{ stream_id: live, user_id: u1, vod_id: 77, kind: 'speech', start_sec: 500, end_sec: 505, text: 'welcome in chat, we are live', label: null, confidence: 0.9 }]);---- const s = await sessions.startSession(u1);-- assert.strictEqual(s.status, 'live');-- const again = await sessions.startSession(u1);-- assert.strictEqual(again.id, s.id, 'one live session per user');-- let v = sessions.viewFor('alpha');-- assert.ok(v.session.active_topic && v.session.active_topic.topic, 'a first topic is set');-- assert.strictEqual(v.session.level, 1);-- assert.strictEqual(v.session.active_topic.progress, 0);-- console.log('✅ session start + first topic');---- // Feed trash talk in the transcript AFTER the session offset and tick.-- const off = 600 + 5; // session started ~600s into the stream-- const trash = [-- "Chat, your chat couldn't read a menu. I'm the smartest streamer on this whole site, bet!",-- 'Nobody on this ladder is scared of nothing except me, champ. Come see who is better.',-- "You call that a stream? Best believe my chat is smarter, faster, and prettier than yours!",-- ];-- db.addTimelineEvents(trash.map((t, i) => ({ stream_id: live, user_id: u1, vod_id: 77, kind: 'speech', start_sec: off + i * 10, end_sec: off + i * 10 + 8, text: t, label: null, confidence: 0.9 })));-- await sessions.tick();-- v = sessions.viewFor('alpha');-- const t1 = v.session.active_topic;-- assert.ok(t1.chunks >= 1, 'a chunk was judged');-- assert.ok(t1.progress > 0 && t1.progress <= 100, `progress ${t1.progress}`);-- assert.ok(v.session.xp > 0, 'xp earned');-- assert.ok(v.session.recent_lines.length >= 3, 'live lines visible');-- assert.ok(v.session.talked_about.length >= 1, 'talked-about tags');-- assert.ok(t1.best_line && t1.best_vod_id === 77 && typeof t1.best_line_sec === 'number', 'best line links to the VOD');-- console.log('✅ tick judges new speech against the topic');---- // Hype from two viewers, self-hype blocked.-- const h1 = sessions.hypeSession(u1, 'user:50');-- assert.strictEqual(h1.added, true);-- assert.strictEqual(sessions.hypeSession(u1, 'user:50').added, false);-- sessions.hypeSession(u1, 'anon:abc');-- assert.throws(() => sessions.hypeSession(u1, `user:${u1}`), /yourself/);-- v = sessions.viewFor('alpha');-- assert.strictEqual(v.session.active_topic.hypers, 2);-- console.log('✅ hype adds XP + progress');---- // Keep talking (bypass the 30 s judge interval by rewinding last_judge_at) until the topic clears.-- let cleared = 0;-- for (let round = 0; round < 8 && !cleared; round++) {-- db.run(`UPDATE arena_talk_sessions SET last_judge_at = datetime('now', '-2 minutes') WHERE id = ?`, [s.id]);-- db.addTimelineEvents(trash.map((t, i) => ({ stream_id: live, user_id: u1, vod_id: 77, kind: 'speech', start_sec: off + 100 + round * 40 + i * 10, end_sec: off + 100 + round * 40 + i * 10 + 8, text: t + ` (${round})`, label: null, confidence: 0.9 })));-- await sessions.tick();-- cleared = sessions.viewFor('alpha').session.topics_cleared;-- }-- v = sessions.viewFor('alpha');-- assert.strictEqual(v.session.topics_cleared, 1, 'talking enough clears the topic');-- assert.strictEqual(v.session.cleared_topics.length, 1);-- assert.strictEqual(v.session.cleared_topics[0].status, 'cleared');-- assert.ok(v.session.active_topic && v.session.active_topic.idx === 1, 'a new topic replaces it');-- assert.notStrictEqual(v.session.active_topic.topic, v.session.cleared_topics[0].topic, 'the new topic differs');-- const entry = db.get(`SELECT * FROM arena_talk WHERE user_id = ? AND source = 'session'`, [u1]);-- assert.ok(entry && entry.total > 0, 'a cleared topic becomes a Trash Talk entry');-- assert.ok(arena.loadRoster(true).byId[u1].ratings.talk_bonus >= 1, 'and feeds the POWER bonus');-- console.log('✅ topic clears → next topic + entry + bonus');---- const skipped = await sessions.skipTopic(u1);-- assert.strictEqual(skipped.idx, 2);-- assert.strictEqual(sessions.viewFor('alpha').session.cleared_topics.filter(t => t.status === 'skipped').length, 1);-- assert.ok(sessions.liveSessionSummaries().length === 1 && sessions.liveSessionSummaries()[0].user.username === 'alpha');-- console.log('✅ skip + live list');---- db.run('UPDATE streams SET is_live = 0 WHERE id = ?', [live]);-- await sessions.tick();-- v = sessions.viewFor('alpha');-- assert.strictEqual(v.session.status, 'ended');-- assert.strictEqual(v.session.end_reason, 'stream_ended');-- assert.strictEqual(sessions.liveSessionFor(u1), null);-- assert.throws(() => sessions.stopSession(u1), /No live session/);-- console.log('✅ session ends with the stream');---- console.log('\n✅ All session tests passed');-- process.exit(0);--})().catch(err => { console.error(err); process.exit(1); });ddeleted file mode 100644@@@ -1,135 +0,0 @@--'use strict';----// Trash Talk: topics are per-slot and deterministic without AI, entries are judged--// (heuristic without AI, slurs void), the crowd score comes from unique hypes, the--// POWER bonus decays, and chat commands reply through the ChatServer API.----const assert = require('assert');--const fs = require('fs');--const os = require('os');--const path = require('path');----const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-talk-'));--process.env.DB_PATH = path.join(tmp, 'live.db');--process.env.ARENA_IMAGE_PATH = path.join(tmp, 'arena');--const db = require('../server/db/database');--db.initDb();--const arena = require('../server/arena/arena-service');--const talk = require('../server/arena/trash-talk');--const chat = require('../server/arena/arena-chat');----// ── pure bits ──--assert.strictEqual(talk.currentSlot(Date.UTC(2026, 7, 29, 13, 5)), '2026-08-29/2', '13:05 UTC is slot 2 of 6-hour slots');--assert.strictEqual(talk.currentSlot(Date.UTC(2026, 7, 29, 23, 59)), '2026-08-29/3');--assert.strictEqual(talk._crowdScore(0), 0);--assert.strictEqual(talk._crowdScore(3), 1);--assert.strictEqual(talk._crowdScore(90), 10, 'crowd caps at 10');--assert.strictEqual(talk._stampFor(45), 'COOKED');--assert.strictEqual(talk._stampFor(31), 'SOLID');--assert.strictEqual(talk._stampFor(5), 'FLOP');--assert.strictEqual(talk._totalFor({ spice: 8, wit: 7, on_topic: 9, delivery: 6 }, 9), 33, 'total = 4 judge scores + crowd');--assert.strictEqual(talk._totalFor({ spice: 8, wit: 7, on_topic: 9, delivery: 6, flagged: true }, 30), 0, 'flagged entries score 0');--const h = talk._heuristicJudge('Chat, your chat is smarter than this whole arena, bet!', 'Explain why your chat is the smartest chat on this site.');--assert.ok(h.spice >= 3 && h.on_topic >= 4 && !h.flagged, JSON.stringify(h));--console.log('✅ slots, crowd score, stamps, totals, heuristic judge');----// ── roster ──--const mk = (u, key) => Number(db.createUser({ username: u, email: `${u}@x`, password_hash: 'x', display_name: u.toUpperCase(), stream_key: key }).lastInsertRowid);--const u1 = mk('alpha', 'a'.repeat(32)), u2 = mk('bravo', 'b'.repeat(32)), viewer = mk('fan', 'c'.repeat(32));--for (const uid of [u1, u2, viewer]) db.ensureChannel(uid);--const stream = (uid, peak, hours, daysAgo) => {-- const id = Number(db.createStream({ user_id: uid, title: 't', category: 'irl', protocol: 'rtmp' }).lastInsertRowid);-- db.run(`UPDATE streams SET is_live = 0, started_at = datetime('now', ?), ended_at = datetime('now', ?), duration_seconds = ?, peak_viewers = ? WHERE id = ?`, [`-${daysAgo} days`, `-${daysAgo} days`, Math.round(hours * 3600), peak, id]);-- return id;--};--stream(u1, 50, 4, 1); stream(u2, 40, 4, 2);----(async () => {-- const t1 = await talk.getTopic({ generate: false });-- const t2 = await talk.getTopic({ generate: false });-- assert.strictEqual(t1.id, t2.id, 'one topic per slot');-- assert.ok(t1.topic.length > 10 && t1.ends_at, 'templated topic without AI');-- console.log('✅ topic per slot');---- const b0 = await talk.board({ userId: viewer, generate: false });-- assert.strictEqual(b0.on_roster, false, 'a viewer is not on the roster');-- assert.strictEqual(b0.can_enter, false);-- await assert.rejects(() => talk.submit(viewer, { mode: 'text', text: 'I am the best and everyone knows it, chat.' }), /roster/);-- await assert.rejects(() => talk.submit(u1, { mode: 'text', text: 'short' }), /more than that/);-- await assert.rejects(() => talk.submit(u1, { mode: 'mic', text: '' }), /live-mic session/);-- console.log('✅ entry gating');---- const e1 = await talk.submit(u1, { mode: 'text', text: 'My chat solves my bugs before I finish typing them. Your chat is still loading, champ!' });-- assert.strictEqual(e1.source, 'text');-- assert.ok(e1.total > 0 && e1.scores && e1.stamp, JSON.stringify(e1));-- assert.strictEqual(e1.flagged, false);-- await assert.rejects(() => talk.submit(u1, { mode: 'text', text: 'Trying to enter the same topic twice should fail.' }), /already entered/);-- const bad = await talk.submit(u2, { mode: 'text', text: 'Your chat is full of retards and you know it, loser.' });-- assert.strictEqual(bad.flagged, true, 'slur → void');-- assert.strictEqual(bad.total, 0);-- console.log('✅ judged entries; slurs void');---- const before = talk.hype(e1.id, 'user:999');-- assert.strictEqual(before.added, true);-- assert.strictEqual(talk.hype(e1.id, 'user:999').added, false, 'one hype per person');-- for (let i = 0; i < 5; i++) talk.hype(e1.id, `anon:${i}`);-- const after = talk.hype(e1.id, 'user:1000');-- assert.strictEqual(after.crowd_uniques, 7);-- assert.ok(Math.abs(after.crowd - 2.3) < 0.01, `crowd 7/3 → ${after.crowd}`);-- assert.strictEqual(after.total, e1.total + after.crowd);-- assert.throws(() => talk.hype(e1.id, `user:${u1}`), /yourself/);-- assert.throws(() => talk.hype(bad.id, 'user:5'), /void/);-- console.log('✅ crowd hype');---- const board = await talk.board({ userId: u1, generate: false });-- assert.strictEqual(board.entries.length, 2);-- assert.strictEqual(board.entries[0].user.username, 'alpha', 'ranked by total');-- assert.strictEqual(board.entries[1].text, null, 'a flagged entry hides its text from others');-- assert.strictEqual(board.my_entry.id, e1.id);-- assert.strictEqual(board.can_enter, false, 'already entered');-- assert.ok(board.hall_of_trash.length === 1 && board.hall_of_trash[0].topic, 'hall lists non-flagged entries with their topic');-- const ownBad = await talk.board({ userId: u2, generate: false });-- assert.ok(ownBad.my_entry.text && ownBad.my_entry.note, 'the author still sees their voided text + note');-- console.log('✅ board');---- const bonuses = talk.talkBonuses();-- assert.ok(bonuses[u1] >= 1 && bonuses[u1] <= talk.TALK_BONUS_MAX, `bonus ${bonuses[u1]}`);-- assert.strictEqual(bonuses[u2], undefined, 'flagged entry gives no bonus');-- const roster = arena.loadRoster(true);-- assert.strictEqual(roster.byId[u1].ratings.talk_bonus, bonuses[u1]);-- assert.strictEqual(roster.byId[u1].ratings.power, roster.byId[u1].ratings.base_power + bonuses[u1], 'bonus is added to POWER');-- assert.strictEqual(roster.byId[u2].ratings.talk_bonus, 0);-- const card = await arena.getFighter('alpha', { generate: false });-- assert.strictEqual(card.trash_talk.length, 1);-- console.log('✅ POWER bonus');---- // ── chat commands (fake ChatServer) ──-- const sent = [], broadcast = [];-- const fakeChat = { sendTo: (ws, m) => sent.push(m.message), broadcastToStream: (sid, m) => broadcast.push(m.message) };-- const live = Number(db.createStream({ user_id: u1, title: 'live', category: 'irl', protocol: 'rtmp' }).lastInsertRowid);-- db.run('UPDATE streams SET is_live = 1 WHERE id = ?', [live]);-- const client = (user, anonId = null) => ({ user, anonId, streamId: live, ip: '1.2.3.4' });-- assert.strictEqual(chat.handle(fakeChat, {}, client(null, 'anonA'), '!sr', ['!sr']), false, 'unrelated commands fall through');-- assert.strictEqual(chat.handle(fakeChat, {}, client(null, 'anonA'), '!hype', ['!hype']), true);-- await new Promise(r => setTimeout(r, 50));-- assert.ok(/Hyped!|already hyped/.test(sent[sent.length - 1]), sent[sent.length - 1]);-- chat.handle(fakeChat, {}, client(null, 'anonB'), '!talk', ['!talk']);-- await new Promise(r => setTimeout(r, 50));-- assert.ok(/Trash Talk topic/.test(sent[sent.length - 1]));-- chat.handle(fakeChat, {}, client(db.getUserById(viewer)), '!arena', ['!arena']);-- await new Promise(r => setTimeout(r, 100));-- assert.ok(/PWR/.test(sent[sent.length - 1]), sent[sent.length - 1]);-- chat.handle(fakeChat, {}, client(null, 'anonC'), '!vote', ['!vote', 'a']);-- await new Promise(r => setTimeout(r, 50));-- assert.ok(/Sign in/.test(sent[sent.length - 1]));-- chat.handle(fakeChat, {}, client(null, 'anonD'), '!fight', ['!fight', 'bravo']);-- await new Promise(r => setTimeout(r, 50));-- assert.ok(/called out/.test(broadcast[broadcast.length - 1]), 'fight is announced to the room');-- chat.handle(fakeChat, {}, client(null, 'anonD'), '!talk', ['!talk']);-- await new Promise(r => setTimeout(r, 50));-- assert.ok(/Easy/.test(sent[sent.length - 1]), 'rate limited per person');-- console.log('✅ chat commands');---- console.log('\n✅ All Trash Talk tests passed');-- process.exit(0);--})().catch(err => { console.error(err); process.exit(1); });@@@ -1,9 +1,9 @@'use strict';--// Arena regression tests: ratings are fair percentiles (incl. MIC from the transcripts),--// battles are deterministic per day and order-independent, the crowd vote is the last--// round, quotes come from real transcript lines with VOD links, and the roster/API works--// end-to-end on a temp DB without any AI configured (personas fall back, no LLM calls).++// Arena roster regression tests: ratings are fair percentiles (incl. MIC from the transcripts),++// the behaviour-only filter blocks threats/minors/doxxing but never vocabulary, quotes come from++// real transcript lines with VOD links, and the roster/card/stat API works end-to-end on a temp DB++// without any AI configured (personas fall back, no LLM calls). Beefs + the board: arena-beef-board.test.js.const assert = require('assert');const fs = require('fs');@@@ -36,26 +36,12 @@ const solo = arena._computeRatings({ 7: { ...base } });assert.strictEqual(solo[7].hype, 70, 'a roster of one is a flat 70 — no population to compare to');console.log('✅ ratings are percentile-based with a floor (7 stats incl. MIC)');--const A = { hype: 90, grind: 60, chat: 70, loyalty: 80, clutch: 50, vibe: 85, mic: 75, power: 80 };--const B = { hype: 60, grind: 90, chat: 65, loyalty: 70, clutch: 95, vibe: 60, mic: 50, power: 70 };--const r1 = arena._simulateRounds(A, B, '1:2:2026-08-29');--const r2 = arena._simulateRounds(A, B, '1:2:2026-08-29');--const r3 = arena._simulateRounds(A, B, '1:2:2026-08-30');--assert.deepStrictEqual(r1, r2, 'same pair + same day → identical rounds');--assert.notDeepStrictEqual(r1.map(r => r.a), r3.map(r => r.a), 'a new day rolls new rounds');--assert.strictEqual(r1.length, 5, 'five rounds incl. Mic Drop');--assert.strictEqual(r1[4].stat, 'mic');--for (const r of r1) assert.ok(['a', 'b'].includes(r.winner) && r.a >= 40 && r.b >= 40);--console.log('✅ battles are seeded per day');----const w = (s) => ({ winner: s });--assert.deepStrictEqual(arena._scoreBattle([w('a'), w('a'), w('b'), w('a'), w('b')], { a: 0, b: 0 }, 80, 70), { a: 3, b: 2, crowd: null, winner: 'a', tiebreak: null });--const even = [w('a'), w('a'), w('b'), w('b'), w('a')];--assert.strictEqual(arena._scoreBattle(even, { a: 0, b: 5 }, 80, 70).tiebreak, 'power');--assert.strictEqual(arena._scoreBattle(even, { a: 0, b: 5 }, 80, 70).winner, 'a', 'power tiebreak goes to the higher Power');--assert.strictEqual(arena._scoreBattle([w('a'), w('a'), w('b'), w('b'), w('b')], { a: 9, b: 1 }, 70, 80).winner, 'b', 'crowd adds one round: 2+1 vs 3 → 3–3 → Power (b) wins');--assert.strictEqual(arena._scoreBattle(even, { a: 0, b: 5 }, 80, 80).winner, null, '3–3 after the crowd round with equal Power stays a draw');--console.log('✅ crowd vote is the last round');++// ── Filter: behaviour, not vocabulary (the platform does not censor language) ──++for (const bad of ['kys', 'kill yourself', 'go drink bleach', 'I will kill you', "i'm gonna stab him", 'his home address is 12 elm st', 'her phone number is 555 0199', 'child porn link', 'cp links in bio', 'doxxed him already'])++ assert.ok(arena._isBannedText(bad), `banned (behaviour): ${bad}`);++for (const ok of ['that beaner coon nonsense', 'stop being a faggot', 'what up my niggas', "he's a retard", 'such a whore', 'pussy move', 'we got raped in that match', 'this shell script is hell', 'kill the lights', 'his address is wrong on the form? no, the URL address'])++ assert.ok(!arena._isBannedText(ok), `allowed (speech): ${ok}`);++console.log('✅ filter blocks threats / minors / doxxing only — slurs and profanity are speech');// ── End-to-end on a temp DB (no AI configured → fallback personas/quotes, no LLM calls) ──const mk = (username, key) => Number(db.createUser({ username, email: `${username}@x`, password_hash: 'x', display_name: username.toUpperCase(), stream_key: key }).lastInsertRowid);@@@ -85,19 +71,17 @@ for (let i = 0; i < 40; i++) {db.addTimelineEvents(lines);db.addTimelineEvents([{ stream_id: s1, user_id: u1, vod_id: 901, kind: 'sound', start_sec: 30, end_sec: 33, text: null, label: 'Laughter', confidence: 0.8 }, { stream_id: s1, user_id: u1, vod_id: 901, kind: 'sound', start_sec: 90, end_sec: 93, text: null, label: 'Rock music', confidence: 0.8 }]);--// Lines with slurs / hate / hard profanity must never become quotes — not even as candidates.++// Offensive lines are fair game for quotes; threats / doxxing never are.db.addTimelineEvents([-- { stream_id: s1, user_id: u1, vod_id: 901, kind: 'speech', start_sec: 5000, end_sec: 5010, text: "Let's go chat, you absolute f4ggots, that was insane!", label: null, confidence: 0.9 },-- { stream_id: s1, user_id: u1, vod_id: 901, kind: 'speech', start_sec: 5100, end_sec: 5110, text: 'No way, chat, that guy is such a retard!', label: null, confidence: 0.9 },-- { stream_id: s1, user_id: u1, vod_id: 901, kind: 'speech', start_sec: 5200, end_sec: 5210, text: 'Holy, that was insane, kill yourself if you disagree!', label: null, confidence: 0.9 },++ { stream_id: s1, user_id: u1, vod_id: 901, kind: 'speech', start_sec: 5000, end_sec: 5010, text: "Let's go chat, you absolute clowns, that was insane you retards!", label: null, confidence: 0.9 },++ { stream_id: s1, user_id: u1, vod_id: 901, kind: 'speech', start_sec: 5100, end_sec: 5110, text: 'No way, chat, that guy should kill himself, kys buddy, insane!', label: null, confidence: 0.9 },++ { stream_id: s1, user_id: u1, vod_id: 901, kind: 'speech', start_sec: 5200, end_sec: 5210, text: 'Holy, that was insane, his home address is 12 elm street lol!', label: null, confidence: 0.9 },]);--for (const bad of ["what up my n1ggas", "stop being a faggot", "keep it real on those faggots", "niggers", "r3tards everywhere", "he's a retard", "kys", "kill yourself", "such a whore", "pussy move", "f4gg0tz", "niggaaaa"]) assert.ok(arena._isBannedText(bad), `banned: ${bad}`);--for (const ok of ["the shell script is hell to debug", "we dug a dyke in the sand? no — a dike, the engineering kind"]) { /* documented: broad filter may catch "dyke" — acceptable */ }--assert.ok(!arena._isBannedText("the shell script is hell to debug and I hate it"), 'ordinary sentences pass');const cands = arena._quoteCandidates(u1);assert.ok(cands.length >= 20, 'has candidates');--assert.ok(cands.every(c => !arena._isBannedText(c.text)), 'no banned line is ever a candidate');--console.log('✅ slurs/hate never surface as quotes');++assert.ok(cands.some(c => c.text.includes('retards')), 'offensive vocabulary is not filtered out of quotes');++assert.ok(cands.every(c => !/kys|home address/.test(c.text)), 'threats / doxxing never become quotes');++console.log('✅ quote candidates: speech stays, behaviour goes');const voice = arena._voiceStatsFor(u1, '-90 days');assert.strictEqual(voice.has_data, true);@@@ -115,10 +99,12 @@ assert.deepStrictEqual(fighters.map(f => f.user.username), ['alpha', 'bravo', 'cassert.strictEqual(fighters[0].rank, 1);assert.strictEqual(fighters[0].ratings.mic, 99, 'the only one with transcripts tops MIC');assert.strictEqual(fighters[1].ratings.mic, fighters[2].ratings.mic, 'no-transcript fighters tie on MIC (shared rank)');--assert.ok(fighters[1].ratings.mic < fighters[0].ratings.mic);assert.ok(fighters[0].persona.fighter_name, 'fallback persona has a name even with AI off');assert.strictEqual(fighters[0].persona_is_fallback, true);assert.strictEqual(fighters[0].voice.has_data, true);++assert.deepStrictEqual(fighters[0].record, { wins: 0, losses: 0, draws: 0 }, 'beef record starts empty');++assert.deepStrictEqual(fighters[0].level, { level: 1, xp: 0 }, 'Trash Level starts at 1');++assert.strictEqual(fighters[0].ratings.talk_bonus, 0, 'no mouth bonus without XP');console.log('✅ roster from real stream/analytics/transcript rows, inactive streamers excluded');(async () => {@@@ -131,10 +117,10 @@ console.log('✅ roster from real stream/analytics/transcript rows, inactive strassert.strictEqual(card.image_generation, 'off', 'no AI → no image generation');assert.ok(card.quotes && card.quotes.picks.length >= 3, 'heuristic quotes without AI');assert.ok(card.quotes.picks[0].vod_id === 901 && typeof card.quotes.picks[0].start_sec === 'number', 'quotes link to the VOD second');-- assert.ok(card.quotes.picks[0].text.includes("Let's go") || card.quotes.picks[0].text.includes('insane'), 'the spiciest line ranks first without AI');assert.strictEqual(card.quotes._fallback, true);-- assert.deepStrictEqual(card.recent_battles, []);-- assert.strictEqual(card.rivalry, null);++ assert.deepStrictEqual(card.beefs, []);++ assert.strictEqual(card.active_topic, null);++ assert.strictEqual(card.level.level, 1);const off = await arena.getFighter('idle');assert.strictEqual(off.not_on_roster, true);console.log('✅ fighter card + transcript quotes');@@@ -149,48 +135,14 @@ console.log('✅ roster from real stream/analytics/transcript rows, inactive strassert.strictEqual(arena.getStatDetail(u1, 'nope'), null);console.log('✅ stat drill-down');-- const b1 = await arena.getBattle('alpha', 'bravo');-- const b2 = await arena.getBattle('bravo', 'alpha');-- assert.strictEqual(b1.id, b2.id, 'a-vs-b and b-vs-a are the same battle');-- assert.strictEqual(b1.rounds.length, 5);-- assert.ok(b1.commentary.intro && b1.commentary.rounds.length === 5, 'templated commentary without AI');-- assert.strictEqual(b1.commentary_is_fallback, true);-- assert.strictEqual(b1.votes.a + b1.votes.b, 0);-- assert.ok(b1.a.walkout && b1.a.walkout.vod_id === 901, 'walkout line comes from the transcript');-- assert.strictEqual(b1.a.quotes, undefined, 'full quote list is not shipped with the battle');-- assert.deepStrictEqual(b1.history, { fights: 1, a_wins: b1.outcome.winner === 'a' ? 1 : 0, b_wins: b1.outcome.winner === 'b' ? 1 : 0 });---- const v1 = arena.castVote(b1.id, 'user:99', 'b');-- assert.deepStrictEqual(v1.votes, { a: 0, b: 1 });-- 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 + head-to-head history');---- const rec = arena.listFighters().find(f => f.user.username === after.winner.username);-- assert.strictEqual(rec.record.wins, 1, 'winner gets the W on the leaderboard');-- const profile = await arena.getFighter('alpha', { generate: false });-- assert.strictEqual(profile.recent_battles.length, 1);-- assert.strictEqual(profile.recent_battles[0].opponent.username, 'bravo');-- const me = await arena.getMainEvent({ generate: false });-- assert.ok(me && me.a && me.b && me.a.user.id !== me.b.user.id, 'main event pairs two different top fighters');-- const me2 = await arena.getMainEvent({ generate: false });-- assert.strictEqual(me.id, me2.id, 'main event is the same all day');-- const live = arena.getLiveMatchups();-- assert.strictEqual(live.live_count, 0);++ assert.deepStrictEqual(arena.liveFighters(), [], 'nobody live');const st = arena.status();assert.strictEqual(st.roster, 3);assert.strictEqual(st.with_voice_data, 1);-- assert.ok(st.battles >= 1);-- assert.strictEqual(st.votes, 2);-- assert.strictEqual(st.rounds.length, 5);-- console.log('✅ records, recent fights, main event, live matchups, status');++ assert.strictEqual(st.beefs_open, 0);++ assert.strictEqual(st.listener, 15000, 'listener tick advertised');++ console.log('✅ live fighters + status');-- console.log('\n✅ All Arena tests passed');++ console.log('\n✅ All Arena roster tests passed');process.exit(0);})().catch(err => { console.error(err); process.exit(1); });
@@ -1,94 +1,92 @@
@@ -1,94 +1,92 @@
-# Arena — streamer vs streamer
+# Arena — beefs, the board, and the ears
-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.
+The **Arena** tab (`/arena`) is where the platform's culture gets weaponised into entertainment. Nobody clicks "fight". Everything runs off what streamers *say* on mic (the continuous audio transcription) and what viewers *do* in chat:
-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).
+- **Beefs** — a streamer says another fighter's name while talking shit → a beef opens by itself, the target goes on the clock, silence is a forfeit.
+- **The board** — topics, debates, phrase challenges and bounties that come from chat (`!topic`, `!bounty`) and from the **pulse** (the AI reads global chat, transcripts and the AI timeline every 30 min and writes what the community is on about). Streamers click a topic, talk on it, clear its angles → XP → **Trash Level**.
+- **Fighters** — the roster is every streamer active in the last 45 days, rated on 7 stats (HYPE, GRIND, CHAT, LOYALTY, CLUTCH, VIBE, **MIC**) as percentiles across the roster, plus an AI persona and portrait. Recent Trash Level XP and beef wins add a *mouth bonus* (≤ +12) to POWER.
-## What each streamer gets
+With AI off everything still works: template headlines, fallback angles, keyword judges.
-| Piece | Source | Cost |
-|---|---|---|
-| **Ratings** — HYPE, GRIND, CHAT, LOYALTY, CLUTCH, VIBE, **MIC** (40–99) and overall **POWER** | `streams` + `stream_analytics` (last 90 days), `follows`, `transactions` (tips). **MIC** comes from the continuous audio transcription (`stream_timeline_events`): share of stream time spent talking, words per minute, hype words per hour, laughs per hour (sound events). Each rating is the streamer's *percentile* on that metric across the active roster (streamed in the last 45 days), so a 99 means "best on the site", never an absolute number. Streamers without transcript data tie at the MIC floor. | none |
-| **Quotes** — "things they actually said", each with a ▶ that jumps to the VOD at that second, plus a walkout line, mic style and a one-line voice verdict | Transcript lines (25–220 chars, last 90 days) pre-scored for hype/punctuation, then the AI picks the most quotable and harmless ones. Heuristic pick when AI is off. Cached 24 h. Needs ≥ 20 lines. | ~1 LLM call / streamer / day |
-| **Persona** — fighter name, title, class, element, signature move, special, weakness, taunt, lore, catchphrase, entrance music, a quip per stat | AI (`summary` role) from the streamer's AI overview, stream memories, chat-AI notes, recent VOD titles/overviews and the numbers. Cached 24 h. | ~1 LLM call / streamer / day |
-| **Portrait** — stylised character-select art | AI image model (OpenAI-compatible `/images/generations`). The prompt is built from the persona, category, profile colour and a *scene* description of the latest thumbnail (setting, objects, mood — the vision model is told never to describe faces or identity). It is deliberately **not a likeness** of the person. Cached 7 days. | 1 image / streamer / week, only when enabled |
-| **Record** — W–L | `arena_battles` | none |
+## Speech policy
-## Battles
+The platform does not censor language. Offensive, provocative, taboo words — slurs included — are allowed, are never filtered, and are never a reason for the judge to score low. The only lines that don't count (`server/arena/arena-service.js` `isBannedText`, plus the judge's `flagged`) are **behaviour**: direct threats of violence (`kys`, "I'll kill you"), sexual content about minors, and doxxing (addresses, phone numbers). Those lines are dropped from quotes, feeds and scoring; nothing else is.
-`/arena/battle/<a>/<b>` — the same pair on the same day always gets the same five rounds (Hype Check, Chat War, Endurance, Clutch Time, **Mic Drop**), simulated from the ratings with a seeded random so the underdog sometimes lands an upset. The **crowd vote** is the fifth round: one vote per user (per IP when anonymous), changeable until midnight UTC, and it breaks 2–2 ties (Power breaks a tie with no votes). The announcer commentary is AI (`chat` role, cached per battle; it gets both walkout lines) with a templated fallback. Every round line expands to show the ratings and rolls behind it; **Tale of the tape** compares the raw numbers; the all-time head-to-head record is shown above the commentary.
+## Beefs (`server/arena/beef.js`)
-**Main event** — one featured battle per day, seeded from the top 8 of the ladder, pinned to the top of `/arena` with its own vote buttons.
+| Rule | Value |
+|---|---|
+| Opens when | the listener judges ≥ 20 words in the 45 s after a fighter's name was said as *aimed at them* (roast, callout, disrespect, brag over them) |
+| Clock | the other side must answer on their own stream within **15 min if live**, **24 h if offline** (an offline clock is tightened to 15 min the moment they go live) |
+| Answer | any judged hit from the side on the clock; every hit flips the clock to the other side |
+| Forfeit | clock runs out → the silent side loses |
+| Hard end | 24 h after opening → higher **total** wins (`total = Σ hit quality (0–10, judged) + crowd hype (max 10)`), equal = draw |
+| Upset | winner ranked ≥ 4 places below the loser (+20 XP) |
+| Rematch | same pair again → flagged, with the rivalry's record and **receipts** (best lines from earlier beefs) |
+| XP | +5 open · +quality per hit (×2 while a bounty is on the target) · +40 win |
+| Crowd | `!hype` / Hype button: one per person per side · `!side <name>`: pick who wins → **clout** when right |
-Live streamers are paired by neighbouring Power on the leaderboard ("Live battles"), with their live thumbnails, refreshed every 30 s.
+Headlines (open + result) are AI-written (`chat` role) with templates as fallback; the announcer's one-liner per hit comes from the judge. Streaks, upsets and the head-to-head history are shown on every beef card.
-## API
+## The board (`server/arena/board.js`)
-| Method | Path | Notes |
+| Kind | Comes from | Resolves |
|---|---|---|
|---|---|---|
-| GET | `/api/arena/fighters` | leaderboard |
-| GET | `/api/arena/fighters/:username` | full card incl. `voice`, `quotes`, `recent_battles`, `rivalry`; generates a stale persona/quotes synchronously and kicks off a portrait in the background (`image_pending`) |
-| GET | `/api/arena/fighters/:username/stat/:stat` | drill-down: per-stream series (last 14 streams), rank on that stat, top 3 of the ladder |
-| GET | `/api/arena/main-event` | today's featured battle |
-| POST | `/api/arena/fighters/:username/refresh` | admin — regenerate persona (+ portrait unless `{ "image": false }`) |
-| GET | `/api/arena/battle/:a/:b` | today's battle (created on first view); `?generate=0` skips AI |
-| POST | `/api/arena/battle/:a/:b/vote` | `{ "side": "a" \| "b" }` |
-| 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/`.
+| **topic** | chat (`!topic`), the page, the pulse | archived after 36 h idle; streamers clear its 3 AI-cut **angles** (brag / roast / bit) by talking; all 3 → *conquered* |
+| **debate** (A vs B) | the pulse (arguments exploding in chat / transcripts) | after 24 h: `talk×2 + chat picks` per side; MVP +30 XP; right picks earn clout |
+| **phrase** ("say X on stream") | the pulse (memes going round) | 6 h; no model needed — 15 XP first time it comes out of your mouth, 5 after |
+| **bounty** (on a fighter) | chat (`!bounty <user>`), the page, the pulse | 6 h; every beef hit on the target pays double; top collector +40 XP |
-## Interaction
+**Heat** = judged hits×3 + hype + side picks + fighters talking×4 in the last hour; ≥ 12 is **HOT** and the board sorts by it. The **pulse** (`refreshPulse`, every 30 min, `summary` role) reads: the global chat AI summary, `chat_timeline_events`, recent chat, last-hour transcripts, stream memories, streamer overviews, VOD overviews, open beefs and the ladder — and posts up to 6 new typed events with tabloid headlines plus a one-line "pulse" sentence shown at the top of `/arena`. Templates seed the board when AI is off.
-- Leaderboard rows expand in place (radar, quips, signature move, fight/profile buttons); every stat on a profile opens a sparkline of the last 14 streams with the roster's top 3; the portrait opens a lightbox that explains how it was painted (and shows the prompt).
-- 🔊 buttons read taunts, quotes and the announcer's call aloud with the browser's own speech synthesis (no server cost); ▶ on a quote jumps to the VOD at that moment so you hear the real thing.
-- Live matchups show the **hot mic** — the last line the transcription heard on each live stream.
+**Trash Level** = 1 + XP/50. XP: judged topic chunk (quality×0.8) · angle cleared +25 · topic conquered +60 · chat hype +2 · beef (above). The Trash Level ladder and the viewer **clout** ladder (right side picks, accuracy, streak) sit on `/arena`.
+## The ears (`server/arena/listener.js`)
-## Trash Talk (`/arena/talk`)
+Every 15 s, for every roster stream that is live **and** has a transcript line in the last 30 min:
-The part streamers and viewers play themselves.
+1. New `stream_timeline_events` speech lines are read. Every line is checked against open phrase challenges.
+2. A fighter's name (username, display name, fighter name; `@name`; underscores/dots spoken as spaces; longest match first, never yourself) opens a **mention buffer**; lines in the following 45 s join it. At ≥ 20 words the **beef judge** runs (`aimed_at_target`, `quality`, `best_line` verbatim, `about`, `announcer`, `flagged`) → `beef.recordHit`.
+3. Other lines pool for the **topic judge** against the streamer's active board topic (≥ 20 words, ≥ 30 s between calls): `angle_idx`, `quality`, `progress_gain` (0–60), `best_line`, `about`.
+4. One judge call per stream per tick; keyword heuristics when AI is off.
-- **Topic** — every 6 h the Arena posts a topic (AI-written from the main event / top of the ladder; templated when AI is off), e.g. *"Explain why your chat is the smartest chat on this site."*
-- **Entering** — a streamer on the roster enters once per topic, either from the **live mic** (press *I'm about to cook*, say it on stream, *Drop the mic* — the entry is what the transcription heard in that window, linked to the VOD second) or by **typing** (≤ 280 chars). Live mic needs the stream to be live with the timeline transcription running.
-- **Judge** — the AI scores **Spice, Wit, On-topic, Delivery** (0–10 each) with a verdict, a judge's note and a stamp (COOKED ≥ 42 · SOLID ≥ 30 · MID ≥ 18 · FLOP). Slurs/hate/threats are caught by the hard filter before the model, and the model can flag cruelty: a flagged entry scores 0 and its text is hidden from everyone but the author. Heuristic scoring when AI is off.
-- **Crowd** — viewers add the fifth score: `!hype` in the streamer's chat or the Hype button, one per person, `Crowd = min(10, hypers / 3)`, open for 24 h.
-- **Power** — total (/50) × 12 → a Trash Talk bonus on POWER (max +12), decaying linearly over 7 days; shown on the profile and included in the leaderboard order.
-- **Hall of Trash** — the ten best lines of the last 30 days.
+`/arena/live/<username>` shows exactly what the ears hear for a fighter: hot mic lines, the last judgements, active topic progress, open beefs and any bounty on them (auto-refreshes; a level-up flashes).
+## Pages
-### Live sessions (`/arena/talk/<username>`)
+`/arena` — pulse · on the mic now · open + settled beefs · the board (start a topic, pick debate sides, hype, "Talk on this") · Trash Level ladder · clout ladder · power ladder (rows expand) · rules.
+`/arena/beef/<id>` — tale of the tape, tug-of-war, live clock, ringside feed (every judged hit with ▶ to the VOD second and 🔊), receipts from earlier beefs, hype/side buttons.
+`/arena/topic/<id>` — angles, who's talking and their per-angle progress, best lines (▶), debate sides, bounty target, join/leave.
+`/arena/<username>` — stats drill-down, voice + quotes, Trash Level card, rivalries with receipts, beef history.
-The continuous version: a streamer who is live with the timeline transcription running presses **Start live session** on `/arena/talk`. From then on (`server/arena/talk-session.js`, ticker every 15 s):
+## Chat commands (`server/arena/arena-chat.js`)
-- New transcript lines are buffered against the **current topic**; once ≥ 25 new words have arrived and ≥ 30 s passed since the last call, the judge scores the chunk: *is this trash talk on the topic?*, Spice/Wit/On-topic/Delivery, a `progress_gain` (0–60 — how much of the topic that chunk covered), the best verbatim line, and a ≤ 10-word "what they talked about" tag.
-- **Progress** fills the topic bar; at 100 the topic is **cleared** → it becomes a regular Trash Talk entry (POWER bonus, Hall of Trash) and the **next topic is generated**, shaped by what was already cleared. Talk enough → the topic changes. Streamers can skip a topic or end the session.
-- **XP / Trash Level**: each judged chunk adds XP = quality × (trash talk ? 1 : 0.15); a level every 40 XP. Viewers add +2 XP and +4 % progress with `!hype` (one per person per topic).
-- The public console shows the live topic + bar, the latest judgement ("that was trash talk" / "not trash talk yet"), the best line so far (▶ VOD), the **hot mic** feed, "what they talked about" tags, topics cleared/skipped, and a level-up flash. Live sessions are listed on `/arena/talk` under "Talking trash right now".
-- Guard rails: one live session per streamer; ends when the stream ends, after 20 idle minutes, at 3 h, or at 300 judge calls; the slur filter voids chunks before the model sees them; lines shown on the console are filtered too.
+`!topic <text>` (1 per 10 min) · `!bounty <user>` (1 per hour) · `!hype` (the streamer's newest open beef, else their active topic) · `!side <name|a|b>` · `!beef` · `!board` · `!arena [user]`. One command per person every 4 s; replies are private system lines, milestones go to the room.
-API: `GET /api/arena/talk/sessions` · `GET /api/arena/talk/session/:user` · `POST /api/arena/talk/session/start|stop|skip` (auth) · `POST /api/arena/talk/session/:user/hype`.
+## API (`/api/arena`)
-Chat commands (`server/arena/arena-chat.js`, hooked into `ChatServer.handleBangCommand`): `!hype`, `!talk`, `!arena [user]`, `!vote a|b` (main event, signed-in), `!fight <user>`. Rate-limited to one per person every 4 s; replies are private system lines, hype milestones and call-outs are announced to the room.
-
-API: `GET /api/arena/talk` (board: topic, entries, hall, my entry, mic availability) · `POST /api/arena/talk/mic/start` · `GET /api/arena/talk/mic/feed` · `POST /api/arena/talk/submit {mode:'mic'|'text', text?}` · `POST /api/arena/talk/:id/hype`.
+| Method | Path | Notes |
+|---|---|---|
+| GET | `/status` · `/fighters` · `/fighters/:user` · `/fighters/:user/stat/:stat` · `/live` | roster, card (+ `beefs`, `rivalries`, `level`, `active_topic`), drill-down, live fighters (hot mic, active topic, open beefs) |
+| POST | `/fighters/:user/refresh` | admin — regenerate persona (+ portrait) |
+| GET | `/console/:user` | the ears: listener state, hot mic, active topic + my progress, open beefs, bounty |
+| GET | `/board` | `open` (by heat), `resolved`, `pulse`, `levels`, `clout` |
+| POST | `/board/topics {text}` · `/board/bounty {username}` | auth |
+| GET | `/board/topics/:id` | detail incl. per-member progress and best lines |
+| POST | `/board/topics/:id/join` · `/leave` (auth, roster) · `/side {side}` · `/hype {user_id}` | |
+| POST | `/pulse/refresh` | admin — force a pulse read |
+| GET | `/beefs` · `/beefs/:id` | |
+| POST | `/beefs/:id/hype {side}` · `/beefs/:id/side {side}` | one per person; anonymous by hashed IP |
+| GET | `/levels` · `/clout` | ladders |
+
+## Settings
-## Guard rails
+| Key | Default | Effect |
+|---|---|---|
+| `arena_enabled` | `true` | `false` → API 404s |
+| `ai_image_enabled` / `ai_image_model` / `ai_image_quality` / `ai_image_cost_usd` | `false` / `gpt-image-1` / `low` / `0.011` | portraits |
+| `arena_vote_salt` | `JWT_SECRET` | salt for anonymous voter hashing |
-- 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.
+Background job (`server/arena/arena-job.js`): personas/portraits every 20 min (bounded, budget-aware) · listener every 15 s · clocks + expiries every 60 s · pulse every 30 min.
-Tests: `node test/arena.test.js` (ratings, seeded battles, votes, roster on a temp DB — no AI needed).
+Tests: `node test/arena.test.js` (roster, ratings, filter, quotes) · `node test/arena-beef-board.test.js` (beefs, board, listener tick, chat commands, API) — both on a temp DB, no AI needed.
@@ -13946,62 +13946,8 @@ label.staff-inline-toggle.aiv-field { flex-direction:row; align-items:center; }
@@ -13946,62 +13946,8 @@ label.staff-inline-toggle.aiv-field { flex-direction:row; align-items:center; }
.arena-opponent strong { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.arena-opponent strong { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.arena-opponent small { color: var(--text-muted); }
.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); } }
@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; } }
@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; }
-}
-
/* ── Arena: expandable rows, stat drill-down, voice, main event, tape ── */
/* ── Arena: expandable rows, stat drill-down, voice, main event, tape ── */
.arena-row { cursor: pointer; }
.arena-row { cursor: pointer; }
.arena-row .arena-chevron { color: var(--text-muted); transition: transform 0.15s; margin-left: 6px; }
.arena-row .arena-chevron { color: var(--text-muted); transition: transform 0.15s; margin-left: 6px; }
@@ -14065,46 +14011,6 @@ label.staff-inline-toggle.aiv-field { flex-direction:row; align-items:center; }
@@ -14065,46 +14011,6 @@ label.staff-inline-toggle.aiv-field { flex-direction:row; align-items:center; }
.arena-lightbox-close { position: absolute; top: 16px; right: 20px; background: none; border: 0; color: #fff; font-size: 1.8rem; cursor: pointer; }
.arena-lightbox-close { position: absolute; top: 16px; right: 20px; background: none; border: 0; color: #fff; font-size: 1.8rem; cursor: pointer; }
@media (max-width: 720px) { .arena-lightbox-inner { grid-template-columns: 1fr; } }
@media (max-width: 720px) { .arena-lightbox-inner { grid-template-columns: 1fr; } }
-/* main event */
-.arena-main-event { margin: 4px 0 24px; padding: 16px 18px; border-radius: 16px; border: 1px solid var(--border); background: linear-gradient(120deg, color-mix(in srgb, var(--accent) 16%, var(--bg-card)), var(--bg-card) 60%, color-mix(in srgb, #ec4899 14%, var(--bg-card))); }
-.arena-main-event-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; margin-bottom: 10px; }
-.arena-main-event-head h2 { margin: 0; font-size: 1.1rem; display: flex; align-items: center; gap: 8px; }
-.arena-main-event-head h2 i { color: #f5c542; }
-.arena-main-event-body { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 14px; }
-.arena-me-side { display: flex; align-items: center; gap: 12px; min-width: 0; text-decoration: none; color: var(--text-primary); }
-.arena-me-side.is-b { flex-direction: row-reverse; text-align: right; }
-.arena-me-side strong { display: block; font-size: 1.05rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
-.arena-me-side span { font-size: 0.78rem; color: var(--text-secondary); }
-.arena-me-center { display: flex; flex-direction: column; align-items: center; gap: 6px; }
-.arena-me-score { font-size: 1.6rem; font-weight: 900; font-variant-numeric: tabular-nums; }
-.arena-me-vote { display: flex; gap: 6px; }
-.arena-me-vote button { padding: 6px 10px; border-radius: 8px; border: 1px solid var(--border); background: var(--bg-card); color: var(--text-primary); font-size: 0.8rem; cursor: pointer; }
-.arena-me-vote button.is-picked { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 20%, var(--bg-card)); }
-.arena-me-tally { font-size: 0.72rem; color: var(--text-muted); }
-.arena-me-intro { font-size: 0.88rem; color: var(--text-secondary); margin: 10px 0 0; font-style: italic; }
-
-/* battle extras */
-.arena-history { font-size: 0.8rem; color: var(--text-muted); }
-.arena-walkout { margin: 8px 0 0; font-size: 0.82rem; color: var(--text-secondary); display: flex; align-items: center; gap: 6px; justify-content: center; flex-wrap: wrap; }
-.arena-walkout q { font-style: italic; }
-.arena-line.is-expandable { cursor: pointer; }
-.arena-line-detail { display: none; margin-top: 6px; font-size: 0.78rem; color: var(--text-muted); }
-.arena-line.is-open .arena-line-detail { display: block; }
-.arena-tape { width: 100%; }
-.arena-tape summary { cursor: pointer; font-weight: 600; font-size: 0.9rem; padding: 6px 0; color: var(--text-secondary); }
-.arena-tape table { width: 100%; border-collapse: collapse; font-size: 0.82rem; font-variant-numeric: tabular-nums; }
-.arena-tape td, .arena-tape th { padding: 5px 6px; border-bottom: 1px solid var(--border); text-align: center; }
-.arena-tape th { color: var(--text-muted); font-weight: 500; font-size: 0.72rem; }
-.arena-tape td.is-better { color: var(--accent-light); font-weight: 700; }
-.arena-tape td.arena-tape-label { text-align: center; color: var(--text-muted); }
-@media (max-width: 900px) {
- .arena-row.is-open .arena-row-expand { grid-template-columns: 1fr; }
- .arena-main-event-body { grid-template-columns: 1fr; }
- .arena-me-side.is-b { flex-direction: row; text-align: left; }
-}
-#arena-stat-detail { grid-column: 1 / -1; }
-#arena-stat-detail:empty { display: none; }
-
/* ── AI timeline transcript: speech lines vs. sound strips ── */
/* ── AI timeline transcript: speech lines vs. sound strips ── */
.ai-tl-tr-bar { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; margin: 0 0 8px; padding-bottom: 8px; border-bottom: 1px solid var(--border, rgba(255,255,255,0.07)); }
.ai-tl-tr-bar { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; margin: 0 0 8px; padding-bottom: 8px; border-bottom: 1px solid var(--border, rgba(255,255,255,0.07)); }
.ai-tl-tr-filter { display: inline-flex; gap: 4px; }
.ai-tl-tr-filter { display: inline-flex; gap: 4px; }
@@ -14184,182 +14090,273 @@ label.staff-inline-toggle.aiv-field { flex-direction:row; align-items:center; }
@@ -14184,182 +14090,273 @@ label.staff-inline-toggle.aiv-field { flex-direction:row; align-items:center; }
}
}
@media (prefers-reduced-motion: reduce) { .hero-chart-svg .bar { animation: none; } .hero-chart, .hero-chart-overlay { animation: none; } }
@media (prefers-reduced-motion: reduce) { .hero-chart-svg .bar { animation: none; } .hero-chart, .hero-chart-overlay { animation: none; } }
-/* ── Arena: Trash Talk ── */
-.arena-talk-card { margin: 4px 0 24px; padding: 16px 18px; border-radius: 16px; border: 1px solid var(--border); background: linear-gradient(120deg, color-mix(in srgb, #f97316 14%, var(--bg-card)), var(--bg-card) 65%); }
-.arena-talk-card-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; }
-.arena-talk-card-head h2 { margin: 0; font-size: 1.1rem; display: flex; align-items: center; gap: 8px; }
-.arena-talk-card-head h2 i { color: #f97316; }
-.arena-talk-card-head h2 small { color: var(--text-muted); font-weight: 400; font-size: 0.8rem; }
-.arena-talk-topic { font-size: 1.15rem; font-weight: 600; margin: 10px 0 8px; line-height: 1.35; }
-.arena-talk-card-foot { display: flex; justify-content: space-between; gap: 10px; flex-wrap: wrap; font-size: 0.85rem; color: var(--text-secondary); }
-.arena-talk-card-foot code, .arena-talk-how code, .arena-talk-gate code, .arena-talk-hype code { background: var(--bg-tertiary); padding: 1px 5px; border-radius: 4px; font-size: 0.85em; }
-.arena-stamp { display: inline-block; padding: 2px 8px; border-radius: 6px; font-size: 0.7rem; font-weight: 900; letter-spacing: 0.08em; border: 1.5px solid currentColor; transform: rotate(-3deg); }
-.arena-stamp--cooked { color: #f97316; } .arena-stamp--solid { color: #22c55e; } .arena-stamp--mid { color: #eab308; } .arena-stamp--flop { color: #94a3b8; }
-.arena-stamp--big { font-size: 1.3rem; padding: 6px 14px; animation: arena-stamp-in 0.5s cubic-bezier(0.2, 1.4, 0.4, 1) both; animation-delay: 1.3s; }
@keyframes arena-stamp-in { from { transform: scale(2.2) rotate(-12deg); opacity: 0; } to { transform: scale(1) rotate(-3deg); opacity: 1; } }
@keyframes arena-stamp-in { from { transform: scale(2.2) rotate(-12deg); opacity: 0; } to { transform: scale(1) rotate(-3deg); opacity: 1; } }
-.arena-talk-hero { display: grid; grid-template-columns: 1.3fr 1fr; gap: 20px; margin-bottom: 20px; }
-.arena-talk-hero-main { padding: 22px; border-radius: 18px; border: 1px solid var(--border); background: radial-gradient(80% 60% at 0% 0%, color-mix(in srgb, #f97316 20%, transparent), transparent), var(--bg-card); }
-.arena-talk-kicker { font-size: 0.75rem; letter-spacing: 0.08em; text-transform: uppercase; color: #f97316; font-weight: 700; margin-bottom: 8px; }
-.arena-talk-kicker span { color: var(--text-muted); text-transform: none; letter-spacing: 0; }
-.arena-talk-topic-big { margin: 0; font-size: clamp(1.4rem, 3vw, 2rem); line-height: 1.25; }
-.arena-talk-hint { color: var(--text-secondary); margin: 12px 0 0; }
-.arena-talk-hint i { color: #eab308; }
-.arena-talk-how { padding: 16px 18px; border-radius: 18px; border: 1px solid var(--border); background: var(--bg-card); font-size: 0.88rem; }
-.arena-talk-how h3 { margin: 0 0 8px; font-size: 1rem; }
-.arena-talk-how ol { margin: 0 0 8px; padding-left: 20px; display: flex; flex-direction: column; gap: 6px; color: var(--text-secondary); }
-.arena-talk-how b { color: var(--text-primary); }
-.arena-talk-panel { margin-bottom: 24px; padding: 18px; border-radius: 18px; border: 2px solid color-mix(in srgb, #f97316 45%, var(--border)); background: var(--bg-card); }
-.arena-talk-panel-head { display: flex; justify-content: space-between; align-items: center; gap: 10px; margin-bottom: 12px; }
-.arena-talk-panel-head h2 { margin: 0; font-size: 1.1rem; display: flex; align-items: center; gap: 8px; } .arena-talk-panel-head h2 i { color: #f97316; }
-.arena-talk-modes { display: flex; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; }
-.arena-talk-mode { padding: 8px 14px; border-radius: 10px; border: 1px solid var(--border); background: var(--bg-tertiary); color: var(--text-secondary); cursor: pointer; font-weight: 600; }
-.arena-talk-mode.active { border-color: #f97316; color: var(--text-primary); background: color-mix(in srgb, #f97316 14%, var(--bg-tertiary)); }
-.arena-talk-mode:disabled { opacity: 0.5; cursor: not-allowed; }
-.arena-talk-mode small { font-weight: 400; color: var(--text-muted); }
-.arena-talk-panel textarea { width: 100%; min-height: 90px; padding: 12px; border-radius: 12px; border: 1px solid var(--border); background: var(--bg-input); color: var(--text-primary); font: inherit; resize: vertical; }
-.arena-talk-actions { display: flex; justify-content: space-between; align-items: center; gap: 10px; margin-top: 10px; flex-wrap: wrap; }
.arena-mic-status { display: flex; align-items: center; gap: 8px; font-weight: 600; color: var(--text-secondary); }
.arena-mic-status { display: flex; align-items: center; gap: 8px; font-weight: 600; color: var(--text-secondary); }
.arena-mic-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--text-muted); }
.arena-mic-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--text-muted); }
.arena-mic-status.is-live { color: #ef4444; } .arena-mic-status.is-live .arena-mic-dot { background: #ef4444; box-shadow: 0 0 0 4px rgba(239, 68, 68, 0.25); animation: arena-pulse 1.2s infinite; }
.arena-mic-status.is-live { color: #ef4444; } .arena-mic-status.is-live .arena-mic-dot { background: #ef4444; box-shadow: 0 0 0 4px rgba(239, 68, 68, 0.25); animation: arena-pulse 1.2s infinite; }
.arena-mic-feed { margin-top: 10px; min-height: 90px; max-height: 220px; overflow-y: auto; padding: 10px 12px; border-radius: 12px; background: var(--bg-secondary); border: 1px dashed var(--border); font-size: 0.9rem; }
.arena-mic-feed { margin-top: 10px; min-height: 90px; max-height: 220px; overflow-y: auto; padding: 10px 12px; border-radius: 12px; background: var(--bg-secondary); border: 1px dashed var(--border); font-size: 0.9rem; }
-.arena-mic-line { display: flex; gap: 10px; padding: 3px 0; animation: arena-in 0.3s; } .arena-mic-line span { color: var(--text-muted); font-variant-numeric: tabular-nums; font-size: 0.78rem; min-width: 38px; }
-.arena-talk-gate { padding: 14px 16px; border-radius: 12px; background: var(--bg-tertiary); border: 1px solid var(--border); margin-bottom: 20px; color: var(--text-secondary); }
-.arena-talk-gate.is-done { border-color: #22c55e; } .arena-talk-gate.is-error { border-color: var(--danger); color: var(--danger); }
-.arena-talk-list { display: flex; flex-direction: column; gap: 10px; }
-.arena-talk-entry { padding: 14px 16px; border-radius: 14px; background: var(--bg-card); border: 1px solid var(--border); transition: transform 0.1s; }
-.arena-talk-entry.is-mine { border-color: color-mix(in srgb, #f97316 50%, var(--border)); }
-.arena-talk-entry.is-flagged { opacity: 0.7; }
-.arena-talk-entry.is-hit { animation: arena-hit 0.5s; }
-.arena-talk-entry-head { display: flex; justify-content: space-between; align-items: center; gap: 10px; flex-wrap: wrap; }
-.arena-talk-who { display: flex; align-items: center; gap: 10px; color: var(--text-primary); text-decoration: none; min-width: 0; }
-.arena-talk-who span { display: flex; flex-direction: column; min-width: 0; } .arena-talk-who small { color: var(--text-muted); font-size: 0.75rem; } .arena-talk-who em { font-style: italic; }
-.arena-talk-total { display: flex; align-items: center; gap: 8px; } .arena-talk-total b { font-size: 1.5rem; color: var(--accent-light); } .arena-talk-total small { color: var(--text-muted); }
-.arena-talk-text { margin: 10px 0; padding: 10px 14px; border-left: 3px solid #f97316; background: var(--bg-tertiary); border-radius: 0 10px 10px 0; font-size: 1rem; line-height: 1.5; }
-.arena-talk-hear { margin-left: 8px; font-size: 0.8rem; color: var(--accent-light); text-decoration: none; }
-.arena-talk-void { color: var(--text-muted); font-style: italic; }
-.arena-talk-verdict { margin: 6px 0; font-weight: 600; } .arena-talk-verdict i { color: var(--accent); margin-right: 6px; }
-.arena-talk-note { margin: 4px 0; font-size: 0.85rem; color: var(--text-secondary); font-style: italic; }
-.arena-talk-scores { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 6px 16px; margin: 8px 0; }
-.arena-talk-score { display: grid; grid-template-columns: 86px 1fr 24px; align-items: center; gap: 8px; font-size: 0.8rem; }
-.arena-talk-score-label { color: var(--text-secondary); } .arena-talk-score-label i { width: 14px; color: #f97316; }
-.arena-talk-score-track { height: 8px; border-radius: 4px; background: var(--bg-tertiary); overflow: hidden; }
-.arena-talk-score-fill { display: block; height: 100%; background: linear-gradient(90deg, #f97316, #fbbf24); transform-origin: left; }
-.arena-talk-scores.is-animated .arena-talk-score-fill { animation: arena-fill 0.7s cubic-bezier(0.2, 0.8, 0.3, 1) both; animation-delay: calc(0.15s + var(--i, 0) * 0.22s); }
@keyframes arena-fill { from { transform: scaleX(0); } to { transform: scaleX(1); } }
@keyframes arena-fill { from { transform: scaleX(0); } to { transform: scaleX(1); } }
-.arena-talk-score b { text-align: right; font-variant-numeric: tabular-nums; }
-.arena-talk-hype { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-top: 8px; font-size: 0.82rem; color: var(--text-secondary); }
-.arena-talk-reveal { padding: 16px; border-radius: 14px; border: 1px solid #f97316; background: radial-gradient(70% 50% at 50% 0%, color-mix(in srgb, #f97316 18%, transparent), transparent), var(--bg-card); animation: arena-in 0.3s; }
-.arena-talk-reveal.is-flagged { border-color: var(--danger); }
-.arena-talk-reveal-head { display: flex; align-items: center; justify-content: center; gap: 18px; margin-bottom: 8px; }
-.arena-talk-reveal-total b { font-size: 3rem; font-weight: 900; color: var(--accent-light); font-variant-numeric: tabular-nums; } .arena-talk-reveal-total small { color: var(--text-muted); font-size: 1rem; }
.arena-talk-bonus { font-size: 0.85rem; font-weight: 700; color: #f97316; }
.arena-talk-bonus { font-size: 0.85rem; font-weight: 700; color: #f97316; }
-@media (max-width: 900px) { .arena-talk-hero { grid-template-columns: 1fr; } .arena-talk-score { grid-template-columns: 74px 1fr 22px; } }
-
-/* ── Arena: phone layout pass ── */
-@media (max-width: 900px) {
- .arena-row { grid-template-columns: 28px 56px minmax(0, 1fr) auto; gap: 10px; padding: 10px 12px; }
- .arena-row .arena-portrait-sm { width: 56px; height: 56px; }
- .arena-row-main strong { font-size: 0.95rem; white-space: normal; line-height: 1.2; }
- .arena-row-sub { white-space: normal; line-height: 1.3; }
- .arena-rank { font-size: 1rem; }
- .arena-row-stats { flex-direction: column; gap: 2px; align-items: flex-end; }
- .arena-power b { font-size: 1.3rem; }
- .arena-record { font-size: 0.75rem; }
- .arena-hero { padding: 16px 0 10px; } .arena-hero h1 { font-size: 1.5rem; }
- .arena-board-head { flex-direction: column; align-items: stretch; }
- .arena-board-head input { min-width: 0; width: 100%; }
- .arena-live-match { grid-template-columns: 1fr; gap: 8px; }
- .arena-live-b { flex-direction: row; text-align: left; }
- .arena-live-vs { justify-self: center; width: 44px; height: 44px; font-size: 0.8rem; }
- .arena-live-thumb { width: 80px; height: 45px; }
- .arena-main-event-body { gap: 10px; }
- .arena-me-side.is-b { flex-direction: row; text-align: left; }
- .arena-talk-card-foot { flex-direction: column; gap: 6px; }
- .arena-profile { padding: 14px; gap: 16px; }
- .arena-profile-portrait { flex-direction: column; }
- .arena-portrait-lg { max-width: 220px; }
- .arena-profile-name h1 { font-size: 1.5rem; }
- .arena-quips { grid-template-columns: 1fr; }
- .arena-numbers { grid-template-columns: repeat(2, 1fr); }
- .arena-side { padding: 12px; }
- .arena-portrait-md { max-width: 180px; }
- .arena-vs { font-size: 2.2rem; }
- .arena-battle-actions .btn { font-size: 0.85rem; padding: 8px 12px; }
- .arena-talk-hero-main { padding: 16px; }
- .arena-talk-scores { grid-template-columns: 1fr; }
- .arena-talk-entry-head { align-items: flex-start; }
- .arena-quote { grid-template-columns: 1fr; }
- .arena-voice-meters { grid-template-columns: repeat(2, 1fr); }
-}
-@media (max-width: 480px) {
- .arena-row { grid-template-columns: 22px 48px minmax(0, 1fr) auto; gap: 8px; padding: 8px 10px; }
- .arena-row .arena-portrait-sm { width: 48px; height: 48px; }
- .arena-row-main strong { font-size: 0.9rem; }
- .arena-row-sub { font-size: 0.74rem; }
- .arena-row-sub .arena-live-pill { margin-left: 4px; }
- .arena-numbers { grid-template-columns: repeat(2, 1fr); }
- .arena-hero-actions { width: 100%; }
- .arena-hero-actions .btn { width: 100%; justify-content: center; }
-}
-
-/* ── Arena: live trash-talk sessions ── */
-.arena-sessions-live { margin: 0 0 20px; }
-.arena-sessions-live h2 { font-size: 1.05rem; margin: 0 0 10px; display: flex; align-items: center; gap: 8px; }
-.arena-session-strip { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 10px; }
-.arena-session-chip { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 12px; background: var(--bg-card); border: 1px solid color-mix(in srgb, #ef4444 40%, var(--border)); color: var(--text-primary); text-decoration: none; }
-.arena-session-chip .arena-live-dot { flex-shrink: 0; }
-.arena-session-chip span { display: flex; flex-direction: column; min-width: 0; }
-.arena-session-chip strong { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
-.arena-session-chip small { color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
-.arena-session-chip .arena-session-lvl { margin-left: auto; font-weight: 800; color: #f97316; white-space: nowrap; }
-.arena-session { display: grid; grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr); gap: 16px; margin-bottom: 24px; }
-.arena-session-main, .arena-session-side { display: flex; flex-direction: column; gap: 12px; min-width: 0; }
-.arena-session-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; padding: 12px 16px; border-radius: 14px; background: var(--bg-card); border: 1px solid var(--border); }
-.arena-session-who { display: flex; align-items: center; gap: 10px; min-width: 0; }
-.arena-session-who strong { font-size: 1.05rem; }
-.arena-session-status { display: inline-flex; align-items: center; gap: 6px; font-size: 0.75rem; font-weight: 800; letter-spacing: 0.06em; text-transform: uppercase; color: #ef4444; }
-.arena-session-status.is-ended { color: var(--text-muted); }
-.arena-session-level { display: flex; align-items: center; gap: 12px; }
-.arena-session-level b { font-size: 1.6rem; color: #f97316; line-height: 1; }
-.arena-session-level small { display: block; font-size: 0.62rem; letter-spacing: 0.1em; color: var(--text-muted); }
.arena-xp { width: 140px; }
.arena-xp { width: 140px; }
.arena-xp-track { height: 8px; border-radius: 4px; background: var(--bg-tertiary); overflow: hidden; }
.arena-xp-track { height: 8px; border-radius: 4px; background: var(--bg-tertiary); overflow: hidden; }
.arena-xp-fill { display: block; height: 100%; background: linear-gradient(90deg, #f97316, #fbbf24); transition: width 0.6s cubic-bezier(0.2, 0.8, 0.3, 1); }
.arena-xp-fill { display: block; height: 100%; background: linear-gradient(90deg, #f97316, #fbbf24); transition: width 0.6s cubic-bezier(0.2, 0.8, 0.3, 1); }
.arena-xp-label { font-size: 0.7rem; color: var(--text-muted); margin-top: 3px; text-align: right; }
.arena-xp-label { font-size: 0.7rem; color: var(--text-muted); margin-top: 3px; text-align: right; }
-.arena-session-topic { padding: 18px 20px; border-radius: 16px; border: 2px solid color-mix(in srgb, #f97316 55%, var(--border)); background: radial-gradient(80% 60% at 0% 0%, color-mix(in srgb, #f97316 18%, transparent), transparent), var(--bg-card); position: relative; overflow: hidden; }
-.arena-session-topic.is-cleared-flash { animation: arena-cleared 0.9s ease-out; }
@keyframes arena-cleared { 0% { box-shadow: 0 0 0 0 rgba(249, 115, 22, 0.6); } 100% { box-shadow: 0 0 0 30px rgba(249, 115, 22, 0); } }
@keyframes arena-cleared { 0% { box-shadow: 0 0 0 0 rgba(249, 115, 22, 0.6); } 100% { box-shadow: 0 0 0 30px rgba(249, 115, 22, 0); } }
-.arena-session-topic-kicker { font-size: 0.72rem; letter-spacing: 0.1em; text-transform: uppercase; color: #f97316; font-weight: 800; margin-bottom: 6px; display: flex; justify-content: space-between; gap: 8px; }
-.arena-session-topic-kicker span { color: var(--text-muted); letter-spacing: 0; text-transform: none; font-weight: 500; }
-.arena-session-topic h2 { margin: 0 0 8px; font-size: clamp(1.15rem, 2.4vw, 1.6rem); line-height: 1.3; }
-.arena-session-hint { color: var(--text-secondary); font-size: 0.9rem; margin: 0 0 12px; }
.arena-progress { height: 14px; border-radius: 7px; background: var(--bg-tertiary); overflow: hidden; position: relative; }
.arena-progress { height: 14px; border-radius: 7px; background: var(--bg-tertiary); overflow: hidden; position: relative; }
.arena-progress-fill { display: block; height: 100%; background: linear-gradient(90deg, #f97316, #fbbf24 70%, #22c55e); transition: width 0.8s cubic-bezier(0.2, 0.8, 0.3, 1); box-shadow: 0 0 12px rgba(249, 115, 22, 0.5); }
.arena-progress-fill { display: block; height: 100%; background: linear-gradient(90deg, #f97316, #fbbf24 70%, #22c55e); transition: width 0.8s cubic-bezier(0.2, 0.8, 0.3, 1); box-shadow: 0 0 12px rgba(249, 115, 22, 0.5); }
.arena-progress-label { display: flex; justify-content: space-between; font-size: 0.75rem; color: var(--text-muted); margin-top: 5px; }
.arena-progress-label { display: flex; justify-content: space-between; font-size: 0.75rem; color: var(--text-muted); margin-top: 5px; }
-.arena-session-controls { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px; }
-.arena-session-verdict { margin-top: 10px; font-size: 0.85rem; color: var(--text-secondary); display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
-.arena-session-verdict .is-hit { color: #22c55e; font-weight: 700; } .arena-session-verdict .is-miss { color: var(--text-muted); font-weight: 700; }
-.arena-session-feed { padding: 12px 14px; border-radius: 14px; background: var(--bg-secondary); border: 1px dashed var(--border); min-height: 160px; max-height: 320px; overflow-y: auto; }
-.arena-session-feed-head { display: flex; align-items: center; justify-content: space-between; font-size: 0.75rem; color: var(--text-muted); margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.06em; }
-.arena-session-line { display: grid; grid-template-columns: 42px 1fr auto; gap: 8px; padding: 4px 0; font-size: 0.92rem; animation: arena-in 0.3s; align-items: baseline; }
-.arena-session-line span:first-child { color: var(--text-muted); font-variant-numeric: tabular-nums; font-size: 0.75rem; }
-.arena-session-line a { color: var(--text-muted); font-size: 0.75rem; text-decoration: none; } .arena-session-line a:hover { color: var(--accent-light); }
-.arena-session-line.is-new { color: var(--text-primary); }
-.arena-session-card { padding: 12px 14px; border-radius: 14px; background: var(--bg-card); border: 1px solid var(--border); }
-.arena-session-card h3 { margin: 0 0 8px; font-size: 0.95rem; display: flex; align-items: center; gap: 8px; } .arena-session-card h3 i { color: #f97316; }
-.arena-about { display: flex; flex-wrap: wrap; gap: 6px; }
-.arena-about span { font-size: 0.75rem; padding: 3px 9px; border-radius: 999px; background: var(--bg-tertiary); border: 1px solid var(--border); color: var(--text-secondary); animation: arena-in 0.3s; }
-.arena-about span.is-hit { border-color: color-mix(in srgb, #f97316 60%, var(--border)); color: var(--text-primary); }
-.arena-cleared-list { display: flex; flex-direction: column; gap: 8px; }
-.arena-cleared { padding: 10px 12px; border-radius: 10px; background: var(--bg-tertiary); border-left: 3px solid #22c55e; font-size: 0.85rem; }
-.arena-cleared.is-skipped { border-left-color: var(--text-muted); opacity: 0.75; }
-.arena-cleared b { display: block; margin-bottom: 2px; }
-.arena-cleared q { color: var(--text-secondary); font-style: italic; }
-.arena-cleared small { color: var(--text-muted); display: block; margin-top: 2px; }
-.arena-session-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
-.arena-session-stat { padding: 8px 10px; border-radius: 10px; background: var(--bg-tertiary); text-align: center; }
-.arena-session-stat b { display: block; font-size: 1.1rem; } .arena-session-stat span { font-size: 0.65rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; }
.arena-levelup { position: fixed; left: 50%; top: 22%; transform: translateX(-50%); z-index: 3500; padding: 14px 26px; border-radius: 16px; background: linear-gradient(135deg, #f97316, #fbbf24); color: #111; font-weight: 900; font-size: 1.4rem; letter-spacing: 0.06em; box-shadow: 0 20px 60px rgba(249, 115, 22, 0.45); animation: arena-levelup 2.2s ease-out forwards; pointer-events: none; }
.arena-levelup { position: fixed; left: 50%; top: 22%; transform: translateX(-50%); z-index: 3500; padding: 14px 26px; border-radius: 16px; background: linear-gradient(135deg, #f97316, #fbbf24); color: #111; font-weight: 900; font-size: 1.4rem; letter-spacing: 0.06em; box-shadow: 0 20px 60px rgba(249, 115, 22, 0.45); animation: arena-levelup 2.2s ease-out forwards; pointer-events: none; }
@keyframes arena-levelup { 0% { transform: translate(-50%, 20px) scale(0.6); opacity: 0; } 15% { transform: translate(-50%, 0) scale(1.08); opacity: 1; } 80% { opacity: 1; } 100% { transform: translate(-50%, -30px) scale(1); opacity: 0; } }
@keyframes arena-levelup { 0% { transform: translate(-50%, 20px) scale(0.6); opacity: 0; } 15% { transform: translate(-50%, 0) scale(1.08); opacity: 1; } 80% { opacity: 1; } 100% { transform: translate(-50%, -30px) scale(1); opacity: 0; } }
-@media (max-width: 900px) { .arena-session { grid-template-columns: 1fr; } .arena-session-stats { grid-template-columns: repeat(3, 1fr); } .arena-xp { width: 110px; } }
-@media (max-width: 480px) { .arena-hero-actions { flex-direction: column; align-items: stretch; } }
+
+/* ── Arena v2: pulse, on the mic, beefs, the board, ladders, consoles ── */
+.arena-lede { margin: 0; max-width: 720px; color: var(--text-secondary); font-size: 0.95rem; line-height: 1.5; }
+.arena-hero-actions .arena-note { display: inline-flex; align-items: center; gap: 6px; }
+.arena-pulse { margin: 4px 0 22px; }
+.arena-pulse-inner { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; padding: 12px 16px; border-radius: 14px; border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--border)); background: linear-gradient(90deg, color-mix(in srgb, var(--accent) 18%, var(--bg-card)), var(--bg-card)); }
+.arena-pulse-kicker { display: inline-flex; align-items: center; gap: 6px; font-weight: 800; letter-spacing: 0.08em; font-size: 0.72rem; text-transform: uppercase; color: var(--accent-light); }
+.arena-pulse-text { flex: 1; min-width: 240px; font-size: 1.02rem; font-weight: 600; }
+.arena-pulse-meta { font-size: 0.75rem; color: var(--text-muted); }
+.arena-tag { display: inline-flex; align-items: center; gap: 5px; padding: 3px 8px; border-radius: 999px; font-size: 0.72rem; font-weight: 700; background: var(--bg-tertiary); color: var(--text-secondary); text-decoration: none; white-space: nowrap; }
+a.arena-tag:hover { color: var(--accent-light); }
+.arena-tag-hot { background: rgba(239, 68, 68, 0.16); color: #f87171; }
+.arena-tag-gold { background: rgba(245, 197, 66, 0.16); color: #f5c542; }
+.arena-tag-dim { opacity: 0.7; font-weight: 600; }
+.arena-lvl { display: inline-flex; align-items: center; padding: 1px 7px; border-radius: 6px; font-size: 0.68rem; font-weight: 800; letter-spacing: 0.04em; background: linear-gradient(90deg, #f97316, #fbbf24); color: #1a1006; }
+.arena-lvl-big { font-size: 0.85rem; padding: 4px 10px; }
+.arena-chip { display: inline-flex; align-items: center; gap: 8px; color: var(--text-primary); text-decoration: none; min-width: 0; }
+.arena-chip > span { display: flex; flex-direction: column; min-width: 0; line-height: 1.2; }
+.arena-chip strong { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.arena-chip small { color: var(--text-muted); font-size: 0.74rem; display: flex; gap: 4px; align-items: center; flex-wrap: wrap; }
+.arena-play { display: inline-flex; align-items: center; gap: 4px; color: var(--accent-light); text-decoration: none; font-size: 0.8rem; }
+.arena-play:hover { text-decoration: underline; }
+.arena-live-dot-sm { width: 7px; height: 7px; display: inline-block; margin-right: 4px; }
+
+/* on the mic now */
+.arena-mic-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 12px; }
+.arena-mic-card { display: grid; grid-template-columns: 112px 1fr; gap: 12px; padding: 10px; border-radius: 14px; background: var(--bg-card); border: 1px solid rgba(231, 76, 60, 0.45); }
+.arena-mic-card.is-quiet { border-color: var(--border); }
+.arena-mic-thumb { display: block; }
+.arena-mic-thumb .arena-live-thumb { width: 112px; height: 63px; }
+.arena-mic-body { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
+.arena-mic-head { display: flex; flex-direction: column; gap: 2px; }
+.arena-mic-head a { color: var(--text-primary); text-decoration: none; }
+.arena-mic-meta { font-size: 0.75rem; color: var(--text-secondary); display: flex; gap: 4px; align-items: center; flex-wrap: wrap; }
+.arena-mic-line { font-size: 0.88rem; line-height: 1.35; quotes: "“" "”"; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
+.arena-mic-line-empty { color: var(--text-muted); font-style: italic; }
+.arena-mic-tags { display: flex; gap: 6px; flex-wrap: wrap; }
+
+/* beefs */
+.arena-beefs { margin: 8px 0 28px; }
+.arena-beefs h2, .arena-board-head h2, .arena-ladder h2, .arena-live h2 { display: flex; align-items: center; gap: 10px; font-size: 1.15rem; margin: 0 0 12px; }
+.arena-beefs h2 small, .arena-board-head h2 small, .arena-ladder h2 small { color: var(--text-muted); font-weight: 400; font-size: 0.82rem; }
+.arena-beef-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); gap: 12px; }
+.arena-beef { display: flex; flex-direction: column; gap: 10px; padding: 14px; border-radius: 16px; background: var(--bg-card); border: 1px solid var(--border); }
+.arena-beef.is-open { border-color: rgba(239, 68, 68, 0.45); box-shadow: 0 0 0 1px rgba(239, 68, 68, 0.12), 0 10px 30px rgba(0, 0, 0, 0.25); }
+.arena-beef.is-done { opacity: 0.85; }
+.arena-beef-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 10px; flex-wrap: wrap; }
+.arena-beef-head a { color: var(--text-primary); text-decoration: none; }
+.arena-beef-headline { font-weight: 800; font-size: 1.02rem; line-height: 1.3; }
+.arena-beef-tags { display: flex; gap: 6px; flex-wrap: wrap; }
+.arena-beef-sides { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 10px; }
+.arena-beef-side { display: flex; align-items: center; gap: 10px; color: var(--text-primary); text-decoration: none; min-width: 0; }
+.arena-beef-side > span { display: flex; flex-direction: column; min-width: 0; }
+.arena-beef-side strong { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.arena-beef-side small { color: var(--text-muted); font-size: 0.74rem; }
+.arena-beef-side-b { flex-direction: row-reverse; text-align: right; }
+.arena-beef-vs { font-weight: 900; font-size: 0.8rem; letter-spacing: 0.1em; color: var(--text-muted); }
+.arena-tug { display: flex; height: 22px; border-radius: 11px; overflow: hidden; background: var(--bg-tertiary); font-size: 0.7rem; font-weight: 800; }
+.arena-tug span { display: flex; align-items: center; padding: 0 8px; color: #fff; transition: width 0.6s cubic-bezier(0.2, 0.8, 0.2, 1); min-width: 0; overflow: hidden; }
+.arena-tug-a { background: linear-gradient(90deg, color-mix(in srgb, var(--fc) 80%, #000), var(--fc)); justify-content: flex-start; }
+.arena-tug-b { background: linear-gradient(90deg, var(--fc), color-mix(in srgb, var(--fc) 80%, #000)); justify-content: flex-end; }
+.arena-tug-big { height: 34px; font-size: 0.9rem; }
+.arena-beef-foot { display: flex; justify-content: space-between; align-items: center; gap: 10px; flex-wrap: wrap; }
+.arena-beef-actions { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
+.arena-clock { display: inline-flex; align-items: center; gap: 6px; font-size: 0.8rem; color: var(--text-secondary); font-variant-numeric: tabular-nums; }
+.arena-clock b { color: var(--text-primary); }
+.arena-clock.is-urgent { color: #f87171; } .arena-clock.is-urgent b { color: #f87171; animation: arena-blink 1s steps(2) infinite; }
+@keyframes arena-blink { 50% { opacity: 0.35; } }
+.arena-sides-tally { font-size: 0.78rem; color: var(--text-muted); display: inline-flex; gap: 5px; align-items: center; }
+.arena-beef-last { display: block; font-size: 0.85rem; color: var(--text-secondary); quotes: "“" "”"; border-left: 3px solid var(--border-light); padding-left: 10px; }
+.arena-beef-empty { display: flex; align-items: center; gap: 14px; padding: 18px; border-radius: 14px; border: 1px dashed var(--border); color: var(--text-secondary); }
+.arena-beef-empty i { font-size: 1.6rem; color: var(--accent); }
+.arena-beef-empty p { margin: 0; }
+.arena-done { margin-top: 12px; }
+.arena-done summary { cursor: pointer; color: var(--text-secondary); font-weight: 600; margin-bottom: 10px; }
+.arena-done summary small { color: var(--text-muted); font-weight: 400; }
+.btn.is-hyped { color: #f87171; border-color: rgba(239, 68, 68, 0.5); }
+
+/* the board */
+.arena-board { margin: 8px 0 28px; }
+.arena-topic-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 12px; }
+.arena-topic { display: flex; flex-direction: column; gap: 8px; padding: 14px; border-radius: 16px; background: var(--bg-card); border: 1px solid var(--border); position: relative; }
+.arena-topic.is-hot { border-color: rgba(249, 115, 22, 0.55); box-shadow: 0 0 0 1px rgba(249, 115, 22, 0.12); }
+.arena-topic-debate { border-left: 4px solid #60a5fa; }
+.arena-topic-phrase { border-left: 4px solid #a78bfa; }
+.arena-topic-bounty { border-left: 4px solid #f5c542; }
+.arena-topic-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 0.75rem; color: var(--text-muted); }
+.arena-kind { display: inline-flex; align-items: center; gap: 5px; font-weight: 800; letter-spacing: 0.06em; text-transform: uppercase; font-size: 0.68rem; color: var(--accent-light); }
+.arena-heat { display: inline-flex; align-items: center; gap: 5px; font-weight: 700; color: var(--text-secondary); }
+.arena-heat.is-hot { color: #f97316; }
+.arena-heat-bar { width: 46px; height: 5px; border-radius: 3px; background: var(--bg-tertiary); overflow: hidden; display: inline-block; }
+.arena-heat-bar span { display: block; height: 100%; background: linear-gradient(90deg, #f97316, #fbbf24); }
+.arena-topic-by { margin-left: auto; }
+.arena-topic-headline { font-weight: 800; font-size: 0.82rem; letter-spacing: 0.02em; color: #fbbf24; text-transform: uppercase; }
+.arena-topic-link { color: var(--text-primary); text-decoration: none; }
+.arena-topic-link:hover .arena-topic-text { color: var(--accent-light); }
+.arena-topic-text { font-size: 1.05rem; font-weight: 700; line-height: 1.35; }
+.arena-topic-text b { font-weight: 900; }
+.arena-debate-title em { font-style: italic; color: var(--text-muted); font-weight: 600; padding: 0 4px; }
+.arena-topic-hint { margin: 0; font-size: 0.85rem; color: var(--text-secondary); }
+.arena-topic-foot { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: auto; }
+.arena-topic-who { display: flex; align-items: center; gap: 4px; }
+.arena-topic-who a { display: inline-flex; } .arena-topic-who a.is-dim { opacity: 0.55; }
+.arena-topic-stats { font-size: 0.75rem; color: var(--text-muted); }
+.arena-topic-actions { margin-left: auto; display: flex; gap: 6px; flex-wrap: wrap; }
+.arena-debate { display: grid; grid-template-columns: 1fr auto 1fr; gap: 8px; align-items: center; }
+.arena-debate .btn { white-space: normal; }
+.arena-debate-tally { display: flex; flex-direction: column; align-items: center; gap: 3px; min-width: 90px; }
+.arena-debate-bar { width: 90px; height: 6px; border-radius: 3px; background: #e74c3c; overflow: hidden; display: block; }
+.arena-debate-bar span { display: block; height: 100%; background: #60a5fa; }
+.arena-debate-nums { font-size: 0.72rem; color: var(--text-muted); }
+.arena-debate-big { padding: 14px; border-radius: 14px; background: var(--bg-card); border: 1px solid var(--border); }
+.arena-debate-big .arena-note { grid-column: 1 / -1; margin: 6px 0 0; }
+.arena-resolved { display: flex; flex-direction: column; gap: 6px; }
+.arena-resolved-row { display: flex; justify-content: space-between; gap: 10px; padding: 8px 12px; border-radius: 10px; background: var(--bg-card); border: 1px solid var(--border); font-size: 0.88rem; flex-wrap: wrap; }
+.arena-resolved-row a { color: var(--text-primary); text-decoration: none; }
+.arena-composer { display: flex; flex-direction: column; gap: 10px; max-width: 560px; }
+.arena-composer textarea { width: 100%; padding: 10px 12px; border-radius: 10px; border: 1px solid var(--border); background: var(--bg-input); color: var(--text-primary); font: inherit; resize: vertical; }
+.arena-composer-actions { display: flex; justify-content: flex-end; }
+
+/* ladders */
+.arena-ladders { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin: 0 0 28px; }
+.arena-ladder { padding: 14px; border-radius: 16px; background: var(--bg-card); border: 1px solid var(--border); }
+.arena-mini-list { display: flex; flex-direction: column; gap: 6px; }
+.arena-mini-row { display: grid; grid-template-columns: 28px 1fr auto; align-items: center; gap: 10px; padding: 6px 8px; border-radius: 10px; background: var(--bg-tertiary); }
+.arena-mini-row .arena-rank { font-size: 1rem; }
+.arena-rules { margin: 8px 0 30px; padding: 16px 18px; border-radius: 16px; background: var(--bg-card); border: 1px solid var(--border); }
+.arena-rules h3 { margin: 0 0 8px; font-size: 1rem; }
+.arena-rules ul, .arena-rules-list { margin: 0; padding-left: 18px; font-size: 0.86rem; color: var(--text-secondary); line-height: 1.5; }
+.arena-rules code { font-size: 0.8em; background: var(--bg-tertiary); padding: 1px 5px; border-radius: 4px; }
+
+/* beef page */
+.arena-beef-page { display: flex; flex-direction: column; gap: 16px; }
+.arena-beef-hero h1 { margin: 6px 0; font-size: 1.7rem; line-height: 1.2; display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
+.arena-beef-opener { margin: 0; color: var(--text-secondary); font-size: 0.92rem; }
+.arena-beef-opener q { quotes: "“" "”"; }
+.arena-beef-tape { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
+.arena-beef-tape-side { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 16px; border-radius: 16px; background: var(--bg-card); border: 1px solid var(--border); text-align: center; }
+.arena-beef-tape-side .arena-portrait-md { max-width: 180px; }
+.arena-beef-tape-side h2 { margin: 0; font-size: 1.3rem; }
+.arena-beef-tape-meta { font-size: 0.8rem; color: var(--text-secondary); display: flex; gap: 4px; align-items: center; }
+.arena-beef-tape-nums { display: flex; gap: 14px; }
+.arena-beef-tape-nums span { display: flex; flex-direction: column; align-items: center; line-height: 1.1; }
+.arena-beef-tape-nums b { font-size: 1.3rem; } .arena-beef-tape-nums small { font-size: 0.65rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.06em; }
+.arena-beef-tape-actions { display: flex; gap: 6px; flex-wrap: wrap; justify-content: center; }
+.arena-beef-status { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; font-size: 0.85rem; }
+.arena-beef-cols { display: grid; grid-template-columns: 1.4fr 1fr; gap: 16px; }
+.arena-feed, .arena-beef-aside section, .arena-console-aside section, .arena-console-main { padding: 14px; border-radius: 16px; background: var(--bg-card); border: 1px solid var(--border); }
+.arena-beef-aside, .arena-console-aside { display: flex; flex-direction: column; gap: 12px; }
+.arena-feed h3, .arena-beef-aside h3, .arena-console-aside h3, .arena-console-main h3, .arena-angles h3, .arena-members h3, .arena-best-lines h3 { margin: 0 0 10px; font-size: 0.98rem; display: flex; align-items: center; gap: 8px; }
+.arena-feed h3 small, .arena-beef-aside h3 small, .arena-console-main h3 small, .arena-angles h3 small, .arena-members h3 small, .arena-best-lines h3 small { color: var(--text-muted); font-weight: 400; font-size: 0.78rem; }
+.arena-feed-line { display: flex; gap: 10px; padding: 8px 0; border-top: 1px solid var(--border); }
+.arena-feed-line:first-of-type { border-top: 0; }
+.arena-feed-line.is-b { flex-direction: row-reverse; text-align: right; }
+.arena-feed-line.is-system { justify-content: center; text-align: center; color: var(--text-muted); font-size: 0.82rem; }
+.arena-feed-body { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
+.arena-feed-body q { font-size: 0.95rem; quotes: "“" "”"; }
+.arena-feed-body small { color: var(--text-muted); font-size: 0.74rem; display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
+.arena-feed-line.is-b .arena-feed-body small { justify-content: flex-end; }
+.arena-feed-announcer { font-weight: 700; color: #fbbf24; font-size: 0.85rem; }
+.arena-receipt { padding: 8px 0; border-top: 1px solid var(--border); }
+.arena-receipt q { quotes: "“" "”"; font-size: 0.9rem; }
+.arena-receipt small { display: block; color: var(--text-muted); font-size: 0.74rem; margin-top: 2px; }
+.arena-receipt small a { color: var(--accent-light); }
+
+/* topic page */
+.arena-topic-page { display: flex; flex-direction: column; gap: 12px; padding: 18px; border-radius: 18px; background: var(--bg-card); border: 1px solid var(--border); }
+.arena-topic-page h1.arena-topic-text { margin: 0; font-size: 1.6rem; }
+.arena-topic-result { padding: 10px 14px; border-radius: 12px; background: rgba(245, 197, 66, 0.12); color: #f5c542; font-weight: 700; }
+.arena-bounty-target { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; padding: 10px 12px; border-radius: 12px; background: rgba(245, 197, 66, 0.08); }
+.arena-topic-cta { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
+.arena-angle-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 10px; }
+.arena-angle { display: flex; flex-direction: column; gap: 3px; padding: 10px 12px; border-radius: 12px; background: var(--bg-tertiary); font-size: 0.88rem; }
+.arena-angle span { color: var(--text-muted); font-size: 0.78rem; }
+.arena-member { display: grid; grid-template-columns: minmax(180px, 1fr) 1.2fr auto; align-items: center; gap: 12px; padding: 8px 0; border-top: 1px solid var(--border); }
+.arena-member.is-active .arena-chip strong { color: #f87171; }
+.arena-member-progress { display: flex; gap: 6px; }
+.arena-member-progress .arena-progress { flex: 1; height: 10px; }
+.arena-progress.is-cleared .arena-progress-fill { background: #22c55e; }
+.arena-angle-list { display: flex; flex-direction: column; gap: 6px; margin-top: 8px; }
+.arena-angle-row { display: grid; grid-template-columns: 70px 1fr; gap: 8px; align-items: center; font-size: 0.82rem; }
+.arena-angle-row .arena-progress { height: 8px; }
+.arena-angle-row.is-cleared { color: #22c55e; }
+.arena-angle-row small { color: var(--text-muted); }
+.arena-rivalries { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 10px; }
+.arena-rivalry { display: flex; flex-direction: column; gap: 6px; padding: 10px 12px; border-radius: 12px; background: var(--bg-card); border: 1px solid var(--border); }
+.arena-rivalry.is-open { border-color: rgba(239, 68, 68, 0.45); }
+.arena-receipt-mini { display: block; font-size: 0.8rem; color: var(--text-secondary); quotes: "“" "”"; }
+.arena-level-card { display: flex; flex-direction: column; gap: 8px; padding: 12px 14px; border-radius: 14px; background: var(--bg-tertiary); }
+.arena-level-head { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
+.arena-level-nums { display: flex; gap: 18px; flex-wrap: wrap; }
+.arena-level-nums span { display: flex; flex-direction: column; line-height: 1.1; }
+.arena-level-nums b { font-size: 1.1rem; } .arena-level-nums small { font-size: 0.66rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.06em; }
+
+/* the ears (live console) */
+.arena-console { display: flex; flex-direction: column; gap: 14px; }
+.arena-console-head { display: grid; grid-template-columns: auto 1fr auto; gap: 14px; align-items: center; padding: 14px; border-radius: 16px; background: var(--bg-card); border: 1px solid var(--border); }
+.arena-console-head h1 { margin: 0 0 4px; font-size: 1.4rem; display: flex; align-items: center; gap: 8px; }
+.arena-console-status { font-size: 0.85rem; color: var(--text-secondary); display: flex; align-items: center; gap: 8px; }
+.arena-console-status.is-on { color: #4ade80; }
+.arena-console-level { display: flex; flex-direction: column; align-items: flex-end; gap: 4px; min-width: 200px; }
+.arena-console-level .arena-xp-track { width: 100%; }
+.arena-console-level small { font-size: 0.72rem; color: var(--text-muted); }
+.arena-console-cols { display: grid; grid-template-columns: 1.4fr 1fr; gap: 14px; }
+.arena-mic-line-row { display: grid; grid-template-columns: 52px 1fr auto; gap: 8px; align-items: baseline; padding: 4px 0; font-size: 0.9rem; border-top: 1px solid var(--border); }
+.arena-mic-line-row:first-child { border-top: 0; }
+.arena-mic-time { font-size: 0.72rem; color: var(--text-muted); font-variant-numeric: tabular-nums; }
+.arena-judgement { margin-top: 10px; padding: 10px 12px; border-radius: 12px; background: var(--bg-tertiary); font-size: 0.85rem; color: var(--text-secondary); border-left: 3px solid var(--border-light); }
+.arena-judgement.is-hit { border-left-color: #f97316; color: var(--text-primary); }
+.arena-console-beef { display: flex; flex-direction: column; gap: 6px; padding: 8px 0; border-top: 1px solid var(--border); }
+.arena-console-beef a { color: var(--text-primary); text-decoration: none; }
+.arena-console-beef .arena-tug { height: 14px; font-size: 0.6rem; }
+.arena-expires { font-variant-numeric: tabular-nums; }
+
+@media (max-width: 900px) {
+ .arena-ladders, .arena-beef-cols, .arena-console-cols, .arena-beef-tape { grid-template-columns: 1fr; }
+ .arena-console-head { grid-template-columns: auto 1fr; }
+ .arena-console-level { grid-column: 1 / -1; align-items: stretch; }
+ .arena-member { grid-template-columns: 1fr; gap: 6px; }
+}
+@media (max-width: 600px) {
+ .arena-hero { padding: 14px 0 10px; } .arena-hero h1 { font-size: 1.5rem; }
+ .arena-live h2, .arena-beefs h2, .arena-board-head h2, .arena-ladder h2 { flex-wrap: wrap; } .arena-live h2 small, .arena-board-head h2 small { flex-basis: 100%; }
+ .arena-board-head { flex-direction: column; align-items: flex-start; }
+ .arena-hero-actions { flex-direction: column; align-items: stretch; }
+ .arena-pulse-inner { padding: 10px 12px; }
+ .arena-mic-grid, .arena-beef-grid, .arena-topic-grid { grid-template-columns: 1fr; }
+ .arena-mic-card { grid-template-columns: 1fr; }
+ .arena-mic-thumb .arena-live-thumb { width: 100%; height: auto; aspect-ratio: 16 / 9; }
+ .arena-beef-sides { grid-template-columns: 1fr; }
+ .arena-beef-side-b { flex-direction: row; text-align: left; }
+ .arena-beef-vs { display: none; }
+ .arena-beef-foot, .arena-topic-foot { flex-direction: column; align-items: flex-start; }
+ .arena-topic-actions { margin-left: 0; }
+ .arena-debate { grid-template-columns: 1fr; }
+ .arena-debate-tally { flex-direction: row; justify-content: center; }
+ .arena-row { grid-template-columns: 28px 44px 1fr; grid-auto-rows: auto; gap: 8px 10px; padding: 10px; }
+ .arena-row .arena-portrait-sm { width: 44px; height: 44px; font-size: 1.1rem; }
+ .arena-row-stats { grid-column: 1 / -1; justify-content: space-between; }
+ .arena-row-expand { grid-column: 1 / -1; }
+ .arena-row-expand-actions { flex-wrap: wrap; }
+ .arena-profile { grid-template-columns: 1fr; padding: 14px; }
+ .arena-profile-stats { grid-template-columns: 1fr; }
+ .arena-numbers { grid-template-columns: repeat(2, 1fr); }
+ .arena-quips { grid-template-columns: 1fr; }
+ .arena-beef-hero h1 { font-size: 1.25rem; }
+ .arena-topic-page { padding: 14px; } .arena-topic-page h1.arena-topic-text { font-size: 1.25rem; }
+ .arena-level-nums { gap: 12px; }
+ .arena-lightbox-inner { padding: 14px; }
+}
+
+.arena-leaderboard { margin: 0 0 28px; }
+.arena-lore { display: flex; flex-direction: column; gap: 10px; }
@@ -62,7 +62,7 @@
@@ -62,7 +62,7 @@
</script>
</script>
<!-- Stylesheets -->
<!-- Stylesheets -->
- <link rel="stylesheet" href="/css/style.css?v=145">
+ <link rel="stylesheet" href="/css/style.css?v=146">
<link rel="stylesheet" href="/css/chat-shared.css?v=1">
<link rel="stylesheet" href="/css/chat-shared.css?v=1">
<link rel="stylesheet" href="/css/broadcast.css?v=11">
<link rel="stylesheet" href="/css/broadcast.css?v=11">
<link rel="stylesheet" href="/css/cosmetics.css?v=2">
<link rel="stylesheet" href="/css/cosmetics.css?v=2">
@@ -5107,7 +5107,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
@@ -5107,7 +5107,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<script src="/js/staff-console.js?v=1" defer></script>
<script src="/js/staff-console.js?v=1" defer></script>
<script src="/js/dashboard-moderation.js?v=5" defer></script>
<script src="/js/dashboard-moderation.js?v=5" defer></script>
<script src="/js/ai-viewers.js?v=2" defer></script>
<script src="/js/ai-viewers.js?v=2" defer></script>
-<script src="/js/arena.js?v=4" defer></script>
+<script src="/js/arena.js?v=5" defer></script>
<script src="/js/powerchat.js?v=10" defer></script>
<script src="/js/powerchat.js?v=10" defer></script>
<script src="/js/pastes.js?v=15" defer></script>
<script src="/js/pastes.js?v=15" defer></script>
<script src="/js/draggable-fab.js?v=2" defer></script>
<script src="/js/draggable-fab.js?v=2" defer></script>
@@ -1,22 +1,30 @@
@@ -1,22 +1,30 @@
/**
/**
- * OpenVibe.Live — Arena tab (streamer vs streamer)
+ * OpenVibe.Live — Arena tab (speech-driven beefs + the community shit-talking board)
*
*
* Routes (handled from app.js):
* Routes (handled from app.js):
- * /arena main event + live matchups + leaderboard (rows expand)
- * /arena/<username> fighter profile (stats drill down, voice + quotes, portrait lightbox)
- * /arena/battle/<a>/<b> head-to-head battle (rounds, tale of the tape, walkouts, crowd vote)
+ * /arena pulse · on the mic now · open beefs · the board · ladders
+ * /arena/beef/<id> one beef: tug-of-war, clock, announcer feed, receipts, sides
+ * /arena/topic/<id> one board event: angles, progress, best lines, sides/bounty/phrase
+ * /arena/live/<username> the ears: what the listener hears from a live fighter (auto-refresh)
+ * /arena/<username> fighter profile (stats drill down, voice + quotes, level, beefs, rivalries)
*
*
- * Talks to /api/arena/* (server/arena). Everything renders with escaped template strings —
- * persona/quote text is AI-written or transcribed and must never be trusted as HTML.
+ * Nothing here starts a fight. Beefs open when a streamer says another fighter's name on mic
+ * while talking shit (server/arena/listener.js). Everything renders with escaped template
+ * strings — persona/quote/headline text is AI-written or transcribed and is never trusted as HTML.
*/
*/
'use strict';
'use strict';
const ARENA_STATS = ['hype', 'grind', 'chat', 'loyalty', 'clutch', 'vibe', 'mic'];
const ARENA_STATS = ['hype', 'grind', 'chat', 'loyalty', 'clutch', 'vibe', 'mic'];
const ARENA_STAT_LABEL = { hype: 'Hype', grind: 'Grind', chat: 'Chat', loyalty: 'Loyalty', clutch: 'Clutch', vibe: 'Vibe', mic: 'Mic' };
const ARENA_STAT_LABEL = { hype: 'Hype', grind: 'Grind', chat: 'Chat', loyalty: 'Loyalty', clutch: 'Clutch', vibe: 'Vibe', mic: 'Mic' };
-let _arenaRoster = null; // cached leaderboard payload
-let _arenaLiveTimer = null;
+const ARENA_KIND = {
+ topic: { label: 'Topic', icon: 'fa-comment-dots' },
+ debate: { label: 'Debate', icon: 'fa-scale-unbalanced' },
+ phrase: { label: 'Say it', icon: 'fa-quote-left' },
+ bounty: { label: 'Bounty', icon: 'fa-sack-dollar' },
+};
+let _arenaRoster = null;
+let _arenaTimers = [];
let _arenaImagePoll = null;
let _arenaImagePoll = null;
-let _arenaBattleTimers = [];
let _arenaUtterance = null;
let _arenaUtterance = null;
function _aEsc(s) { return typeof esc === 'function' ? esc(String(s ?? '')) : String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); }
function _aEsc(s) { return typeof esc === 'function' ? esc(String(s ?? '')) : String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); }
@@ -24,15 +32,24 @@ function _aNum(n) { n = Number(n) || 0; return n >= 1000 ? `${(n / 1000).toFixed
@@ -24,15 +32,24 @@ function _aNum(n) { n = Number(n) || 0; return n >= 1000 ? `${(n / 1000).toFixed
function _aInitial(u) { return (u.display_name || u.username || '?').trim().charAt(0).toUpperCase(); }
function _aInitial(u) { return (u.display_name || u.username || '?').trim().charAt(0).toUpperCase(); }
function _aStamp(sec) { sec = Math.max(0, Math.floor(sec || 0)); const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60; return (h ? `${h}:` : '') + `${h ? String(m).padStart(2, '0') : m}:${String(s).padStart(2, '0')}`; }
function _aStamp(sec) { sec = Math.max(0, Math.floor(sec || 0)); const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60; return (h ? `${h}:` : '') + `${h ? String(m).padStart(2, '0') : m}:${String(s).padStart(2, '0')}`; }
function _aDate(d) { try { return new Date(String(d).replace(' ', 'T') + (String(d).endsWith('Z') ? '' : 'Z')).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); } catch { return String(d || ''); } }
function _aDate(d) { try { return new Date(String(d).replace(' ', 'T') + (String(d).endsWith('Z') ? '' : 'Z')).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); } catch { return String(d || ''); } }
+function _aAgo(d) {
+ if (!d) return '';
+ const ms = Date.now() - new Date(String(d).includes('T') ? d : String(d).replace(' ', 'T') + 'Z').getTime();
+ const m = Math.round(ms / 60000);
+ if (m < 1) return 'just now'; if (m < 60) return `${m}m ago`; const h = Math.round(m / 60); if (h < 48) return `${h}h ago`; return `${Math.round(h / 24)}d ago`;
+}
+function _aClock(sec) { sec = Math.max(0, Math.floor(sec || 0)); if (sec >= 3600) return `${Math.floor(sec / 3600)}h ${String(Math.floor((sec % 3600) / 60)).padStart(2, '0')}m`; return `${Math.floor(sec / 60)}:${String(sec % 60).padStart(2, '0')}`; }
+function _aToast(msg, type = 'info') { if (typeof toast === 'function') toast(msg, type); else console.log('[Arena]', msg); }
+function _aMe() { return (typeof currentUser !== 'undefined' && currentUser) ? currentUser : null; }
+function _aSpinner(text) { return `<div class="arena-loading"><i class="fa-solid fa-circle-notch fa-spin"></i><span>${_aEsc(text)}</span></div>`; }
function _aStopTimers() {
function _aStopTimers() {
- if (_arenaLiveTimer) { clearInterval(_arenaLiveTimer); _arenaLiveTimer = null; }
+ for (const t of _arenaTimers) { clearInterval(t); clearTimeout(t); }
+ _arenaTimers = [];
if (_arenaImagePoll) { clearInterval(_arenaImagePoll); _arenaImagePoll = null; }
if (_arenaImagePoll) { clearInterval(_arenaImagePoll); _arenaImagePoll = null; }
- for (const t of _arenaBattleTimers) clearTimeout(t);
- _arenaBattleTimers = [];
_aStopSpeaking();
_aStopSpeaking();
- if (typeof _aTalkStop === 'function') _aTalkStop();
}
}
+function _aEvery(ms, fn) { const t = setInterval(() => { if (typeof currentPage !== 'undefined' && currentPage !== 'arena') return _aStopTimers(); fn(); }, ms); _arenaTimers.push(t); return t; }
// ── Speech: hear the taunt / quotes read out (browser TTS, no server cost) ──
// ── Speech: hear the taunt / quotes read out (browser TTS, no server cost) ──
function _aStopSpeaking() {
function _aStopSpeaking() {
@@ -61,26 +78,13 @@ function _aBindSpeak(root) {
@@ -61,26 +78,13 @@ function _aBindSpeak(root) {
/** Portrait: AI image when present, otherwise the avatar (or an initial) styled as a card. */
/** Portrait: AI image when present, otherwise the avatar (or an initial) styled as a card. */
function _aPortrait(f, size = 'md') {
function _aPortrait(f, size = 'md') {
- const u = f.user;
+ const u = f.user || f;
const color = u.profile_color || '#8b5cf6';
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>`;
- }
+ 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>`;
return `<div class="arena-portrait arena-portrait-${size} arena-portrait-initial" style="--fc:${_aEsc(color)}"><span>${_aEsc(_aInitial(u))}</span></div>`;
}
}
-function _aRatingBars(r, { clickable = false } = {}) {
- return `<div class="arena-bars">${ARENA_STATS.map(k => `
- <div class="arena-bar ${clickable ? 'is-clickable' : ''}" data-stat="${k}" title="${_aEsc(ARENA_STAT_LABEL[k])}">
- <span class="arena-bar-label">${_aEsc(ARENA_STAT_LABEL[k])}</span>
- <span class="arena-bar-track"><span class="arena-bar-fill" style="width:${Math.max(0, Math.min(100, r[k] || 0))}%"></span></span>
- <span class="arena-bar-val">${r[k] ?? '–'}</span>
- </div>`).join('')}</div>`;
-}
-
/** Heptagon radar chart as inline SVG (no library). */
/** Heptagon radar chart as inline SVG (no library). */
function _aRadar(r, color, size = 200) {
function _aRadar(r, color, size = 200) {
const c = size / 2, R = size / 2 - 28;
const c = size / 2, R = size / 2 - 28;
@@ -119,8 +123,16 @@ function _aSpark(series, color) {
@@ -119,8 +123,16 @@ function _aSpark(series, color) {
function _aChannelLink(u) { return `/${encodeURIComponent(u.username)}`; }
function _aChannelLink(u) { return `/${encodeURIComponent(u.username)}`; }
function _aFighterLink(u) { return `/arena/${encodeURIComponent(u.username)}`; }
function _aFighterLink(u) { return `/arena/${encodeURIComponent(u.username)}`; }
-function _aBattleLink(a, b) { return `/arena/battle/${encodeURIComponent(a.username)}/${encodeURIComponent(b.username)}`; }
-function _aA(href, inner, cls = '') { return `<a class="${cls}" href="${_aEsc(href)}" onclick="return handleLinkClick(event, '${_aEsc(href)}')">${inner}</a>`; }
+function _aConsoleLink(u) { return `/arena/live/${encodeURIComponent(u.username)}`; }
+function _aBeefLink(b) { return `/arena/beef/${b.id}`; }
+function _aTopicLink(t) { return `/arena/topic/${t.id}`; }
+function _aVodLink(vodId, sec) { return vodId ? `/vod/${vodId}?t=${Math.max(0, Math.floor(sec || 0))}` : null; }
+function _aA(href, inner, cls = '', title = '') { return `<a class="${cls}" href="${_aEsc(href)}" ${title ? `title="${_aEsc(title)}"` : ''} onclick="return handleLinkClick(event, '${_aEsc(href)}')">${inner}</a>`; }
+function _aPlay(vodId, sec, label = '') { const h = _aVodLink(vodId, sec); return h ? _aA(h, `<i class="fa-solid fa-play"></i>${label ? ` ${_aEsc(label)}` : ''}`, 'arena-play', 'Hear them say it (jumps to the VOD)') : ''; }
+function _aLevelPill(level) { return `<span class="arena-lvl" title="Trash Level — earned by talking shit on the board and in beefs">TL ${_aEsc(level ?? 1)}</span>`; }
+function _aBriefChip(f, extra = '') {
+ return _aA(_aFighterLink(f.user), `${_aPortrait(f, 'xs')}<span><strong>${_aEsc(f.fighter_name)}${f.live ? ' <span class="arena-live-pill">LIVE</span>' : ''}</strong><small>${f.rank ? `#${f.rank} · ` : ''}${_aLevelPill(f.level)}${extra}</small></span>`, 'arena-chip');
+}
// ── Page entry ───────────────────────────────────────────────
// ── Page entry ───────────────────────────────────────────────
@@ -128,128 +140,302 @@ async function loadArenaPage(segments = []) {
@@ -128,128 +140,302 @@ async function loadArenaPage(segments = []) {
_aStopTimers();
_aStopTimers();
const root = document.getElementById('arena-root');
const root = document.getElementById('arena-root');
if (!root) return;
if (!root) return;
- const [, first, second, third] = segments;
+ const [, first, second] = segments;
try {
try {
- if (first === 'battle' && second && third) return await _aRenderBattle(root, second, third);
- if (first === 'talk') return second ? await _aRenderSession(root, second) : await _aRenderTalk(root);
- if (first && first !== 'battle') return await _aRenderFighter(root, first);
+ if (first === 'beef' && second) return await _aRenderBeef(root, Number(second));
+ if (first === 'topic' && second) return await _aRenderTopic(root, Number(second));
+ if (first === 'live' && second) return await _aRenderConsole(root, second);
+ if (first) return await _aRenderFighter(root, first);
return await _aRenderHome(root);
return await _aRenderHome(root);
} catch (err) {
} 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>`;
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: main event + live matchups + leaderboard ───────────
+// ── Home ─────────────────────────────────────────────────────
async function _aRenderHome(root) {
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 = _aSpinner('Reading the room…');
+ const [boardData, beefs, live, roster] = await Promise.all([api('/arena/board'), api('/arena/beefs'), api('/arena/live').catch(() => ({ live: [] })), api('/arena/fighters')]);
+ _arenaRoster = roster;
root.innerHTML = `
root.innerHTML = `
<div class="arena-hero">
<div class="arena-hero">
<div>
<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. Tap a fighter to expand, pick two to fight, and let chat decide the last round.</p>
+ <h1><i class="fa-solid fa-hand-fist"></i> Arena</h1>
+ <p class="arena-lede">Nobody clicks "fight" here. Say a fighter's name on mic while you're talking shit and the beef opens itself. Talk on a board topic and your Trash Level climbs. Chat picks sides, hypes, starts topics and posts bounties.</p>
</div>
</div>
<div class="arena-hero-actions">
<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>'}
+ ${_aMe() ? `<button class="btn btn-primary" id="arena-new-topic-btn"><i class="fa-solid fa-plus"></i> Start a topic</button>` : ''}
+ <span class="arena-note">${roster.ai ? '<i class="fa-solid fa-ear-listen"></i> AI ears on' : '<i class="fa-solid fa-ear-deaf"></i> AI off — keyword judging'}</span>
</div>
</div>
</div>
</div>
- <section id="arena-main-event" class="arena-main-event" style="display:none"></section>
- <section id="arena-talk-card" class="arena-talk-card" style="display:none"></section>
- <section id="arena-live" class="arena-live"></section>
- <section class="arena-board">
+ <section class="arena-pulse" id="arena-pulse">${_aPulse(boardData.pulse)}</section>
+ <section class="arena-live" id="arena-live">${_aRenderLive(live.live || [])}</section>
+ <section class="arena-beefs" id="arena-beefs">${_aRenderBeefs(beefs)}</section>
+ <section class="arena-board" id="arena-board">${_aRenderBoard(boardData)}</section>
+ <section class="arena-ladders">
+ <div class="arena-ladder">
+ <h2><i class="fa-solid fa-fire"></i> Trash Level ladder <small>XP from beef hits, cleared angles, conquered topics, chat hype</small></h2>
+ ${_aLevelsList(boardData.levels || [])}
+ </div>
+ <div class="arena-ladder">
+ <h2><i class="fa-solid fa-crown"></i> Chat clout <small>viewers who pick the winning side</small></h2>
+ ${_aCloutList(boardData.clout || [])}
+ </div>
+ </section>
+ <section class="arena-leaderboard">
<div class="arena-board-head">
<div class="arena-board-head">
- <h2>Leaderboard</h2>
- <input id="arena-search" type="search" placeholder="Find a fighter…" autocomplete="off">
+ <h2><i class="fa-solid fa-ranking-star"></i> Power ladder <small>${roster.fighters.length} fighters · stats from real streams</small></h2>
+ <input type="search" id="arena-search" placeholder="Find a fighter…" autocomplete="off">
</div>
</div>
<div class="arena-list" id="arena-list"></div>
<div class="arena-list" id="arena-list"></div>
+ </section>
+ <section class="arena-rules">
+ <h3>How the Arena works</h3>
+ <ul>
+ <li><b>Beefs.</b> Say another fighter's name on stream while talking shit → a beef opens and they go on the clock (15 min if they're live, 24 h if not; the clock resets every time a side answers). Silence is a forfeit. Best mouth after 24 h wins. Beat someone ranked 4+ spots above you and it's an <b>upset</b>.</li>
+ <li><b>The board.</b> Topics, debates, phrase challenges and bounties come from chat (<code>!topic</code>, <code>!bounty</code>) and from the community pulse — the AI reads global chat, transcripts and the AI timeline every 30 min. Click a topic, talk on it, clear its angles → XP → Trash Level.</li>
+ <li><b>Chat.</b> <code>!hype</code> boosts your streamer · <code>!side <name></code> picks a side (clout if you're right) · <code>!topic <text></code> · <code>!bounty <user></code> · <code>!beef</code> · <code>!board</code> · <code>!arena</code>.</li>
+ <li><b>Language.</b> Nothing gets censored for being offensive. The only lines that don't count: real threats, anything sexual about minors, and doxxing.</li>
+ </ul>
</section>`;
</section>`;
- _aRenderLive(live);
- _aRenderList(fighters);
- api('/arena/main-event').then(me => _aRenderMainEvent(me)).catch(() => {});
- api('/arena/talk?generate=0').then(t => _aRenderTalkCard(t)).catch(() => {});
- document.getElementById('arena-search').addEventListener('input', (e) => {
+ _aRenderList(roster.fighters);
+ _aBindHome(root);
+ document.getElementById('arena-search')?.addEventListener('input', (e) => {
const q = e.target.value.trim().toLowerCase();
const q = e.target.value.trim().toLowerCase();
- _aRenderList(q ? fighters.filter(f => [f.user.username, f.user.display_name, f.persona.fighter_name, f.persona.class, f.persona.element].some(s => String(s || '').toLowerCase().includes(q))) : fighters);
+ _aRenderList(!q ? roster.fighters : roster.fighters.filter(f => [f.persona.fighter_name, f.user.display_name, f.user.username, f.persona.class, f.persona.element].filter(Boolean).some(s => s.toLowerCase().includes(q))));
});
});
- 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(_aBattleLink(a.user, b.user));
+ document.getElementById('arena-new-topic-btn')?.addEventListener('click', () => _aTopicComposer());
+ _aEvery(20000, async () => {
+ try {
+ const [b, l, bd] = await Promise.all([api('/arena/beefs'), api('/arena/live'), api('/arena/board')]);
+ const beefsEl = document.getElementById('arena-beefs'), liveEl = document.getElementById('arena-live'), boardEl = document.getElementById('arena-board'), pulseEl = document.getElementById('arena-pulse');
+ if (beefsEl) beefsEl.innerHTML = _aRenderBeefs(b);
+ if (liveEl) liveEl.innerHTML = _aRenderLive(l.live || []);
+ if (boardEl) boardEl.innerHTML = _aRenderBoard(bd);
+ if (pulseEl) pulseEl.innerHTML = _aPulse(bd.pulse);
+ _aBindHome(root);
+ } catch { /* keep the last render */ }
});
});
- _arenaLiveTimer = setInterval(async () => {
- if (currentPage !== 'arena' || !document.getElementById('arena-live')) return _aStopTimers();
- try { _aRenderLive(await api('/arena/live')); } catch { /* keep last */ }
- }, 30000);
-}
-
-function _aRenderMainEvent(me) {
- const el = document.getElementById('arena-main-event');
- if (!el || !me || !me.a) return;
- const side = (f, s) => _aA(_aBattleLink(me.a.user, me.b.user), `${_aPortrait(f, 'sm')}<div><strong>${_aEsc(f.persona.fighter_name)}</strong><span>${_aEsc(f.user.display_name)} · #${f.rank} · PWR ${f.ratings.power}</span></div>`, `arena-me-side is-${s}`);
- const render = (votes, outcome, yourVote) => {
- el.style.display = '';
- el.innerHTML = `
- <div class="arena-main-event-head">
- <h2><i class="fa-solid fa-star"></i> Main event · ${_aEsc(me.day)}</h2>
- ${_aA(_aBattleLink(me.a.user, me.b.user), '<i class="fa-solid fa-play"></i> Watch the fight', 'btn btn-ghost')}
- </div>
- <div class="arena-main-event-body">
- ${side(me.a, 'a')}
- <div class="arena-me-center">
- <div class="arena-me-score">${outcome.a} – ${outcome.b}</div>
- <div class="arena-me-vote">
- <button data-side="a" class="${yourVote === 'a' ? 'is-picked' : ''}">Back ${_aEsc(me.a.persona.fighter_name.split(' ')[0])}</button>
- <button data-side="b" class="${yourVote === 'b' ? 'is-picked' : ''}">Back ${_aEsc(me.b.persona.fighter_name.split(' ')[0])}</button>
- </div>
- <div class="arena-me-tally">${votes.a + votes.b} vote${votes.a + votes.b === 1 ? '' : 's'} · ${outcome.winner ? `${_aEsc((outcome.winner === 'a' ? me.a : me.b).persona.fighter_name)} leads` : 'dead even'}</div>
- </div>
- ${side(me.b, 'b')}
- </div>
- <p class="arena-me-intro"><i class="fa-solid fa-microphone-lines"></i> ${_aEsc(me.commentary?.intro || '')}</p>`;
- el.querySelectorAll('.arena-me-vote button').forEach(btn => btn.addEventListener('click', async () => {
- btn.disabled = true;
- try { const r = await api(`/arena/battle/${encodeURIComponent(me.a.user.username)}/${encodeURIComponent(me.b.user.username)}/vote`, { method: 'POST', body: { side: btn.dataset.side } }); render(r.votes, r.outcome, r.your_side); }
- catch (err) { btn.disabled = false; if (typeof showToast === 'function') showToast(err?.message || 'Vote failed', 'error'); }
- }));
- };
- render(me.votes, me.outcome, me.your_vote || null);
+ _aEvery(1000, () => _aTickClocks(root));
+}
+
+function _aPulse(p) {
+ if (!p || !p.text) return `<div class="arena-pulse-inner"><span class="arena-pulse-kicker"><i class="fa-solid fa-heart-pulse"></i> Pulse</span><span class="arena-pulse-text">The community is quiet. Start something.</span></div>`;
+ return `<div class="arena-pulse-inner">
+ <span class="arena-pulse-kicker"><i class="fa-solid fa-heart-pulse fa-beat"></i> Pulse</span>
+ <span class="arena-pulse-text">${_aEsc(p.text)}</span>
+ <span class="arena-pulse-meta">${p.sources ? _aEsc(p.sources) + ' · ' : ''}read ${_aEsc(_aAgo(p.at))}</span>
+ </div>`;
}
}
function _aRenderLive(live) {
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="${_aEsc(_aChannelLink(f.user))}" onclick="return handleLinkClick(event, '${_aEsc(_aChannelLink(f.user))}')">
- ${f.thumbnail_url ? `<img class="arena-live-thumb" src="${_aEsc(f.thumbnail_url)}?t=${Date.now()}" alt="">` : _aPortrait(f, 'sm')}
- <div class="arena-live-meta">
- <strong>${_aEsc(f.persona.fighter_name)}</strong>
- <span>${_aEsc(f.user.display_name)} · <i class="fa-solid fa-eye"></i> ${_aNum(f.stream.viewer_count)} · PWR ${f.ratings.power}</span>
- ${f.hot_mic ? `<span title="Hot mic — last thing the transcript heard"><i class="fa-solid fa-microphone"></i> “${_aEsc(f.hot_mic.text.length > 70 ? f.hot_mic.text.slice(0, 70) + '…' : f.hot_mic.text)}”</span>` : ''}
+ if (!live.length) return `<h2><span class="arena-live-dot"></span> On the mic now <small>nobody from the roster is live</small></h2><p class="arena-note">When a fighter goes live with transcription on, the Arena listens for name-drops and board topics.</p>`;
+ return `<h2><span class="arena-live-dot"></span> On the mic now <small>${live.length} live · the Arena is listening</small></h2>
+ <div class="arena-mic-grid">${live.map(f => `
+ <div class="arena-mic-card ${f.transcribed ? '' : 'is-quiet'}" style="--fc:${_aEsc(f.user.profile_color || '#8b5cf6')}">
+ ${_aA(_aChannelLink(f.user), f.thumbnail_url ? `<img class="arena-live-thumb" src="${_aEsc(f.thumbnail_url)}" alt="">` : _aPortrait(f, 'sm'), 'arena-mic-thumb')}
+ <div class="arena-mic-body">
+ <div class="arena-mic-head">
+ ${_aA(_aFighterLink(f.user), `<strong>${_aEsc(f.persona.fighter_name)}</strong>`)}
+ <span class="arena-mic-meta">#${f.rank} · PWR ${f.ratings.power} · ${_aLevelPill(f.level)} · <i class="fa-solid fa-eye"></i> ${_aNum(f.stream.viewer_count)}</span>
+ </div>
+ ${f.hot_mic ? `<q class="arena-mic-line">${_aEsc(f.hot_mic.text)}</q>` : `<span class="arena-mic-line arena-mic-line-empty">${f.transcribed ? 'listening…' : 'no transcript yet — mic stats need transcription on'}</span>`}
+ <div class="arena-mic-tags">
+ ${f.active_topic ? _aA(_aTopicLink(f.active_topic), `<i class="fa-solid fa-comment-dots"></i> on: ${_aEsc(f.active_topic.text)}`, 'arena-tag') : '<span class="arena-tag arena-tag-dim">no board topic</span>'}
+ ${f.open_beefs ? `<span class="arena-tag arena-tag-hot"><i class="fa-solid fa-fire"></i> ${f.open_beefs} beef${f.open_beefs > 1 ? 's' : ''} open</span>` : ''}
+ ${_aA(_aConsoleLink(f.user), '<i class="fa-solid fa-ear-listen"></i> ears', 'arena-tag')}
+ </div>
</div>
</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')}
- ${_aA(_aBattleLink(m.a.user, m.b.user), 'VS<small>fight</small>', 'arena-live-vs')}
- ${card(m.b, 'b')}
- </div>`).join('')}
- ${live.waiting ? `<div class="arena-live-match arena-live-waiting">${card(live.waiting, 'a')}<div class="arena-live-vs arena-live-vs-idle">?<small>waiting for a challenger</small></div></div>` : ''}
+ </div>`).join('')}</div>`;
+}
+
+// ── Beefs ────────────────────────────────────────────────────
+
+function _aTug(b, { big = false } = {}) {
+ const ca = b.a.user.profile_color || '#8b5cf6', cb = b.b.user.profile_color || '#e74c3c';
+ return `<div class="arena-tug ${big ? 'arena-tug-big' : ''}" title="${_aEsc(`${b.a.fighter_name} ${b.a.total} — ${b.b.total} ${b.b.fighter_name}`)}">
+ <span class="arena-tug-a" style="width:${b.share_a}%;--fc:${_aEsc(ca)}"><b>${b.share_a >= 12 ? `${b.share_a}%` : ''}</b></span>
+ <span class="arena-tug-b" style="width:${100 - b.share_a}%;--fc:${_aEsc(cb)}"><b>${100 - b.share_a >= 12 ? `${100 - b.share_a}%` : ''}</b></span>
+ </div>`;
+}
+function _aClockTag(b) {
+ if (b.status !== 'open' || !b.on_clock) return '';
+ const who = b.on_clock === 'a' ? b.a : b.b;
+ return `<span class="arena-clock ${b.clock_seconds_left < 120 ? 'is-urgent' : ''}" data-until="${_aEsc(b.clock_until)}" data-who="${_aEsc(who.fighter_name)}"><i class="fa-solid fa-stopwatch"></i> ${_aEsc(who.fighter_name)} has <b>${_aClock(b.clock_seconds_left)}</b> to answer${b.clock_is_live_window ? '' : ' (offline clock)'}</span>`;
+}
+function _aTickClocks(root) {
+ root.querySelectorAll('.arena-clock[data-until]').forEach(el => {
+ const left = Math.max(0, Math.round((Date.parse(el.dataset.until) - Date.now()) / 1000));
+ const b = el.querySelector('b'); if (b) b.textContent = _aClock(left);
+ el.classList.toggle('is-urgent', left < 120);
+ if (left === 0) el.innerHTML = `<i class="fa-solid fa-hourglass-end"></i> ${_aEsc(el.dataset.who)} ran out of time…`;
+ });
+}
+function _aBeefTags(b) {
+ const tags = [];
+ if (b.rematch) tags.push('<span class="arena-tag arena-tag-hot"><i class="fa-solid fa-rotate-left"></i> REMATCH</span>');
+ if (b.bounty) tags.push('<span class="arena-tag arena-tag-gold"><i class="fa-solid fa-sack-dollar"></i> BOUNTY</span>');
+ if (b.upset) tags.push('<span class="arena-tag arena-tag-hot"><i class="fa-solid fa-bolt"></i> UPSET</span>');
+ if (b.streaks?.a >= 2) tags.push(`<span class="arena-tag">${_aEsc(b.a.fighter_name)} on a ${b.streaks.a}-beef streak</span>`);
+ if (b.streaks?.b >= 2) tags.push(`<span class="arena-tag">${_aEsc(b.b.fighter_name)} on a ${b.streaks.b}-beef streak</span>`);
+ if (b.history?.fights) tags.push(`<span class="arena-tag arena-tag-dim">history ${b.history.wins_1}–${b.history.wins_2} in ${b.history.fights}</span>`);
+ return tags.join('');
+}
+function _aBeefCard(b) {
+ const open = b.status === 'open';
+ const winner = !open && b.winner_user_id ? (b.winner_user_id === b.a.user.id ? b.a : b.b) : null;
+ return `<div class="arena-beef ${open ? 'is-open' : 'is-done'}" data-beef="${b.id}">
+ <div class="arena-beef-head">
+ ${_aA(_aBeefLink(b), `<span class="arena-beef-headline">${_aEsc((open ? b.headline : b.result_headline || b.headline) || `${b.a.fighter_name} vs ${b.b.fighter_name}`)}</span>`)}
+ <span class="arena-beef-tags">${_aBeefTags(b)}${!open ? `<span class="arena-tag ${winner ? 'arena-tag-gold' : 'arena-tag-dim'}">${winner ? `${_aEsc(winner.fighter_name)} won${b.resolution === 'forfeit' ? ' by forfeit' : ''}` : 'draw'}</span>` : ''}</span>
+ </div>
+ <div class="arena-beef-sides">
+ ${_aA(_aFighterLink(b.a.user), `${_aPortrait(b.a, 'sm')}<span><strong>${_aEsc(b.a.fighter_name)}${b.a.live ? ' <span class="arena-live-pill">LIVE</span>' : ''}</strong><small>#${b.a.rank || '–'} · ${b.a.hits} hits · crowd ${b.a.crowd}/${b.rules.crowd_max}</small></span>`, 'arena-beef-side arena-beef-side-a')}
+ <span class="arena-beef-vs">${open ? 'VS' : 'FINAL'}</span>
+ ${_aA(_aFighterLink(b.b.user), `${_aPortrait(b.b, 'sm')}<span><strong>${_aEsc(b.b.fighter_name)}${b.b.live ? ' <span class="arena-live-pill">LIVE</span>' : ''}</strong><small>#${b.b.rank || '–'} · ${b.b.hits} hits · crowd ${b.b.crowd}/${b.rules.crowd_max}</small></span>`, 'arena-beef-side arena-beef-side-b')}
+ </div>
+ ${_aTug(b)}
+ <div class="arena-beef-foot">
+ ${open ? _aClockTag(b) : `<span class="arena-note">ended ${_aEsc(_aAgo(b.resolved_at))}</span>`}
+ ${open ? `<span class="arena-beef-actions">
+ <button class="btn btn-ghost btn-sm arena-hype-btn" data-beef="${b.id}" data-side="a" title="Hype ${_aEsc(b.a.fighter_name)}"><i class="fa-solid fa-fire"></i> ${_aEsc(b.a.fighter_name)}</button>
+ <button class="btn btn-ghost btn-sm arena-hype-btn" data-beef="${b.id}" data-side="b" title="Hype ${_aEsc(b.b.fighter_name)}"><i class="fa-solid fa-fire"></i> ${_aEsc(b.b.fighter_name)}</button>
+ <span class="arena-sides-tally" title="who chat thinks wins"><i class="fa-solid fa-people-group"></i> ${b.sides.a}–${b.sides.b}</span>
+ </span>` : ''}
+ </div>
+ ${b.feed?.length ? `<q class="arena-beef-last">${_aEsc(b.feed[b.feed.length - 1].announcer || b.feed[b.feed.length - 1].text || '')}</q>` : ''}
+ </div>`;
+}
+function _aRenderBeefs(beefs) {
+ const open = beefs.open || [], done = beefs.resolved || [];
+ return `<h2><i class="fa-solid fa-fire-flame-curved"></i> Beefs <small>${open.length ? `${open.length} open` : 'none open — say a name on mic'}</small></h2>
+ ${open.length ? `<div class="arena-beef-grid">${open.map(_aBeefCard).join('')}</div>` : `<div class="arena-beef-empty"><i class="fa-solid fa-microphone-lines"></i><p>No beef right now. A streamer only has to say another fighter's name while talking shit — the ears do the rest.</p></div>`}
+ ${done.length ? `<details class="arena-done"><summary>Settled beefs <small>${done.length}</small></summary><div class="arena-beef-grid">${done.slice(0, 6).map(_aBeefCard).join('')}</div></details>` : ''}`;
+}
+
+async function _aHype(beefId, side, btn) {
+ try {
+ const r = await api(`/arena/beefs/${beefId}/hype`, { method: 'POST', body: { side } });
+ _aToast(r.added ? `🔥 Hyped! crowd ${r.crowd}/10 (${r.hypers} hyping)` : `You already hyped this side (${r.hypers} hyping)`, r.added ? 'success' : 'info');
+ if (btn) btn.classList.add('is-hyped');
+ } catch (err) { _aToast(err?.message || 'Hype failed', 'error'); }
+}
+async function _aSide(beefId, side) {
+ try { const r = await api(`/arena/beefs/${beefId}/side`, { method: 'POST', body: { side } }); _aToast(`🗳️ Side picked. Crowd: ${r.a}–${r.b}. Pick right → clout.`, 'success'); return r; }
+ catch (err) { _aToast(err?.message || 'Failed', 'error'); }
+}
+function _aBindHome(root) {
+ root.querySelectorAll('.arena-hype-btn').forEach(btn => btn.onclick = (e) => { e.preventDefault(); _aHype(btn.dataset.beef, btn.dataset.side, btn); });
+ root.querySelectorAll('.arena-topic-hype').forEach(btn => btn.onclick = async (e) => {
+ e.preventDefault();
+ try { const r = await api(`/arena/board/topics/${btn.dataset.topic}/hype`, { method: 'POST', body: { user_id: Number(btn.dataset.user) } }); _aToast(r.added ? `🔥 Hyped (${r.hypers} hyping)` : 'Already hyped', r.added ? 'success' : 'info'); btn.classList.add('is-hyped'); }
+ catch (err) { _aToast(err?.message || 'Failed', 'error'); }
+ });
+ root.querySelectorAll('.arena-debate-side').forEach(btn => btn.onclick = async (e) => {
+ e.preventDefault();
+ try { const r = await api(`/arena/board/topics/${btn.dataset.topic}/side`, { method: 'POST', body: { side: btn.dataset.side } }); _aToast(`🗳️ You're with ${btn.dataset.label}. ${r.a}–${r.b}`, 'success'); const t = btn.closest('.arena-topic'); if (t) { const tally = t.querySelector('.arena-debate-tally'); if (tally) tally.innerHTML = _aDebateTally(r); } }
+ catch (err) { _aToast(err?.message || 'Failed', 'error'); }
+ });
+ root.querySelectorAll('.arena-topic-join').forEach(btn => btn.onclick = async (e) => {
+ e.preventDefault();
+ try { await api(`/arena/board/topics/${btn.dataset.topic}/join`, { method: 'POST' }); _aToast('🎤 You\'re on it. Start talking on stream — the ears are listening.', 'success'); navigate(`/arena/topic/${btn.dataset.topic}`); }
+ catch (err) { _aToast(err?.message || 'Failed', 'error'); }
+ });
+ _aBindSpeak(root);
+}
+
+// ── Board ────────────────────────────────────────────────────
+
+function _aDebateTally(s) { return `<span class="arena-debate-bar"><span style="width:${s.share_a}%"></span></span><span class="arena-debate-nums">${s.a} · ${s.b}</span>`; }
+function _aHeat(t, hotThreshold) {
+ const pct = Math.min(100, Math.round((t.heat / Math.max(hotThreshold * 2, 1)) * 100));
+ return `<span class="arena-heat ${t.hot ? 'is-hot' : ''}" title="heat: talk + hype + joins in the last hours"><i class="fa-solid fa-fire"></i> ${t.hot ? 'HOT' : ''} ${t.heat}<span class="arena-heat-bar"><span style="width:${pct}%"></span></span></span>`;
+}
+function _aTopicTitle(t) {
+ if (t.kind === 'debate') return `<span class="arena-debate-title"><b>${_aEsc(t.side_a)}</b> <em>vs</em> <b>${_aEsc(t.side_b)}</b></span>`;
+ if (t.kind === 'phrase') return `Work “<b>${_aEsc(t.phrase)}</b>” into your stream`;
+ if (t.kind === 'bounty') return `Bounty on <b>${_aEsc(t.target?.fighter_name || '?')}</b> — double XP for talking shit about them`;
+ return _aEsc(t.text);
+}
+function _aTopicCard(t, hot, { me } = {}) {
+ const k = ARENA_KIND[t.kind] || ARENA_KIND.topic;
+ const onIt = me && t.talking_now.some(f => f.user.id === me.id);
+ const onRoster = me && _arenaRoster?.fighters?.some(f => f.user.id === me.id);
+ return `<div class="arena-topic arena-topic-${t.kind} ${t.hot ? 'is-hot' : ''}" data-topic="${t.id}">
+ <div class="arena-topic-head">
+ <span class="arena-kind"><i class="fa-solid ${k.icon}"></i> ${k.label}</span>
+ ${_aHeat(t, hot)}
+ <span class="arena-topic-by">${t.created_by === 'ai' ? '<i class="fa-solid fa-heart-pulse"></i> pulse' : `<i class="fa-solid fa-user"></i> ${_aEsc(t.creator_name || t.created_by)}`}${t.expires_at ? ` · <span class="arena-expires" data-until="${_aEsc(t.expires_at)}">${_aEsc(_aClock((Date.parse(t.expires_at) - Date.now()) / 1000))} left</span>` : ''}</span>
+ </div>
+ ${t.headline ? `<div class="arena-topic-headline">${_aEsc(t.headline)}</div>` : ''}
+ ${_aA(_aTopicLink(t), `<div class="arena-topic-text">${_aTopicTitle(t)}</div>`, 'arena-topic-link')}
+ ${t.hint ? `<p class="arena-topic-hint">${_aEsc(t.hint)}</p>` : ''}
+ ${t.kind === 'debate' ? `<div class="arena-debate">
+ <button class="btn btn-ghost btn-sm arena-debate-side" data-topic="${t.id}" data-side="a" data-label="${_aEsc(t.side_a)}">${_aEsc(t.side_a)}</button>
+ <span class="arena-debate-tally">${_aDebateTally(t.sides || { a: 0, b: 0, share_a: 50 })}</span>
+ <button class="btn btn-ghost btn-sm arena-debate-side" data-topic="${t.id}" data-side="b" data-label="${_aEsc(t.side_b)}">${_aEsc(t.side_b)}</button>
+ </div>` : ''}
+ <div class="arena-topic-foot">
+ <span class="arena-topic-who">${t.talking_now.length ? `<span class="arena-live-dot arena-live-dot-sm"></span> ${t.talking_now.map(f => _aA(_aFighterLink(f.user), _aPortrait(f, 'xs'), '', `${f.fighter_name} is on this`)).join('')}` : ''}${t.members.filter(m => !m.active).slice(0, 6).map(m => _aA(_aFighterLink(m.user), _aPortrait(m, 'xs'), 'is-dim', `${m.fighter_name} · ${m.cleared}/${t.angles.length || 3} angles`)).join('')}</span>
+ <span class="arena-topic-stats">${t.hits} hits · ${t.conquered} conquered${t.angles?.length ? ` · ${t.angles.length} angles` : ''}</span>
+ <span class="arena-topic-actions">
+ ${t.talking_now.length && me && !onIt ? t.talking_now.slice(0, 2).map(f => `<button class="btn btn-ghost btn-sm arena-topic-hype" data-topic="${t.id}" data-user="${f.user.id}"><i class="fa-solid fa-fire"></i> ${_aEsc(f.fighter_name)}</button>`).join('') : ''}
+ ${onRoster && t.kind !== 'bounty' ? (onIt ? '<span class="arena-tag arena-tag-hot"><i class="fa-solid fa-microphone"></i> you\'re on it</span>' : `<button class="btn btn-primary btn-sm arena-topic-join" data-topic="${t.id}"><i class="fa-solid fa-microphone"></i> Talk on this</button>`) : ''}
+ </span>
+ </div>
+ </div>`;
+}
+function _aRenderBoard(bd) {
+ const me = _aMe();
+ const open = bd.open || [], resolved = bd.resolved || [];
+ return `<div class="arena-board-head">
+ <h2><i class="fa-solid fa-comments"></i> The board <small>${open.length} open · sorted by heat</small></h2>
+ ${me ? `<button class="btn btn-ghost btn-sm" id="arena-board-new"><i class="fa-solid fa-plus"></i> Topic</button>` : '<span class="arena-note">sign in (or type <code>!topic</code> in any chat) to add one</span>'}
+ </div>
+ ${open.length ? `<div class="arena-topic-grid">${open.map(t => _aTopicCard(t, bd.hot_threshold || 12, { me })).join('')}</div>` : '<p class="arena-note">The board is empty — the pulse fills it in a moment, or start a topic.</p>'}
+ ${resolved.length ? `<details class="arena-done"><summary>Recently settled <small>${resolved.length}</small></summary><div class="arena-resolved">${resolved.map(t => `<div class="arena-resolved-row">${_aA(_aTopicLink(t), `<span class="arena-kind">${_aEsc((ARENA_KIND[t.kind] || ARENA_KIND.topic).label)}</span> ${_aTopicTitle(t)}`)}<span class="arena-note">${t.resolved?.headline ? _aEsc(t.resolved.headline) : t.winner_side ? `${_aEsc(t.winner_side === 'a' ? t.side_a : t.side_b)} won` : 'closed'}</span></div>`).join('')}</div></details>` : ''}`;
+}
+function _aTopicComposer() {
+ const box = document.createElement('div');
+ box.className = 'arena-lightbox';
+ box.innerHTML = `<button class="arena-lightbox-close" aria-label="Close">×</button>
+ <div class="arena-lightbox-inner arena-composer">
+ <h3><i class="fa-solid fa-comment-dots"></i> Put a topic on the board</h3>
+ <p class="arena-note">Something the streamers should talk shit about. The AI cuts it into 3 angles; anyone who clears them levels up. One line, up to 140 characters.</p>
+ <textarea id="arena-composer-text" maxlength="140" rows="3" placeholder="e.g. Streamers who read donations in a baby voice"></textarea>
+ <div class="arena-composer-actions"><button class="btn btn-primary" id="arena-composer-go">Post it</button></div>
</div>`;
</div>`;
+ const close = () => box.remove();
+ box.addEventListener('click', (e) => { if (e.target === box || e.target.closest('.arena-lightbox-close')) close(); });
+ box.querySelector('#arena-composer-go').addEventListener('click', async () => {
+ const text = box.querySelector('#arena-composer-text').value.trim();
+ if (!text) return;
+ try { const r = await api('/arena/board/topics', { method: 'POST', body: { text } }); close(); _aToast('📌 On the board.', 'success'); navigate(`/arena/topic/${r.topic.id}`); }
+ catch (err) { _aToast(err?.message || 'Failed', 'error'); }
+ });
+ document.body.appendChild(box);
+ box.querySelector('textarea').focus();
+}
+document.addEventListener('click', (e) => { if (e.target.closest('#arena-board-new')) { e.preventDefault(); _aTopicComposer(); } });
+
+// ── Ladders ──────────────────────────────────────────────────
+
+function _aLevelsList(rows) {
+ if (!rows.length) return '<p class="arena-note">Nobody has XP yet. Talk on a topic or start a beef.</p>';
+ return `<div class="arena-mini-list">${rows.map((r, i) => `<div class="arena-mini-row">
+ <span class="arena-rank ${i < 3 ? `arena-rank-${i + 1}` : ''}">${i + 1}</span>
+ ${_aA(_aFighterLink(r.user), `${_aPortrait(r, 'xs')}<span><strong>${_aEsc(r.fighter_name)}</strong><small>${r.xp} XP · ${r.beef_hits} beef hits · ${r.angles_cleared} angles · ${r.topics_conquered} conquered</small></span>`, 'arena-chip')}
+ <span class="arena-lvl arena-lvl-big">LVL ${r.level}</span>
+ </div>`).join('')}</div>`;
+}
+function _aCloutList(rows) {
+ if (!rows.length) return '<p class="arena-note">Pick sides in beefs and debates (<code>!side</code>) — call it right and you show up here.</p>';
+ return `<div class="arena-mini-list">${rows.map((r, i) => `<div class="arena-mini-row">
+ <span class="arena-rank ${i < 3 ? `arena-rank-${i + 1}` : ''}">${i + 1}</span>
+ <span class="arena-chip"><span><strong>${r.username ? _aA(_aChannelLink(r), _aEsc(r.name)) : _aEsc(r.name)}</strong><small>${r.wins}/${r.picks} right · ${r.accuracy}%${r.streak >= 2 ? ` · ${r.streak} streak` : ''}</small></span></span>
+ <span class="arena-lvl">${r.wins} W</span>
+ </div>`).join('')}</div>`;
}
}
function _aRenderList(fighters) {
function _aRenderList(fighters) {
@@ -261,13 +447,13 @@ function _aRenderList(fighters) {
@@ -261,13 +447,13 @@ function _aRenderList(fighters) {
<span class="arena-rank ${f.rank <= 3 ? `arena-rank-${f.rank}` : ''}">${f.rank}</span>
<span class="arena-rank ${f.rank <= 3 ? `arena-rank-${f.rank}` : ''}">${f.rank}</span>
${_aPortrait(f, 'sm')}
${_aPortrait(f, 'sm')}
<span class="arena-row-main">
<span class="arena-row-main">
- <strong>${_aEsc(f.persona.fighter_name)} ${f.live ? '<span class="arena-live-pill">LIVE</span>' : ''}<i class="fa-solid fa-chevron-right arena-chevron"></i></strong>
+ <strong>${_aEsc(f.persona.fighter_name)} ${f.live ? '<span class="arena-live-pill">LIVE</span>' : ''} ${_aLevelPill(f.level?.level)}<i class="fa-solid fa-chevron-right arena-chevron"></i></strong>
<span class="arena-row-sub">${_aEsc(f.user.display_name)} · ${_aEsc(f.persona.class)} · ${_aEsc(f.persona.element)}${f.voice?.has_data ? ` · <i class="fa-solid fa-microphone" title="has transcript data"></i> Mic ${f.ratings.mic}` : ''}</span>
<span class="arena-row-sub">${_aEsc(f.user.display_name)} · ${_aEsc(f.persona.class)} · ${_aEsc(f.persona.element)}${f.voice?.has_data ? ` · <i class="fa-solid fa-microphone" title="has transcript data"></i> Mic ${f.ratings.mic}` : ''}</span>
<em class="arena-row-taunt">“${_aEsc(f.persona.taunt)}”</em>
<em class="arena-row-taunt">“${_aEsc(f.persona.taunt)}”</em>
</span>
</span>
<span class="arena-row-stats">
<span class="arena-row-stats">
<span class="arena-power"><b>${f.ratings.power}</b><small>PWR</small></span>
<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 class="arena-record" title="beef record">${f.record.wins}W–${f.record.losses}L</span>
</span>
</span>
<div class="arena-row-expand">
<div class="arena-row-expand">
<div>${_aRadar(f.ratings, f.user.profile_color || '#8b5cf6', 180)}<div class="arena-mini-record">${f.category ? _aEsc(f.category) + ' · ' : ''}last live ${_aEsc(f.last_live_at ? _aDate(f.last_live_at) : '—')}</div></div>
<div>${_aRadar(f.ratings, f.user.profile_color || '#8b5cf6', 180)}<div class="arena-mini-record">${f.category ? _aEsc(f.category) + ' · ' : ''}last live ${_aEsc(f.last_live_at ? _aDate(f.last_live_at) : '—')}</div></div>
@@ -278,7 +464,7 @@ function _aRenderList(fighters) {
@@ -278,7 +464,7 @@ function _aRenderList(fighters) {
</div>
</div>
<div class="arena-row-expand-actions">
<div class="arena-row-expand-actions">
${_aA(_aFighterLink(f.user), '<i class="fa-solid fa-id-card"></i> Full profile', 'btn btn-primary')}
${_aA(_aFighterLink(f.user), '<i class="fa-solid fa-id-card"></i> Full profile', 'btn btn-primary')}
- <button class="btn btn-ghost arena-row-fight" data-user="${_aEsc(f.user.username)}"><i class="fa-solid fa-hand-fist"></i> Fight someone</button>
+ ${f.live ? _aA(_aConsoleLink(f.user), '<i class="fa-solid fa-ear-listen"></i> Listen in', 'btn btn-ghost') : ''}
${_aSpeakBtn(f.persona.taunt, 'btn btn-ghost')}
${_aSpeakBtn(f.persona.taunt, 'btn btn-ghost')}
</div>
</div>
</div>
</div>
@@ -287,15 +473,188 @@ function _aRenderList(fighters) {
@@ -287,15 +473,188 @@ function _aRenderList(fighters) {
if (e.target.closest('a, button')) return;
if (e.target.closest('a, button')) return;
row.classList.toggle('is-open');
row.classList.toggle('is-open');
}));
}));
- el.querySelectorAll('.arena-row-fight').forEach(btn => btn.addEventListener('click', () => {
- const me = fighters.find(f => f.user.username === btn.dataset.user);
- const pool = fighters.filter(f => f !== me);
- if (!me || !pool.length) return;
- navigate(_aBattleLink(me.user, pool[Math.floor(Math.random() * pool.length)].user));
- }));
_aBindSpeak(el);
_aBindSpeak(el);
}
}
+// ── Beef detail ──────────────────────────────────────────────
+
+function _aFeedLine(e, b) {
+ const side = e.side === 'a' ? b.a : b.b;
+ const sys = e.kind && e.kind !== 'hit';
+ return `<div class="arena-feed-line ${sys ? 'is-system' : `is-${e.side}`}">
+ ${sys ? '' : _aA(_aFighterLink(side.user), _aPortrait(side, 'xs'))}
+ <div class="arena-feed-body">
+ ${e.announcer ? `<div class="arena-feed-announcer"><i class="fa-solid fa-bullhorn"></i> ${_aEsc(e.announcer)}</div>` : ''}
+ ${e.text ? `<q>${_aEsc(e.text)}</q>` : ''}
+ <small>${sys ? _aEsc(e.kind) : `${_aEsc(side.fighter_name)}${e.quality != null ? ` · ${e.quality}/10` : ''}${e.about ? ` · ${_aEsc(e.about)}` : ''}${e.bonus ? ` · ${_aEsc(e.bonus)}` : ''}`} · ${_aEsc(_aAgo(e.at))} ${_aPlay(e.vod_id, e.sec)} ${e.text ? _aSpeakBtn(e.text, '') : ''}</small>
+ </div>
+ </div>`;
+}
+async function _aRenderBeef(root, id) {
+ root.innerHTML = _aSpinner('Pulling the receipts…');
+ const b = await api(`/arena/beefs/${id}`);
+ const open = b.status === 'open';
+ const winner = !open && b.winner_user_id ? (b.winner_user_id === b.a.user.id ? b.a : b.b) : null;
+ const draw = () => {
+ root.innerHTML = `
+ <div class="arena-back">${_aA('/arena', '<i class="fa-solid fa-arrow-left"></i> Arena')} ${_aA(_aFighterLink(b.a.user), _aEsc(b.a.fighter_name))} · ${_aA(_aFighterLink(b.b.user), _aEsc(b.b.fighter_name))}</div>
+ <div class="arena-beef-page">
+ <div class="arena-beef-hero">
+ <div class="arena-beef-tags">${_aBeefTags(b)}${!open ? `<span class="arena-tag ${winner ? 'arena-tag-gold' : 'arena-tag-dim'}">${winner ? `${_aEsc(winner.fighter_name)} won${b.resolution === 'forfeit' ? ' by forfeit' : b.resolution === 'score' ? ' on points' : ''}` : 'draw'}</span>` : '<span class="arena-tag arena-tag-hot"><i class="fa-solid fa-fire"></i> OPEN</span>'}</div>
+ <h1>${_aEsc((open ? b.headline : b.result_headline || b.headline) || `${b.a.fighter_name} vs ${b.b.fighter_name}`)} ${_aSpeakBtn((open ? b.headline : b.result_headline || b.headline) || '')}</h1>
+ ${b.opener_line ? `<p class="arena-beef-opener">It started with: <q>${_aEsc(b.opener_line)}</q></p>` : ''}
+ </div>
+ <div class="arena-beef-tape">
+ ${['a', 'b'].map(s => { const f = b[s]; return `<div class="arena-beef-tape-side arena-beef-tape-${s}" style="--fc:${_aEsc(f.user.profile_color || (s === 'a' ? '#8b5cf6' : '#e74c3c'))}">
+ ${_aA(_aFighterLink(f.user), _aPortrait(f, 'md'))}
+ <h2>${_aEsc(f.fighter_name)}${f.live ? ' <span class="arena-live-pill">LIVE</span>' : ''}</h2>
+ <div class="arena-beef-tape-meta">#${f.rank || '–'} · ${_aLevelPill(f.level)}${b.streaks?.[s] >= 2 ? ` · ${b.streaks[s]}-beef streak` : ''}</div>
+ <div class="arena-beef-tape-nums"><span><b>${f.hits}</b><small>hits</small></span><span><b>${f.score}</b><small>quality</small></span><span><b>${f.crowd}</b><small>crowd /${b.rules.crowd_max}</small></span><span><b>${f.total}</b><small>total</small></span></div>
+ ${open ? `<div class="arena-beef-tape-actions"><button class="btn btn-primary btn-sm arena-hype-btn" data-beef="${b.id}" data-side="${s}"><i class="fa-solid fa-fire"></i> Hype</button><button class="btn btn-ghost btn-sm arena-side-btn" data-beef="${b.id}" data-side="${s}"><i class="fa-solid fa-flag"></i> I'm with ${_aEsc(f.fighter_name.split(' ')[0])}</button></div>` : ''}
+ ${f.live ? _aA(_aConsoleLink(f.user), '<i class="fa-solid fa-ear-listen"></i> listen in', 'arena-tag') : ''}
+ </div>`; }).join('')}
+ </div>
+ ${_aTug(b, { big: true })}
+ <div class="arena-beef-status">
+ ${open ? _aClockTag(b) : ''}
+ <span class="arena-sides-tally" id="arena-sides-tally"><i class="fa-solid fa-people-group"></i> chat says ${b.sides.a}–${b.sides.b} (${b.sides.share_a}% ${_aEsc(b.a.fighter_name)})</span>
+ ${b.ends_at && open ? `<span class="arena-note"><i class="fa-solid fa-hourglass-half"></i> hard end in ${_aEsc(_aClock((Date.parse(b.ends_at) - Date.now()) / 1000))}</span>` : ''}
+ </div>
+ <div class="arena-beef-cols">
+ <section class="arena-feed">
+ <h3><i class="fa-solid fa-bullhorn"></i> Ringside <small>every judged hit, newest last</small></h3>
+ ${b.feed.length ? b.feed.map(e => _aFeedLine(e, b)).join('') : '<p class="arena-note">Nothing judged yet.</p>'}
+ </section>
+ <aside class="arena-beef-aside">
+ ${b.history?.fights ? `<section><h3><i class="fa-solid fa-receipt"></i> Receipts <small>${b.history.wins_1}–${b.history.wins_2} in ${b.history.fights} earlier beef${b.history.fights > 1 ? 's' : ''}</small></h3>
+ ${b.history.receipts.map(r => { const f = r.side_user_id === b.a.user.id ? b.a : b.b; return `<div class="arena-receipt"><q>${_aEsc(r.text)}</q><small>${_aEsc(f.fighter_name)} · ${r.quality}/10 · ${_aA(_aBeefLink({ id: r.beef_id }), `beef #${r.beef_id}`)} ${_aPlay(r.vod_id, r.sec)}</small></div>`; }).join('') || '<p class="arena-note">No quotable receipts.</p>'}</section>` : '<section><h3><i class="fa-solid fa-receipt"></i> Receipts</h3><p class="arena-note">First time these two have beef.</p></section>'}
+ <section><h3><i class="fa-solid fa-gavel"></i> Rules</h3><ul class="arena-rules-list">
+ <li>Answer on your own stream within <b>${b.rules.response_live_min} min</b> if you're live, <b>${b.rules.response_offline_hours} h</b> if not — or forfeit.</li>
+ <li>Every judged answer resets the other side's clock. Beef hard-ends after ${b.rules.max_hours} h; higher total wins.</li>
+ <li>Total = hit quality (AI-judged 1–10) + crowd hype (max ${b.rules.crowd_max}). Chat's side pick is clout, not score.</li>
+ <li>Beat someone ranked 4+ spots above you → <b>upset</b>. Same two again → <b>rematch</b> (+ rivalry receipts).</li>
+ </ul></section>
+ </aside>
+ </div>
+ </div>`;
+ root.querySelectorAll('.arena-hype-btn').forEach(btn => btn.onclick = () => _aHype(b.id, btn.dataset.side, btn));
+ root.querySelectorAll('.arena-side-btn').forEach(btn => btn.onclick = async () => { const r = await _aSide(b.id, btn.dataset.side); if (r) { const t = document.getElementById('arena-sides-tally'); if (t) t.innerHTML = `<i class="fa-solid fa-people-group"></i> chat says ${r.a}–${r.b} (${r.share_a}% ${_aEsc(b.a.fighter_name)})`; } });
+ _aBindSpeak(root);
+ };
+ draw();
+ _aEvery(1000, () => _aTickClocks(root));
+ if (open) _aEvery(12000, async () => { try { const fresh = await api(`/arena/beefs/${id}`); if (JSON.stringify(fresh.feed) !== JSON.stringify(b.feed) || fresh.status !== b.status || fresh.share_a !== b.share_a) { Object.assign(b, fresh); draw(); } } catch { /* */ } });
+}
+
+// ── Topic detail ─────────────────────────────────────────────
+
+async function _aRenderTopic(root, id) {
+ root.innerHTML = _aSpinner('Opening the topic…');
+ let t = await api(`/arena/board/topics/${id}`);
+ const me = _aMe();
+ const draw = () => {
+ const k = ARENA_KIND[t.kind] || ARENA_KIND.topic;
+ const onIt = me && t.talking_now.some(f => f.user.id === me.id);
+ const onRoster = me && (_arenaRoster?.fighters?.some(f => f.user.id === me.id) ?? true);
+ const members = t.members || [];
+ root.innerHTML = `
+ <div class="arena-back">${_aA('/arena', '<i class="fa-solid fa-arrow-left"></i> Arena')}</div>
+ <div class="arena-topic-page arena-topic-${t.kind} ${t.hot ? 'is-hot' : ''}">
+ <div class="arena-topic-head">
+ <span class="arena-kind"><i class="fa-solid ${k.icon}"></i> ${k.label}</span>
+ ${_aHeat(t, 12)}
+ <span class="arena-topic-by">${t.created_by === 'ai' ? '<i class="fa-solid fa-heart-pulse"></i> from the pulse' : `started by ${_aEsc(t.creator_name || t.created_by)}`} · ${_aEsc(_aAgo(t.created_at))}${t.expires_at && t.status === 'open' ? ` · <span class="arena-expires" data-until="${_aEsc(t.expires_at)}">${_aEsc(_aClock((Date.parse(t.expires_at) - Date.now()) / 1000))} left</span>` : ''}${t.status !== 'open' ? ` · <b>${_aEsc(t.status)}</b>` : ''}</span>
+ </div>
+ ${t.headline ? `<div class="arena-topic-headline">${_aEsc(t.headline)} ${_aSpeakBtn(t.headline, '')}</div>` : ''}
+ <h1 class="arena-topic-text">${_aTopicTitle(t)}</h1>
+ ${t.hint ? `<p class="arena-topic-hint">${_aEsc(t.hint)}</p>` : ''}
+ ${t.source_note ? `<p class="arena-note"><i class="fa-solid fa-satellite-dish"></i> ${_aEsc(t.source_note)}</p>` : ''}
+ ${t.resolved?.headline ? `<div class="arena-topic-result"><i class="fa-solid fa-flag-checkered"></i> ${_aEsc(t.resolved.headline)}</div>` : ''}
+ ${t.kind === 'debate' ? `<div class="arena-debate arena-debate-big">
+ <button class="btn btn-ghost arena-debate-side" data-topic="${t.id}" data-side="a" data-label="${_aEsc(t.side_a)}" ${t.status !== 'open' ? 'disabled' : ''}>${_aEsc(t.side_a)}</button>
+ <span class="arena-debate-tally">${_aDebateTally(t.sides || { a: 0, b: 0, share_a: 50 })}</span>
+ <button class="btn btn-ghost arena-debate-side" data-topic="${t.id}" data-side="b" data-label="${_aEsc(t.side_b)}" ${t.status !== 'open' ? 'disabled' : ''}>${_aEsc(t.side_b)}</button>
+ <p class="arena-note">Streamers: say which side you're on and argue it on mic. Chat: pick a side — the side with more talk + more chat wins when the clock runs out, and right picks earn clout.</p>
+ </div>` : ''}
+ ${t.kind === 'bounty' && t.target ? `<div class="arena-bounty-target">${_aBriefChip(t.target, ' · the mark')}<span class="arena-note">Any fighter who lands judged shit talk on ${_aEsc(t.target.fighter_name)} while this is open gets <b>double XP</b>. The mark can answer back on their own stream.</span></div>` : ''}
+ <div class="arena-topic-cta">
+ ${onRoster && t.status === 'open' && t.kind !== 'bounty' ? (onIt ? `<span class="arena-tag arena-tag-hot"><i class="fa-solid fa-microphone"></i> You're on this — talk on stream, the ears are listening</span> <button class="btn btn-ghost btn-sm" id="arena-topic-leave">Leave topic</button> ${_aA(_aConsoleLink(me), '<i class="fa-solid fa-ear-listen"></i> my ears', 'btn btn-ghost btn-sm')}` : `<button class="btn btn-primary" id="arena-topic-join"><i class="fa-solid fa-microphone"></i> Talk on this</button><span class="arena-note">Sets it as your active topic. Go live with transcription on and just talk — every 30 s the judge scores what you said.</span>`) : ''}
+ ${!me ? '<span class="arena-note">Sign in to talk on this (streamers) or hype whoever\'s on it.</span>' : ''}
+ </div>
+ ${t.angles?.length ? `<section class="arena-angles"><h3><i class="fa-solid fa-diagram-project"></i> Angles <small>clear all ${t.angles.length} to conquer the topic (+60 XP)</small></h3>
+ <div class="arena-angle-grid">${t.angles.map(a => `<div class="arena-angle"><b>${a.idx + 1}. ${_aEsc(a.text)}</b><span>${_aEsc(a.hint || '')}</span></div>`).join('')}</div></section>` : (t.kind === 'topic' ? '<p class="arena-note">Angles get cut the moment someone joins.</p>' : '')}
+ ${members.length ? `<section class="arena-members"><h3><i class="fa-solid fa-microphone-lines"></i> Who's talking <small>${t.talking_now.length} live on it</small></h3>
+ ${members.map(m => { const prog = (t.progress || {})[m.user.id] || []; return `<div class="arena-member ${m.active ? 'is-active' : ''}">
+ ${_aBriefChip(m, ` · ${m.cleared}/${t.angles.length || '?'} angles · ${m.score} pts${m.conquered_at ? ' · <b>conquered</b>' : ''}`)}
+ <div class="arena-member-progress">${(t.angles || []).map(a => { const p = prog.find(x => x.angle_idx === a.idx) || { progress: 0 }; return `<span class="arena-progress ${p.cleared ? 'is-cleared' : ''}" title="${_aEsc(a.text)}: ${p.progress}%"><span class="arena-progress-fill" style="width:${p.progress}%"></span></span>`; }).join('')}</div>
+ ${me && m.user.id !== me.id && t.status === 'open' ? `<button class="btn btn-ghost btn-sm arena-topic-hype" data-topic="${t.id}" data-user="${m.user.id}"><i class="fa-solid fa-fire"></i> Hype</button>` : ''}
+ </div>`; }).join('')}</section>` : ''}
+ ${t.best_lines?.length ? `<section class="arena-best-lines"><h3><i class="fa-solid fa-quote-left"></i> Best lines <small>as heard on stream</small></h3>
+ ${t.best_lines.map(l => `<div class="arena-quote"><div><q>${_aEsc(l.text)}</q><small>${_aA(_aFighterLink(l.user), _aEsc(l.fighter_name))}${l.angle_idx >= 0 && t.angles[l.angle_idx] ? ` · angle ${l.angle_idx + 1}` : ''}</small></div><div class="arena-quote-actions">${_aPlay(l.vod_id, l.sec)} ${_aSpeakBtn(l.text, '')}</div></div>`).join('')}</section>` : ''}
+ </div>`;
+ document.getElementById('arena-topic-join')?.addEventListener('click', async () => { try { const r = await api(`/arena/board/topics/${t.id}/join`, { method: 'POST' }); t = r.topic; _aToast('🎤 You\'re on it. Go talk.', 'success'); draw(); } catch (err) { _aToast(err?.message || 'Failed', 'error'); } });
+ document.getElementById('arena-topic-leave')?.addEventListener('click', async () => { try { await api(`/arena/board/topics/${t.id}/leave`, { method: 'POST' }); t = await api(`/arena/board/topics/${id}`); draw(); } catch (err) { _aToast(err?.message || 'Failed', 'error'); } });
+ _aBindHome(root);
+ };
+ draw();
+ _aEvery(15000, async () => { try { const fresh = await api(`/arena/board/topics/${id}`); if (JSON.stringify(fresh) !== JSON.stringify(t)) { t = fresh; draw(); } } catch { /* */ } });
+}
+
+// ── Live console ("the ears") ────────────────────────────────
+
+async function _aRenderConsole(root, username) {
+ root.innerHTML = _aSpinner('Putting the ears on…');
+ let c = await api(`/arena/console/${encodeURIComponent(username)}`);
+ const draw = () => {
+ const f = c.fighter, L = c.listener || {}, lvl = c.level || {};
+ const me = _aMe(), mine = me && me.id === f.user.id;
+ const xpPct = lvl.xp_per_level ? Math.round((lvl.xp_into_level / lvl.xp_per_level) * 100) : 0;
+ root.innerHTML = `
+ <div class="arena-back">${_aA('/arena', '<i class="fa-solid fa-arrow-left"></i> Arena')} ${_aA(_aFighterLink(f.user), _aEsc(f.fighter_name))} ${c.live ? _aA(_aChannelLink(f.user), '<i class="fa-solid fa-tv"></i> watch', '') : ''}</div>
+ <div class="arena-console" style="--fc:${_aEsc(f.user.profile_color || '#8b5cf6')}">
+ <div class="arena-console-head">
+ ${_aPortrait(f, 'sm')}
+ <div>
+ <h1>${_aEsc(f.fighter_name)} ${c.live ? '<span class="arena-live-pill">LIVE</span>' : '<span class="arena-tag arena-tag-dim">offline</span>'}</h1>
+ <div class="arena-console-status ${L.listening ? 'is-on' : ''}">
+ <i class="fa-solid ${L.listening ? 'fa-ear-listen' : 'fa-ear-deaf'}"></i>
+ ${!c.live ? 'Not live — the ears only work on a live, transcribed stream.' : !c.transcribed ? 'Live, but no transcript in the last 30 min. Turn on audio transcription in the dashboard and the Arena hears you.' : L.listening ? `Listening. ${L.pending_topic_words || 0} words buffered for the topic judge · ${L.mention_buffers || 0} name-drop buffer${L.mention_buffers === 1 ? '' : 's'} open${L.last_judge_at ? ` · last judged ${_aEsc(_aAgo(L.last_judge_at))}` : ''}` : 'Live and transcribed — the listener picks this stream up on its next 15 s tick.'}
+ </div>
+ </div>
+ <div class="arena-console-level">
+ <span class="arena-lvl arena-lvl-big">TRASH LVL ${lvl.level || 1}</span>
+ <span class="arena-xp-track"><span class="arena-xp-fill" style="width:${xpPct}%"></span></span>
+ <small>${lvl.xp_into_level || 0}/${lvl.xp_per_level || 50} XP to level ${(lvl.level || 1) + 1} · ${lvl.recent_xp || 0} this week</small>
+ </div>
+ </div>
+ <div class="arena-console-cols">
+ <section class="arena-console-main">
+ <h3><i class="fa-solid fa-microphone"></i> Hot mic <small>last lines the ears heard</small></h3>
+ <div class="arena-mic-feed">${c.hot_mic?.length ? c.hot_mic.map(l => `<div class="arena-mic-line-row"><span class="arena-mic-time">${_aStamp(l.sec)}</span><span>${_aEsc(l.text)}</span>${_aPlay(l.vod_id, l.sec)}</div>`).join('') : '<p class="arena-note">Nothing heard yet.</p>'}</div>
+ ${L.last_beef_judgement ? `<div class="arena-judgement ${L.last_beef_judgement.aimed_at_target ? 'is-hit' : ''}"><b><i class="fa-solid fa-gavel"></i> Beef judge (${_aEsc(_aAgo(L.last_beef_judgement.at))}):</b> ${L.last_beef_judgement.aimed_at_target ? `HIT · ${L.last_beef_judgement.quality}/10 · ${_aEsc(L.last_beef_judgement.about || '')}${L.last_beef_judgement.opened ? ' · <b>beef opened</b>' : ''}${L.last_beef_judgement.bounty ? ' · <b>bounty collected</b>' : ''}` : `no beef — ${_aEsc(L.last_beef_judgement.about || 'name dropped but not aimed at them')}`}${L.last_beef_judgement.flagged ? ' · <span class="arena-tag arena-tag-dim">line not counted (threat/minor/dox)</span>' : ''}</div>` : ''}
+ ${L.last_topic_judgement ? `<div class="arena-judgement ${L.last_topic_judgement.applied ? 'is-hit' : ''}"><b><i class="fa-solid fa-gavel"></i> Topic judge (${_aEsc(_aAgo(L.last_topic_judgement.at))}):</b> ${L.last_topic_judgement.applied ? `angle ${L.last_topic_judgement.angle_idx + 1} · ${L.last_topic_judgement.quality}/10 · +${L.last_topic_judgement.progress_gain || 0}% → ${L.last_topic_judgement.progress}%${L.last_topic_judgement.cleared_angle ? ' · <b>angle cleared</b>' : ''}${L.last_topic_judgement.conquered ? ' · <b>TOPIC CONQUERED</b>' : ''}` : `off topic — ${_aEsc(L.last_topic_judgement.about || 'keep it on the angles')}`}</div>` : ''}
+ </section>
+ <aside class="arena-console-aside">
+ <section>
+ <h3><i class="fa-solid fa-comment-dots"></i> Active topic</h3>
+ ${c.active_topic ? `${_aA(_aTopicLink(c.active_topic), `<b>${_aTopicTitle(c.active_topic)}</b>`)}
+ <div class="arena-angle-list">${(c.active_topic.angles || []).map(a => { const p = (c.active_topic.my_progress || []).find(x => x.angle_idx === a.idx) || { progress: 0 }; return `<div class="arena-angle-row ${p.cleared_at ? 'is-cleared' : ''}"><span class="arena-progress"><span class="arena-progress-fill" style="width:${Math.round(p.progress || 0)}%"></span></span><span><b>${a.idx + 1}.</b> ${_aEsc(a.text)} <small>${Math.round(p.progress || 0)}%${p.cleared_at ? ' ✓' : ''}</small></span></div>`; }).join('')}</div>` : `<p class="arena-note">${mine ? 'No active topic. Pick one on the board and talk on it.' : 'Not on a board topic.'}</p>${mine ? _aA('/arena', 'Open the board', 'btn btn-ghost btn-sm') : ''}`}
+ </section>
+ <section>
+ <h3><i class="fa-solid fa-fire-flame-curved"></i> Open beefs</h3>
+ ${c.open_beefs?.length ? c.open_beefs.map(b => `<div class="arena-console-beef">${_aA(_aBeefLink(b), `<b>${_aEsc(b.headline || `${b.a.fighter_name} vs ${b.b.fighter_name}`)}</b>`)}${_aTug(b)}${_aClockTag(b)}</div>`).join('') : `<p class="arena-note">None. ${mine ? 'Say another fighter\'s name while talking shit and one opens.' : ''}</p>`}
+ </section>
+ ${c.bounty_on_me ? `<section><h3><i class="fa-solid fa-sack-dollar"></i> Bounty on ${_aEsc(f.fighter_name)}</h3>${_aA(_aTopicLink(c.bounty_on_me), _aEsc(c.bounty_on_me.headline || c.bounty_on_me.text))}<p class="arena-note">Everyone gets double XP for talking shit about ${mine ? 'you' : 'them'} until it expires. ${mine ? 'Answer on mic.' : ''}</p></section>` : ''}
+ <section><h3><i class="fa-solid fa-circle-info"></i> How it's judged</h3><ul class="arena-rules-list"><li>Every 15 s the ears read new transcript lines.</li><li>A fighter's name in a line opens a 45 s window; ≥20 words go to the beef judge.</li><li>Otherwise lines pool for the topic judge (also ≥20 words, ≥30 s apart).</li><li>Offensive language is fine. Threats, minors, doxxing → line ignored.</li></ul></section>
+ </aside>
+ </div>
+ </div>`;
+ };
+ draw();
+ _aEvery(1000, () => _aTickClocks(root));
+ _aEvery(10000, async () => { try { const fresh = await api(`/arena/console/${encodeURIComponent(username)}`); const lvlUp = (fresh.level?.level || 1) > (c.level?.level || 1); c = fresh; draw(); if (lvlUp) _aLevelUp(c.level.level); } catch { /* */ } });
+}
+
// ── Fighter profile ──────────────────────────────────────────
// ── Fighter profile ──────────────────────────────────────────
function _aVoiceCard(f) {
function _aVoiceCard(f) {
@@ -303,7 +662,7 @@ function _aVoiceCard(f) {
@@ -303,7 +662,7 @@ function _aVoiceCard(f) {
const q = f.quotes;
const q = f.quotes;
const color = f.user.profile_color || '#8b5cf6';
const color = f.user.profile_color || '#8b5cf6';
if (!v.has_data) {
if (!v.has_data) {
- return `<div class="arena-voice" style="--fc:${_aEsc(color)}"><div class="arena-voice-head"><h3><i class="fa-solid fa-microphone-slash"></i> On the mic</h3></div><p class="arena-voice-empty">No transcript data yet — the audio transcription picks this up on their next streams. Until then, MIC is rated at the floor.</p></div>`;
+ return `<div class="arena-voice" style="--fc:${_aEsc(color)}"><div class="arena-voice-head"><h3><i class="fa-solid fa-microphone-slash"></i> On the mic</h3></div><p class="arena-voice-empty">No transcript data yet — the audio transcription picks this up on their next streams. Until then, MIC is rated at the floor and the Arena can't hear them.</p></div>`;
}
}
const quotes = (q && q.picks && q.picks.length) ? q.picks : [];
const quotes = (q && q.picks && q.picks.length) ? q.picks : [];
return `<div class="arena-voice" style="--fc:${_aEsc(color)}">
return `<div class="arena-voice" style="--fc:${_aEsc(color)}">
@@ -324,15 +683,23 @@ function _aVoiceCard(f) {
@@ -324,15 +683,23 @@ function _aVoiceCard(f) {
${quotes.length ? `<div class="arena-quotes">${quotes.map(p => `
${quotes.length ? `<div class="arena-quotes">${quotes.map(p => `
<div class="arena-quote">
<div class="arena-quote">
<div><q>${_aEsc(p.text)}</q><small>${_aEsc(p.why || '')}${p.vod_id ? ` · at ${_aStamp(p.start_sec)}` : ''}</small></div>
<div><q>${_aEsc(p.text)}</q><small>${_aEsc(p.why || '')}${p.vod_id ? ` · at ${_aStamp(p.start_sec)}` : ''}</small></div>
- <div class="arena-quote-actions">
- ${p.vod_id ? `<a href="/vod/${p.vod_id}?t=${p.start_sec}" onclick="return handleLinkClick(event, '/vod/${p.vod_id}?t=${p.start_sec}')" title="Hear them say it (jumps to the VOD)"><i class="fa-solid fa-play"></i></a>` : ''}
- ${_aSpeakBtn(p.text, '')}
- </div>
+ <div class="arena-quote-actions">${_aPlay(p.vod_id, p.start_sec)} ${_aSpeakBtn(p.text, '')}</div>
</div>`).join('')}</div>` : '<p class="arena-voice-empty">Quotes appear once enough lines have been transcribed.</p>'}
</div>`).join('')}</div>` : '<p class="arena-voice-empty">Quotes appear once enough lines have been transcribed.</p>'}
${q?._fallback ? '<p class="arena-note">Quotes picked by heuristic — the AI curates these once enabled.</p>' : ''}
${q?._fallback ? '<p class="arena-note">Quotes picked by heuristic — the AI curates these once enabled.</p>' : ''}
</div>`;
</div>`;
}
}
+function _aLevelCard(f) {
+ const l = f.level || {};
+ const pct = l.xp_per_level ? Math.round((l.xp_into_level / l.xp_per_level) * 100) : 0;
+ return `<div class="arena-level-card">
+ <div class="arena-level-head"><span class="arena-lvl arena-lvl-big">TRASH LVL ${l.level || 1}</span><span class="arena-note">${l.xp || 0} XP · ${l.recent_xp || 0} this week${f.ratings.talk_bonus ? ` · <b>+${f.ratings.talk_bonus} POWER</b> from the mouth` : ''}</span></div>
+ <span class="arena-xp-track"><span class="arena-xp-fill" style="width:${pct}%"></span></span>
+ <div class="arena-level-nums"><span><b>${l.beef_hits || 0}</b><small>beef hits</small></span><span><b>${l.angles_cleared || 0}</b><small>angles cleared</small></span><span><b>${l.topics_conquered || 0}</b><small>topics conquered</small></span><span><b>${f.record.wins}–${f.record.losses}${f.record.draws ? `–${f.record.draws}` : ''}</b><small>beef record</small></span></div>
+ ${l.best_line ? `<div class="arena-quote"><div><q>${_aEsc(l.best_line.text)}</q><small>best line on record · ${l.best_line.score}/10</small></div><div class="arena-quote-actions">${_aPlay(l.best_line.vod_id, l.best_line.sec)} ${_aSpeakBtn(l.best_line.text, '')}</div></div>` : ''}
+ </div>`;
+}
+
async function _aRenderFighter(root, username) {
async function _aRenderFighter(root, username) {
root.innerHTML = _aSpinner('Pulling the fighter file…');
root.innerHTML = _aSpinner('Pulling the fighter file…');
const f = await api(`/arena/fighters/${encodeURIComponent(username)}`);
const f = await api(`/arena/fighters/${encodeURIComponent(username)}`);
@@ -341,15 +708,15 @@ async function _aRenderFighter(root, username) {
@@ -341,15 +708,15 @@ async function _aRenderFighter(root, username) {
return;
return;
}
}
const p = f.persona, color = f.user.profile_color || '#8b5cf6';
const p = f.persona, color = f.user.profile_color || '#8b5cf6';
- const recent = f.recent_battles || [];
+ const beefs = f.beefs || [], rivalries = f.rivalries || [];
root.innerHTML = `
root.innerHTML = `
- <div class="arena-back">${_aA('/arena', '<i class="fa-solid fa-arrow-left"></i> Arena')}</div>
+ <div class="arena-back">${_aA('/arena', '<i class="fa-solid fa-arrow-left"></i> Arena')} ${f.live ? _aA(_aConsoleLink(f.user), '<i class="fa-solid fa-ear-listen"></i> Listen in live', '') : ''}</div>
<div class="arena-profile" style="--fc:${_aEsc(color)}">
<div class="arena-profile" style="--fc:${_aEsc(color)}">
<div class="arena-profile-portrait" id="arena-profile-portrait">
<div class="arena-profile-portrait" id="arena-profile-portrait">
${_aPortrait(f, 'lg')}
${_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>' : ''}
${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 class="arena-profile-rank">#${f.rank} <small>of ${f.roster_size}</small></div>
- ${f.rivalry ? `<div class="arena-mini-record"><i class="fa-solid fa-fire"></i> Rivalry: ${_aA(_aBattleLink(f.user, f.rivalry.opponent), _aEsc(f.rivalry.fighter_name))} (${f.rivalry.wins}–${f.rivalry.losses} in ${f.rivalry.fights} fights)</div>` : ''}
+ ${f.active_topic ? `<div class="arena-mini-record"><i class="fa-solid fa-comment-dots"></i> Talking on: ${_aA(_aTopicLink(f.active_topic), _aEsc(f.active_topic.text))}</div>` : ''}
</div>
</div>
<div class="arena-profile-main">
<div class="arena-profile-main">
<div class="arena-profile-name">
<div class="arena-profile-name">
@@ -359,9 +726,10 @@ async function _aRenderFighter(root, username) {
@@ -359,9 +726,10 @@ async function _aRenderFighter(root, username) {
</div>
</div>
<div class="arena-profile-power">
<div class="arena-profile-power">
<div class="arena-power arena-power-lg"><b>${f.ratings.power}</b><small>POWER</small></div>
<div class="arena-power arena-power-lg"><b>${f.ratings.power}</b><small>POWER</small></div>
- ${f.ratings.talk_bonus ? `<div class="arena-talk-bonus" title="Trash Talk bonus — earned on /arena/talk, decays over a week"><i class="fa-solid fa-microphone-lines"></i> +${f.ratings.talk_bonus} trash talk</div>` : ''}
- <div class="arena-record arena-record-lg">${f.record.wins}W – ${f.record.losses}L</div>
+ ${f.ratings.talk_bonus ? `<div class="arena-talk-bonus" title="Mouth bonus — recent Trash Level XP and beef wins, decays over a week"><i class="fa-solid fa-microphone-lines"></i> +${f.ratings.talk_bonus} mouth</div>` : ''}
+ <div class="arena-record arena-record-lg" title="beef record">${f.record.wins}W – ${f.record.losses}L</div>
</div>
</div>
+ ${_aLevelCard(f)}
<div class="arena-profile-stats">
<div class="arena-profile-stats">
${_aRadar(f.ratings, color)}
${_aRadar(f.ratings, color)}
<div class="arena-quips">${ARENA_STATS.map(k => `<div class="arena-quip is-clickable" data-stat="${k}" title="Tap for the breakdown"><b>${_aEsc(ARENA_STAT_LABEL[k])} ${f.ratings[k]} <i class="fa-solid fa-magnifying-glass-chart" style="font-size:0.7em;opacity:0.6"></i></b><span>${_aEsc((p.stat_quips || {})[k] || '')}</span></div>`).join('')}</div>
<div class="arena-quips">${ARENA_STATS.map(k => `<div class="arena-quip is-clickable" data-stat="${k}" title="Tap for the breakdown"><b>${_aEsc(ARENA_STAT_LABEL[k])} ${f.ratings[k]} <i class="fa-solid fa-magnifying-glass-chart" style="font-size:0.7em;opacity:0.6"></i></b><span>${_aEsc((p.stat_quips || {})[k] || '')}</span></div>`).join('')}</div>
@@ -388,15 +756,12 @@ async function _aRenderFighter(root, username) {
@@ -388,15 +756,12 @@ async function _aRenderFighter(root, username) {
</div>
</div>
</div>
</div>
</div>
</div>
- ${(f.trash_talk || []).length ? `<section class="arena-challenge"><h2><i class="fa-solid fa-microphone-lines"></i> Trash talk record</h2><div class="arena-talk-list">${f.trash_talk.map(e => _aTalkEntry(e, { compact: true })).join('')}</div></section>` : ''}
- ${recent.length ? `<section class="arena-challenge"><h2>Recent fights</h2><div class="arena-opponents">${recent.map(b => _aA(_aBattleLink(f.user, b.opponent), `${_aPortrait({ user: b.opponent }, 'xs')}<span><strong>${b.result === 'win' ? '<span style="color:var(--success)">W</span>' : b.result === 'loss' ? '<span style="color:var(--danger)">L</span>' : 'D'} vs ${_aEsc(b.opponent_fighter_name)}</strong><small>${_aEsc(b.day)} · rounds ${b.rounds_won}/${b.rounds_total}</small></span>`, 'arena-opponent')).join('')}</div></section>` : ''}
- <section class="arena-challenge">
- <h2>Pick an opponent</h2>
- <div class="arena-opponents" id="arena-opponents">${_aSpinner('Scouting…')}</div>
- </section>`;
+ ${rivalries.length ? `<section class="arena-challenge"><h2><i class="fa-solid fa-skull-crossbones"></i> Rivalries</h2><div class="arena-rivalries">${rivalries.map(r => `<div class="arena-rivalry ${r.open ? 'is-open' : ''}">${_aBriefChip(r.opponent, ` · ${r.wins}–${r.losses} in ${r.fights}${r.open ? ' · <b>beef open</b>' : ''}`)}${r.receipts.map(x => `<q class="arena-receipt-mini">${_aEsc(x.text)}</q>`).join('')}</div>`).join('')}</div></section>` : ''}
+ ${beefs.length ? `<section class="arena-challenge"><h2><i class="fa-solid fa-fire-flame-curved"></i> Beefs</h2><div class="arena-beef-grid">${beefs.map(_aBeefCard).join('')}</div></section>` : `<section class="arena-challenge"><h2><i class="fa-solid fa-fire-flame-curved"></i> Beefs</h2><p class="arena-note">No beef on record. Someone only has to say their name…</p></section>`}`;
_aBindSpeak(root);
_aBindSpeak(root);
+ _aBindHome(root);
+ _aEvery(1000, () => _aTickClocks(root));
- // Stat drill-down
root.querySelectorAll('.arena-quip.is-clickable').forEach(el => el.addEventListener('click', async () => {
root.querySelectorAll('.arena-quip.is-clickable').forEach(el => el.addEventListener('click', async () => {
const stat = el.dataset.stat;
const stat = el.dataset.stat;
const box = document.getElementById('arena-stat-detail');
const box = document.getElementById('arena-stat-detail');
@@ -419,20 +784,9 @@ async function _aRenderFighter(root, username) {
@@ -419,20 +784,9 @@ async function _aRenderFighter(root, username) {
} catch (err) { box.innerHTML = `<div class="arena-stat-detail">${_aEsc(err?.message || 'Failed')}</div>`; }
} catch (err) { box.innerHTML = `<div class="arena-stat-detail">${_aEsc(err?.message || 'Failed')}</div>`; }
}));
}));
- // Portrait lightbox ("how this was painted")
const portrait = root.querySelector('.arena-portrait-lg');
const portrait = root.querySelector('.arena-portrait-lg');
if (portrait && f.image_url) portrait.addEventListener('click', () => _aLightbox(f));
if (portrait && f.image_url) portrait.addEventListener('click', () => _aLightbox(f));
- // Opponent picker
- try {
- const data = _arenaRoster || await api('/arena/fighters');
- _arenaRoster = data;
- const others = (data.fighters || []).filter(o => o.user.id !== f.user.id);
- const el = document.getElementById('arena-opponents');
- el.innerHTML = others.map(o => _aA(_aBattleLink(f.user, o.user), `${_aPortrait(o, 'xs')}<span><strong>${_aEsc(o.persona.fighter_name)}</strong><small>#${o.rank} · PWR ${o.ratings.power}${o.live ? ' · LIVE' : ''}</small></span>`, 'arena-opponent')).join('') || '<p class="arena-note">Nobody else on the roster yet.</p>';
- } catch { /* */ }
-
- // Poll for the AI portrait while it is being painted.
if (f.image_pending || (!f.image_url && f.image_generation === 'ai')) {
if (f.image_pending || (!f.image_url && f.image_generation === 'ai')) {
let tries = 0;
let tries = 0;
_arenaImagePoll = setInterval(async () => {
_arenaImagePoll = setInterval(async () => {
@@ -442,7 +796,7 @@ async function _aRenderFighter(root, username) {
@@ -442,7 +796,7 @@ async function _aRenderFighter(root, username) {
if (fresh.image_url) {
if (fresh.image_url) {
const holder = document.getElementById('arena-profile-portrait');
const holder = document.getElementById('arena-profile-portrait');
if (holder) {
if (holder) {
- holder.querySelector('.arena-portrait')?.outerHTML && (holder.querySelector('.arena-portrait').outerHTML = _aPortrait(fresh, 'lg'));
+ const old = holder.querySelector('.arena-portrait'); if (old) old.outerHTML = _aPortrait(fresh, 'lg');
holder.querySelector('.arena-portrait-pending')?.remove();
holder.querySelector('.arena-portrait-pending')?.remove();
holder.querySelector('.arena-portrait-lg')?.addEventListener('click', () => _aLightbox(fresh));
holder.querySelector('.arena-portrait-lg')?.addEventListener('click', () => _aLightbox(fresh));
}
}
@@ -473,467 +827,6 @@ function _aLightbox(f) {
@@ -473,467 +827,6 @@ function _aLightbox(f) {
document.body.appendChild(box);
document.body.appendChild(box);
}
}
-// ── Battle ───────────────────────────────────────────────────
-
-function _aTape(A, B) {
- const rows = [
- ['Rank', `#${A.rank}`, `#${B.rank}`, A.rank < B.rank ? 'a' : B.rank < A.rank ? 'b' : null],
- ['Power', A.ratings.power, B.ratings.power],
- ['Hours live (90d)', A.raw.hours, B.raw.hours],
- ['Peak viewers', A.raw.peak_viewers, B.raw.peak_viewers],
- ['Avg viewers', A.raw.avg_viewers, B.raw.avg_viewers],
- ['Chat msgs / hr', A.raw.messages_per_hour, B.raw.messages_per_hour],
- ['Followers', A.raw.followers, B.raw.followers],
- ['Clips', A.raw.clips, B.raw.clips],
- ['Talk time %', A.raw.voice?.talk_ratio_pct ?? 0, B.raw.voice?.talk_ratio_pct ?? 0],
- ['Hype words / hr', A.raw.voice?.hype_per_hour ?? 0, B.raw.voice?.hype_per_hour ?? 0],
- ['Record', `${A.record.wins}–${A.record.losses}`, `${B.record.wins}–${B.record.losses}`, A.record.wins > B.record.wins ? 'a' : B.record.wins > A.record.wins ? 'b' : null],
- ];
- return `<details class="arena-tape"><summary><i class="fa-solid fa-scale-balanced"></i> Tale of the tape</summary><table>
- <thead><tr><th>${_aEsc(A.persona.fighter_name)}</th><th></th><th>${_aEsc(B.persona.fighter_name)}</th></tr></thead>
- <tbody>${rows.map(([label, a, b, better]) => {
- const win = better !== undefined ? better : (Number(a) > Number(b) ? 'a' : Number(b) > Number(a) ? 'b' : null);
- return `<tr><td class="${win === 'a' ? 'is-better' : ''}">${_aEsc(_aNum(a) === 'NaN' ? a : (typeof a === 'number' ? _aNum(a) : a))}</td><td class="arena-tape-label">${_aEsc(label)}</td><td class="${win === 'b' ? 'is-better' : ''}">${_aEsc(typeof b === 'number' ? _aNum(b) : b)}</td></tr>`;
- }).join('')}</tbody></table></details>`;
-}
-
-async function _aRenderBattle(root, a, b) {
- root.innerHTML = `<div class="arena-loading arena-loading-battle"><i class="fa-solid fa-bolt fa-beat"></i><span>Fighters are entering the arena…</span><small>the announcer is warming up</small></div>`;
- const battle = await api(`/arena/battle/${encodeURIComponent(a)}/${encodeURIComponent(b)}`);
- const A = battle.a, B = battle.b;
- 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'))}">
- ${_aA(_aFighterLink(f.user), _aPortrait(f, 'md'))}
- <h2>${_aEsc(f.persona.fighter_name)}</h2>
- <p class="arena-title">${_aEsc(f.persona.title)}</p>
- <p class="arena-handle">${_aEsc(f.user.display_name)} · ${_aEsc(f.persona.class)} · #${f.rank}</p>
- <div class="arena-power"><b>${f.ratings.power}</b><small>PWR</small></div>
- ${_aRatingBars(f.ratings)}
- <p class="arena-side-taunt">“${_aEsc(f.persona.taunt)}” ${_aSpeakBtn(f.persona.taunt)}</p>
- ${f.walkout ? `<p class="arena-walkout"><i class="fa-solid fa-microphone"></i> Walkout line: <q>${_aEsc(f.walkout.text)}</q> ${f.walkout.vod_id ? `<a href="/vod/${f.walkout.vod_id}?t=${f.walkout.start_sec}" onclick="return handleLinkClick(event, '/vod/${f.walkout.vod_id}?t=${f.walkout.start_sec}')" title="Hear them say it"><i class="fa-solid fa-play"></i> hear it</a>` : ''}</p>` : ''}
- </div>`;
- const h = battle.history || { fights: 0 };
- root.innerHTML = `
- <div class="arena-back">${_aA('/arena', '<i class="fa-solid fa-arrow-left"></i> Arena')} <span class="arena-note">Battle of ${_aEsc(battle.day)} — same matchup, same rounds all day; the crowd vote is the last round.</span></div>
- <div class="arena-battle">
- ${side(A, 'a')}
- <div class="arena-center">
- <div class="arena-vs">VS</div>
- <div class="arena-scoreboard" id="arena-scoreboard"><span id="arena-score-a">0</span><span class="arena-score-sep">–</span><span id="arena-score-b">0</span></div>
- <div class="arena-history">${h.fights ? `All-time: ${_aEsc(A.persona.fighter_name)} ${h.a_wins} – ${h.b_wins} ${_aEsc(B.persona.fighter_name)} (${h.fights} fight${h.fights === 1 ? '' : 's'})` : 'First meeting'}</div>
- <div class="arena-commentary" id="arena-commentary"></div>
- <div class="arena-vote" id="arena-vote"></div>
- ${_aTape(A, B)}
- <div class="arena-battle-actions">
- <button class="btn btn-ghost" id="arena-share"><i class="fa-solid fa-link"></i> Share</button>
- ${_aA(_aBattleLink(B.user, A.user), '<i class="fa-solid fa-right-left"></i> Swap corners', 'btn btn-ghost')}
- <button class="btn btn-ghost" id="arena-rematch"><i class="fa-solid fa-dice"></i> Random rematch</button>
- <button class="btn btn-ghost" id="arena-replay"><i class="fa-solid fa-rotate-left"></i> Replay</button>
- </div>
- </div>
- ${side(B, 'b')}
- </div>`;
- _aBindSpeak(root);
- _aPlayBattle(battle);
- document.getElementById('arena-share').addEventListener('click', async () => {
- const url = `${location.origin}${_aBattleLink(A.user, B.user)}`;
- try { await navigator.clipboard.writeText(url); document.getElementById('arena-share').innerHTML = '<i class="fa-solid fa-check"></i> Copied'; } catch { prompt('Battle link', url); }
- });
- document.getElementById('arena-replay').addEventListener('click', () => { for (const t of _arenaBattleTimers) clearTimeout(t); _arenaBattleTimers = []; _aPlayBattle(battle); });
- document.getElementById('arena-rematch').addEventListener('click', async () => {
- const data = _arenaRoster || await api('/arena/fighters');
- _arenaRoster = data;
- const pool = (data.fighters || []).filter(f => f.user.id !== A.user.id);
- if (!pool.length) return;
- navigate(_aBattleLink(A.user, pool[Math.floor(Math.random() * pool.length)].user));
- });
-}
-
-/** 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');
- box.innerHTML = ''; sa.textContent = '0'; sb.textContent = '0';
- document.getElementById('arena-vote').innerHTML = '';
- let a = 0, b = 0;
- const line = (html, cls = '', detail = null) => {
- const d = document.createElement('div');
- d.className = `arena-line ${cls}${detail ? ' is-expandable' : ''}`;
- d.innerHTML = html + (detail ? `<div class="arena-line-detail">${detail}</div>` : '');
- if (detail) d.addEventListener('click', () => d.classList.toggle('is-open'));
- box.appendChild(d); box.scrollTop = box.scrollHeight;
- };
- const at = (ms, fn) => _arenaBattleTimers.push(setTimeout(fn, ms));
- let t = 0;
- line(`<i class="fa-solid fa-microphone-lines"></i> ${_aEsc(c.intro)}`, 'arena-line-intro');
- 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;
- const meta = battle.stat_meta?.[r.stat];
- const detail = `${_aEsc(ARENA_STAT_LABEL[r.stat])} rating ${A.ratings[r.stat]} vs ${B.ratings[r.stat]} (${_aEsc(meta?.desc || '')}) · roll ${r.a} vs ${r.b}, margin ${r.margin}${r.upset ? ' · the lower rating won this one' : ''} · tap to collapse`;
- line(`<span class="arena-round-tag">R${i + 1} · ${_aEsc(r.label)}</span> ${_aEsc(c.rounds[i] || `${winner.persona.fighter_name} takes it.`)} <span class="arena-round-score">${r.a}–${r.b}${r.upset ? ' · UPSET' : ''}</span>`, `arena-line-${r.winner}${r.upset ? ' arena-line-upset' : ''}`, detail);
- document.querySelector(`.arena-side-${r.winner}`)?.classList.add('is-hit');
- setTimeout(() => document.querySelector(`.arena-side-${r.winner}`)?.classList.remove('is-hit'), 600);
- });
- });
- 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)} ${_aSpeakBtn(`${c.intro} ${c.rounds.join(' ')} ${c.finisher} ${c.verdict}`)}`, 'arena-line-verdict');
- _aBindSpeak(box);
- _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);
-}
-
-// ── Trash Talk ───────────────────────────────────────────────
-
-const ARENA_TALK_SCORES = [['spice', 'Spice', 'fa-pepper-hot'], ['wit', 'Wit', 'fa-brain'], ['on_topic', 'On topic', 'fa-bullseye'], ['delivery', 'Delivery', 'fa-microphone'], ['crowd', 'Crowd', 'fa-people-group']];
-let _arenaTalkTimers = [];
-function _aTalkStop() { for (const t of _arenaTalkTimers) clearInterval(t); _arenaTalkTimers = []; }
-
-function _aStampClass(stamp) { return `arena-stamp arena-stamp--${String(stamp || '').toLowerCase()}`; }
-function _aCountdown(iso) {
- const ms = Date.parse(iso) - Date.now();
- if (ms <= 0) return 'new topic any moment';
- const h = Math.floor(ms / 3600000), m = Math.floor((ms % 3600000) / 60000);
- return h ? `${h}h ${m}m left` : `${m}m left`;
-}
-
-/** Topic card on the Arena home. */
-function _aRenderTalkCard(t) {
- const el = document.getElementById('arena-talk-card');
- if (!el || !t || !t.topic) return;
- const top = t.entries && t.entries[0];
- el.style.display = '';
- el.innerHTML = `
- <div class="arena-talk-card-head"><h2><i class="fa-solid fa-microphone-lines"></i> Trash Talk <small>${_aEsc(_aCountdown(t.topic.ends_at))}</small></h2>${_aA('/arena/talk', `<i class="fa-solid fa-fire"></i> ${t.can_enter ? 'Enter now' : 'Open'}`, 'btn btn-primary')}</div>
- <p class="arena-talk-topic">“${_aEsc(t.topic.topic)}”</p>
- <div class="arena-talk-card-foot">
- <span>${t.entries?.length ? `${t.entries.length} entr${t.entries.length === 1 ? 'y' : 'ies'}` : 'No entries yet — be first'}${top ? ` · leading: <b>${_aEsc(top.fighter_name)}</b> ${top.total}/50 <span class="${_aStampClass(top.stamp)}">${_aEsc(top.stamp)}</span>` : ''}</span>
- <span class="arena-note">Streamers talk on the mic or type it · the AI judges · chat types <code>!hype</code> · up to +${t.rules?.bonus_max || 12} POWER</span>
- </div>`;
-}
-
-function _aScoreBars(scores, { animate = false } = {}) {
- if (!scores) return '';
- return `<div class="arena-talk-scores ${animate ? 'is-animated' : ''}">${ARENA_TALK_SCORES.map(([k, label, icon], i) => `
- <div class="arena-talk-score" style="--i:${i}">
- <span class="arena-talk-score-label"><i class="fa-solid ${icon}"></i> ${_aEsc(label)}</span>
- <span class="arena-talk-score-track"><span class="arena-talk-score-fill" style="width:${Math.min(100, (Number(scores[k]) || 0) * 10)}%"></span></span>
- <b>${Number(scores[k]) || 0}</b>
- </div>`).join('')}</div>`;
-}
-
-function _aTalkEntry(e, { compact = false, mine = false, hypeable = true } = {}) {
- const flagged = e.flagged;
- const jump = e.vod_id && e.start_sec != null ? `<a class="arena-talk-hear" href="/vod/${e.vod_id}?t=${e.start_sec}" onclick="return handleLinkClick(event, '/vod/${e.vod_id}?t=${e.start_sec}')" title="Hear them say it"><i class="fa-solid fa-play"></i> hear it</a>` : '';
- return `<div class="arena-talk-entry ${flagged ? 'is-flagged' : ''} ${mine ? 'is-mine' : ''}" data-id="${e.id}">
- <div class="arena-talk-entry-head">
- ${e.user ? _aA(_aFighterLink(e.user), `${_aPortrait({ user: e.user, image_url: e.image_url }, 'xs')}<span><strong>${_aEsc(e.fighter_name)}</strong><small>${_aEsc(e.user.display_name)}${e.rank ? ` · #${e.rank}` : ''} · ${e.source === 'mic' ? '<i class="fa-solid fa-microphone"></i> live mic' : '<i class="fa-solid fa-keyboard"></i> typed'}${e.topic ? ` · <em>${_aEsc(e.topic)}</em>` : ''}</small></span>`, 'arena-talk-who') : ''}
- <div class="arena-talk-total"><b>${e.total}</b><small>/50</small> <span class="${_aStampClass(e.stamp)}">${_aEsc(e.stamp || '')}</span></div>
- </div>
- ${e.text ? `<blockquote class="arena-talk-text">${_aEsc(e.text)} ${_aSpeakBtn(e.text)} ${jump}</blockquote>` : '<p class="arena-talk-void"><i class="fa-solid fa-ban"></i> Voided by the judge — crossed the line.</p>'}
- ${e.verdict ? `<p class="arena-talk-verdict"><i class="fa-solid fa-gavel"></i> ${_aEsc(e.verdict)}</p>` : ''}
- ${!compact ? _aScoreBars(e.scores) : ''}
- ${!compact && e.note && (mine || !flagged) ? `<p class="arena-talk-note">Judge's note: ${_aEsc(e.note)}</p>` : ''}
- ${!compact && !flagged ? `<div class="arena-talk-hype"><button class="btn btn-ghost btn-sm arena-hype-btn" data-id="${e.id}" ${!hypeable || !e.hype_open ? 'disabled' : ''}><i class="fa-solid fa-fire"></i> Hype</button><span class="arena-talk-hype-count">${e.crowd_uniques} hyped · Crowd ${e.scores?.crowd ?? 0}/10</span><span class="arena-note">or type <code>!hype</code> in their chat</span></div>` : ''}
- </div>`;
-}
-
-async function _aRenderTalk(root) {
- _aTalkStop();
- root.innerHTML = _aSpinner('Setting up the ring…');
- const t = await api('/arena/talk');
- const topic = t.topic;
- const me = (typeof currentUser !== 'undefined' && currentUser) ? currentUser : null;
- const rules = t.rules || {};
- root.innerHTML = `
- <div class="arena-back">${_aA('/arena', '<i class="fa-solid fa-arrow-left"></i> Arena')}</div>
- <div class="arena-talk-hero">
- <div class="arena-talk-hero-main">
- <div class="arena-talk-kicker"><i class="fa-solid fa-microphone-lines"></i> Trash Talk · topic changes every ${rules.topic_slot_hours || 6} h · <span id="arena-talk-countdown">${_aEsc(_aCountdown(topic.ends_at))}</span></div>
- <h1 class="arena-talk-topic-big">“${_aEsc(topic.topic)}”</h1>
- ${topic.hint ? `<p class="arena-talk-hint"><i class="fa-solid fa-lightbulb"></i> ${_aEsc(topic.hint)}</p>` : ''}
- </div>
- <div class="arena-talk-how">
- <h3>How it works</h3>
- <ol>
- <li><b>Streamers</b> on the roster answer the topic — on the <b>live mic</b> (we transcribe what you say on stream) or by <b>typing</b> it.</li>
- <li>The <b>AI judge</b> scores Spice, Wit, On-topic and Delivery (0–10 each). Slurs or cruelty void the entry.</li>
- <li><b>Viewers</b> add the fifth score: type <code>!hype</code> in that streamer's chat (or tap Hype here). 3 people = 1 point, up to 10.</li>
- <li>Your total (/50) becomes a <b>Trash Talk bonus</b> on POWER — up to +${rules.bonus_max || 12}, fading over ${rules.bonus_days || 7} days.</li>
- </ol>
- <p class="arena-note">Chat commands: <code>!talk</code> topic · <code>!hype</code> · <code>!arena</code> card · <code>!vote a|b</code> main event · <code>!fight <user></code></p>
- </div>
- </div>
- <section class="arena-sessions-live" id="arena-live-sessions" style="display:none"></section>
- <section id="arena-session-card"></section>
- <section class="arena-talk-enter" id="arena-talk-enter"></section>
- <section class="arena-board">
- <div class="arena-board-head"><h2>This topic's entries <small class="arena-note">${t.entries.length} so far</small></h2>${t.ai ? '' : '<span class="arena-note">AI judge is off — heuristic scoring</span>'}</div>
- <div class="arena-talk-list" id="arena-talk-entries">${t.entries.length ? t.entries.map(e => _aTalkEntry(e, { mine: me && e.user.id === me.id })).join('') : '<div class="arena-empty"><i class="fa-solid fa-microphone-slash"></i><p>Nobody has talked yet. The mic is right there.</p></div>'}</div>
- </section>
- ${t.hall_of_trash?.length ? `<section class="arena-board"><div class="arena-board-head"><h2><i class="fa-solid fa-trophy"></i> Hall of Trash <small class="arena-note">best lines, last 30 days</small></h2></div><div class="arena-talk-list">${t.hall_of_trash.map(e => _aTalkEntry(e, { compact: true })).join('')}</div></section>` : ''}`;
- _aBindSpeak(root);
- _aBindHype(root);
- _aRenderTalkEnter(t, me);
- _aRenderSessionCard(t, me);
- api('/arena/talk/sessions').then(r => _aRenderLiveSessions(r.sessions || [])).catch(() => {});
- _arenaTalkTimers.push(setInterval(() => { const el = document.getElementById('arena-talk-countdown'); if (el) el.textContent = _aCountdown(topic.ends_at); }, 30000));
-}
-
-function _aBindHype(root) {
- root.querySelectorAll('.arena-hype-btn').forEach(btn => btn.addEventListener('click', async () => {
- btn.disabled = true;
- try {
- const r = await api(`/arena/talk/${btn.dataset.id}/hype`, { method: 'POST' });
- const entry = btn.closest('.arena-talk-entry');
- entry.querySelector('.arena-talk-hype-count').textContent = `${r.crowd_uniques} hyped · Crowd ${r.crowd}/10`;
- entry.querySelector('.arena-talk-total').innerHTML = `<b>${r.total}</b><small>/50</small> <span class="${_aStampClass(r.stamp)}">${_aEsc(r.stamp)}</span>`;
- const crowdFill = entry.querySelectorAll('.arena-talk-score-fill')[4]; if (crowdFill) crowdFill.style.width = `${Math.min(100, r.crowd * 10)}%`;
- const crowdNum = entry.querySelectorAll('.arena-talk-score b')[4]; if (crowdNum) crowdNum.textContent = r.crowd;
- btn.innerHTML = r.added ? '<i class="fa-solid fa-fire"></i> Hyped!' : '<i class="fa-solid fa-check"></i> Already hyped';
- entry.classList.add('is-hit'); setTimeout(() => entry.classList.remove('is-hit'), 600);
- } catch (err) { btn.disabled = false; if (typeof showToast === 'function') showToast(err?.message || 'Hype failed', 'error'); }
- }));
-}
-
-/** The entry panel: live mic (countdown + hot-mic feed) or typed, then the score reveal. */
-function _aRenderTalkEnter(t, me) {
- const el = document.getElementById('arena-talk-enter');
- if (!el) return;
- if (!me) { el.innerHTML = `<div class="arena-talk-gate"><i class="fa-solid fa-right-to-bracket"></i> <b>Streamers:</b> sign in to enter. <b>Viewers:</b> hype an entry below, or type <code>!hype</code> in the streamer's chat.</div>`; return; }
- if (!t.on_roster) { el.innerHTML = `<div class="arena-talk-gate"><i class="fa-solid fa-hand-fist"></i> You're not on the roster yet — stream once and you're in. Until then: hype away.</div>`; return; }
- if (t.my_entry) { el.innerHTML = `<div class="arena-talk-gate is-done"><i class="fa-solid fa-circle-check"></i> You're in this round — <b>${t.my_entry.total}/50 · ${_aEsc(t.my_entry.stamp)}</b>. Next topic in ${_aEsc(_aCountdown(t.topic.ends_at))}. Tell chat to <code>!hype</code>.</div>`; return; }
- const mic = t.mic || {};
- el.innerHTML = `
- <div class="arena-talk-panel">
- <div class="arena-talk-panel-head"><h2><i class="fa-solid fa-fire"></i> Your turn</h2><span class="arena-note">one entry per topic</span></div>
- <div class="arena-talk-modes">
- <button class="arena-talk-mode ${mic.available ? 'active' : ''}" data-mode="mic" ${mic.available ? '' : 'disabled'} title="${mic.available ? 'Say it on stream — we transcribe it' : mic.reason === 'not_live' ? 'Go live to use the live mic' : 'Live, but no transcription yet — talk for a few seconds and reload, or type it'}"><i class="fa-solid fa-microphone"></i> Live mic${mic.available ? '' : mic.reason === 'not_live' ? ' <small>(go live)</small>' : ' <small>(no transcript yet)</small>'}</button>
- <button class="arena-talk-mode ${mic.available ? '' : 'active'}" data-mode="text"><i class="fa-solid fa-keyboard"></i> Type it</button>
- </div>
- <div class="arena-talk-mode-body" id="arena-talk-mode-body"></div>
- <div class="arena-talk-result" id="arena-talk-result"></div>
- </div>`;
- const body = document.getElementById('arena-talk-mode-body');
- const renderMode = (mode) => {
- el.querySelectorAll('.arena-talk-mode').forEach(b => b.classList.toggle('active', b.dataset.mode === mode));
- if (mode === 'text') {
- body.innerHTML = `<textarea id="arena-talk-text" maxlength="${t.rules.text_max}" rows="3" placeholder="Talk your talk… (${t.rules.text_max} characters, PG-13, petty not cruel)"></textarea>
- <div class="arena-talk-actions"><span class="arena-note" id="arena-talk-count">0 / ${t.rules.text_max}</span><button class="btn btn-primary" id="arena-talk-submit"><i class="fa-solid fa-gavel"></i> Send it to the judge</button></div>`;
- const ta = document.getElementById('arena-talk-text');
- ta.addEventListener('input', () => { document.getElementById('arena-talk-count').textContent = `${ta.value.length} / ${t.rules.text_max}`; });
- document.getElementById('arena-talk-submit').addEventListener('click', () => submit('text', ta.value));
- } else {
- body.innerHTML = `<div class="arena-mic">
- <div class="arena-mic-status" id="arena-mic-status"><span class="arena-mic-dot"></span> Ready. Press start, then say it on stream — you have ${t.rules.mic_window_sec}s.</div>
- <div class="arena-mic-feed" id="arena-mic-feed"><span class="arena-note">Hot mic — what the transcription hears shows up here (a few seconds behind).</span></div>
- <div class="arena-talk-actions"><button class="btn btn-primary" id="arena-mic-start"><i class="fa-solid fa-microphone"></i> I'm about to cook</button><button class="btn btn-ghost" id="arena-mic-drop" disabled><i class="fa-solid fa-hand-point-down"></i> Drop the mic</button></div>
- </div>`;
- let poll = null;
- document.getElementById('arena-mic-start').addEventListener('click', async () => {
- try {
- await api('/arena/talk/mic/start', { method: 'POST' });
- document.getElementById('arena-mic-start').disabled = true; document.getElementById('arena-mic-drop').disabled = false;
- const status = document.getElementById('arena-mic-status'), feed = document.getElementById('arena-mic-feed');
- status.classList.add('is-live');
- poll = setInterval(async () => {
- try {
- const f = await api('/arena/talk/mic/feed');
- if (!f.active) return;
- status.innerHTML = `<span class="arena-mic-dot"></span> LIVE MIC · ${f.remaining_sec}s left · ${f.lines.length} line${f.lines.length === 1 ? '' : 's'} heard`;
- if (f.lines.length) feed.innerHTML = f.lines.map(l => `<div class="arena-mic-line"><span>${_aStamp(l.at)}</span>${_aEsc(l.text)}</div>`).join('');
- if (f.remaining_sec <= 0) { clearInterval(poll); poll = null; submit('mic'); }
- } catch { /* */ }
- }, 2500);
- _arenaTalkTimers.push(poll);
- } catch (err) { if (typeof showToast === 'function') showToast(err?.message || 'Could not start', 'error'); }
- });
- document.getElementById('arena-mic-drop').addEventListener('click', () => { if (poll) { clearInterval(poll); poll = null; } submit('mic'); });
- }
- };
- const submit = async (mode, text) => {
- const res = document.getElementById('arena-talk-result');
- el.querySelectorAll('button').forEach(b => b.disabled = true);
- res.innerHTML = `<div class="arena-loading"><i class="fa-solid fa-gavel fa-shake"></i><span>The judge is reading…</span></div>`;
- try {
- const r = await api('/arena/talk/submit', { method: 'POST', body: { mode, text } });
- const e = r.entry;
- body.innerHTML = '';
- res.innerHTML = `<div class="arena-talk-reveal ${e.flagged ? 'is-flagged' : ''}">
- <div class="arena-talk-reveal-head"><span class="arena-talk-reveal-total"><b id="arena-reveal-total">0</b><small>/50</small></span><span class="${_aStampClass(e.stamp)} arena-stamp--big" id="arena-reveal-stamp">${_aEsc(e.stamp)}</span></div>
- <blockquote class="arena-talk-text">${_aEsc(e.text || '')}</blockquote>
- ${_aScoreBars(e.scores || { spice: 0, wit: 0, on_topic: 0, delivery: 0, crowd: 0 }, { animate: true })}
- <p class="arena-talk-verdict"><i class="fa-solid fa-gavel"></i> ${_aEsc(e.verdict || '')}</p>
- ${e.note ? `<p class="arena-talk-note">Judge's note: ${_aEsc(e.note)}</p>` : ''}
- <p class="arena-note">${e.flagged ? 'This entry was voided and is hidden from others.' : `Now tell chat to type <code>!hype</code> — every 3 people add a Crowd point. Bonus lands on your POWER within a minute.`}</p>
- </div>`;
- const totalEl = document.getElementById('arena-reveal-total');
- const start = performance.now(), dur = 1400;
- const tick = (now) => { const p = Math.min(1, (now - start) / dur); totalEl.textContent = (e.total * (1 - Math.pow(1 - p, 3))).toFixed(p < 1 ? 1 : 0); if (p < 1) requestAnimationFrame(tick); else totalEl.textContent = e.total; };
- requestAnimationFrame(tick);
- const list = document.getElementById('arena-talk-entries');
- if (list) { const empty = list.querySelector('.arena-empty'); if (empty) empty.remove(); list.insertAdjacentHTML('afterbegin', _aTalkEntry(e, { mine: true, hypeable: false })); _aBindSpeak(list); _aBindHype(list); }
- } catch (err) {
- res.innerHTML = `<div class="arena-talk-gate is-error"><i class="fa-solid fa-triangle-exclamation"></i> ${_aEsc(err?.message || 'The judge walked out.')}</div>`;
- el.querySelectorAll('button').forEach(b => b.disabled = false);
- }
- };
- el.querySelectorAll('.arena-talk-mode').forEach(b => b.addEventListener('click', () => renderMode(b.dataset.mode)));
- renderMode(mic.available ? 'mic' : 'text');
-}
-
-// ── Live trash-talk sessions ─────────────────────────────────
-
-function _aSessionLink(u) { return `/arena/talk/${encodeURIComponent(u.username)}`; }
-
-function _aRenderLiveSessions(list) {
- const el = document.getElementById('arena-live-sessions');
- if (!el) return;
- if (!list.length) { el.style.display = 'none'; return; }
- el.style.display = '';
- el.innerHTML = `<h2><span class="arena-live-dot"></span> Talking trash right now</h2><div class="arena-session-strip">${list.map(s => _aA(_aSessionLink(s.user), `<span class="arena-live-dot"></span><span><strong>${_aEsc(s.fighter_name)}</strong><small>${_aEsc(s.topic || '…')} · ${s.progress}%</small></span><span class="arena-session-lvl">LVL ${s.level}</span>`, 'arena-session-chip')).join('')}</div>`;
-}
-
-/** Streamer-side card on /arena/talk: start a live session, or jump to the running one. */
-async function _aRenderSessionCard(t, me) {
- const el = document.getElementById('arena-session-card');
- if (!el || !me || !t.on_roster) return;
- let view = null;
- try { view = await api(`/arena/talk/session/${encodeURIComponent(me.username)}`); } catch { view = null; }
- const live = view?.session && view.session.status === 'live';
- const mic = t.mic || {};
- el.innerHTML = `<div class="arena-talk-panel">
- <div class="arena-talk-panel-head"><h2><i class="fa-solid fa-tower-broadcast"></i> Live session <span class="arena-note" style="font-weight:400">power-level by talking trash on stream</span></h2>${live ? '<span class="arena-session-status"><span class="arena-live-dot"></span> live now</span>' : ''}</div>
- <p class="arena-note" style="margin:0 0 10px">Start a session while you're live and the transcription does the rest: talk trash on the current topic, the judge scores it every ~30 s, the bar fills, the topic changes when you've cleared it, and every cleared topic counts for your POWER. Viewers push it with <code>!hype</code>.</p>
- <div class="arena-session-controls">
- ${live ? _aA(_aSessionLink(me), '<i class="fa-solid fa-satellite-dish"></i> Open your live console', 'btn btn-primary')
- : `<button class="btn btn-primary" id="arena-session-start" ${mic.available ? '' : 'disabled'} title="${mic.available ? '' : mic.reason === 'not_live' ? 'Go live first' : 'Live, but no transcription yet — talk for a few seconds and reload'}"><i class="fa-solid fa-play"></i> Start live session${mic.available ? '' : mic.reason === 'not_live' ? ' <small>(go live first)</small>' : ' <small>(waiting for transcription)</small>'}</button>`}
- ${view?.session && !live ? `<span class="arena-note">Last session: level ${view.session.level}, ${view.session.topics_cleared} topic${view.session.topics_cleared === 1 ? '' : 's'} cleared${view.session.end_reason ? ` (${_aEsc(String(view.session.end_reason).replace('_', ' '))})` : ''}</span>` : ''}
- </div>
- </div>`;
- el.querySelector('#arena-session-start')?.addEventListener('click', async (e) => {
- e.target.disabled = true;
- try { await api('/arena/talk/session/start', { method: 'POST' }); navigate(_aSessionLink(me)); }
- catch (err) { e.target.disabled = false; if (typeof showToast === 'function') showToast(err?.message || 'Could not start', 'error'); }
- });
-}
-
-async function _aRenderSession(root, username) {
- _aTalkStop();
- root.innerHTML = _aSpinner('Tuning in…');
- let v;
- try { v = await api(`/arena/talk/session/${encodeURIComponent(username)}`); }
- catch (err) { root.innerHTML = `<div class="arena-empty"><i class="fa-solid fa-user-slash"></i><p>${_aEsc(err?.message || 'No such fighter')}</p></div>`; return; }
- const me = (typeof currentUser !== 'undefined' && currentUser) ? currentUser : null;
- const mine = !!(me && me.id === v.user.id);
- if (!v.session) {
- root.innerHTML = `<div class="arena-back">${_aA('/arena/talk', '<i class="fa-solid fa-arrow-left"></i> Trash Talk')}</div><div class="arena-empty"><i class="fa-solid fa-microphone-slash"></i><p><strong>${_aEsc(v.fighter_name)}</strong> hasn't run a live trash-talk session yet.${mine ? ' Start one from the Trash Talk page while you are live.' : ''}</p>${_aA('/arena/talk', 'Back', 'btn')}</div>`;
- return;
- }
- let last = null;
- const render = (s) => {
- const t = s.active_topic;
- const live = s.status === 'live';
- const xpInLevel = s.xp - (s.level - 1) * s.xp_per_level;
- const prev = last; last = s;
- if (prev && s.level > prev.level) _aLevelUp(s.level);
- const clearedFlash = prev && s.topics_cleared > prev.topics_cleared;
- const lines = s.recent_lines || [];
- const newestAt = prev ? Math.max(0, ...(prev.recent_lines || []).map(l => l.at)) : -1;
- root.innerHTML = `
- <div class="arena-back">${_aA('/arena/talk', '<i class="fa-solid fa-arrow-left"></i> Trash Talk')} ${_aA(_aFighterLink(v.user), `<i class="fa-solid fa-id-card"></i> ${_aEsc(v.fighter_name)}`)}</div>
- <div class="arena-session">
- <div class="arena-session-main">
- <div class="arena-session-head">
- <div class="arena-session-who">${_aPortrait({ user: v.user, image_url: v.image_url }, 'sm')}<div><strong>${_aEsc(v.fighter_name)}</strong><div class="arena-session-status ${live ? '' : 'is-ended'}">${live ? '<span class="arena-live-dot"></span> talking trash live' : `session ended${s.end_reason ? ' · ' + _aEsc(String(s.end_reason).replace('_', ' ')) : ''}`}</div></div></div>
- <div class="arena-session-level"><div><b>LVL ${s.level}</b><small>TRASH LEVEL</small></div><div class="arena-xp"><div class="arena-xp-track"><span class="arena-xp-fill" style="width:${Math.min(100, (xpInLevel / s.xp_per_level) * 100)}%"></span></div><div class="arena-xp-label">${s.xp} XP · ${s.next_level_xp - s.xp} to next</div></div></div>
- </div>
- ${t ? `<div class="arena-session-topic ${clearedFlash ? 'is-cleared-flash' : ''}">
- <div class="arena-session-topic-kicker">Topic ${t.idx + 1} · ${_aEsc(t.tone || '')} <span>${live ? 'talk about this — the judge listens every ~30 s' : 'session over'}</span></div>
- <h2>“${_aEsc(t.topic)}”</h2>
- ${t.hint ? `<p class="arena-session-hint"><i class="fa-solid fa-lightbulb"></i> ${_aEsc(t.hint)}</p>` : ''}
- <div class="arena-progress"><span class="arena-progress-fill" style="width:${t.progress}%"></span></div>
- <div class="arena-progress-label"><span>${t.progress}% cleared · ${t.hits} hit${t.hits === 1 ? '' : 's'} · ${t.hypers} hyping</span><span>${t.progress >= 100 ? 'CLEARED' : `${100 - t.progress}% to the next topic`}</span></div>
- ${t.last_judgement ? `<div class="arena-session-verdict"><span class="${t.last_judgement.is_trash_talk ? 'is-hit' : 'is-miss'}">${t.last_judgement.is_trash_talk ? '<i class="fa-solid fa-fire"></i> that was trash talk' : '<i class="fa-solid fa-ellipsis"></i> not trash talk (yet)'}</span> · quality ${t.last_judgement.quality}/10 · “${_aEsc(t.last_judgement.about || '')}”${t.last_judgement.fallback ? ' · heuristic judge' : ''}</div>` : (live ? '<div class="arena-session-verdict"><span class="is-miss"><i class="fa-solid fa-ear-listen"></i> listening…</span> say something spicy</div>' : '')}
- ${t.best_line ? `<p class="arena-talk-note">Best line so far: “${_aEsc(t.best_line)}” ${t.best_vod_id ? `<a class="arena-talk-hear" href="/vod/${t.best_vod_id}?t=${t.best_line_sec}" onclick="return handleLinkClick(event, '/vod/${t.best_vod_id}?t=${t.best_line_sec}')"><i class="fa-solid fa-play"></i> hear it</a>` : ''}</p>` : ''}
- <div class="arena-session-controls">
- ${mine && live ? `<button class="btn btn-ghost" id="arena-session-skip"><i class="fa-solid fa-forward"></i> Skip topic</button><button class="btn btn-ghost" id="arena-session-stop"><i class="fa-solid fa-stop"></i> End session</button>` : ''}
- ${!mine && live ? `<button class="btn btn-primary" id="arena-session-hype"><i class="fa-solid fa-fire"></i> Hype</button><span class="arena-note">or type <code>!hype</code> in their chat</span>` : ''}
- ${_aA(_aChannelLink(v.user), '<i class="fa-solid fa-tv"></i> Watch the stream', 'btn btn-ghost')}
- </div>
- </div>` : ''}
- <div class="arena-session-feed">
- <div class="arena-session-feed-head"><span><i class="fa-solid fa-microphone"></i> Hot mic</span><span>${s.lines_seen} lines · ${_aNum(s.words_seen)} words this session</span></div>
- ${lines.length ? lines.map(l => `<div class="arena-session-line ${l.at > newestAt ? 'is-new' : ''}"><span>${_aStamp(l.at)}</span><span>${_aEsc(l.text)}</span>${l.vod_id ? `<a href="/vod/${l.vod_id}?t=${Math.max(0, l.sec - 2)}" onclick="return handleLinkClick(event, '/vod/${l.vod_id}?t=${Math.max(0, l.sec - 2)}')" title="Hear it"><i class="fa-solid fa-play"></i></a>` : '<span></span>'}</div>`).join('') : '<p class="arena-voice-empty">Nothing heard yet — the transcription runs a few seconds behind.</p>'}
- </div>
- </div>
- <div class="arena-session-side">
- <div class="arena-session-stats"><div class="arena-session-stat"><b>${s.topics_cleared}</b><span>topics cleared</span></div><div class="arena-session-stat"><b>${s.xp}</b><span>xp</span></div><div class="arena-session-stat"><b>${v.talk_bonus || 0}</b><span>power bonus</span></div></div>
- <div class="arena-session-card"><h3><i class="fa-solid fa-tags"></i> What they talked about</h3>${s.talked_about.length ? `<div class="arena-about">${s.talked_about.map(a => `<span class="${a.hit ? 'is-hit' : ''}" title="topic ${a.topic_idx + 1}">${_aEsc(a.text)}</span>`).join('')}</div>` : '<p class="arena-voice-empty">Tags appear after the first judged chunk.</p>'}</div>
- <div class="arena-session-card"><h3><i class="fa-solid fa-list-check"></i> Topics this session</h3>${s.cleared_topics.length ? `<div class="arena-cleared-list">${s.cleared_topics.slice().reverse().map(c => `<div class="arena-cleared ${c.status === 'skipped' ? 'is-skipped' : ''}"><b>${c.status === 'cleared' ? '<i class="fa-solid fa-check"></i>' : '<i class="fa-solid fa-forward"></i>'} ${_aEsc(c.topic)}</b>${c.best_line ? `<q>${_aEsc(c.best_line)}</q> ${c.best_vod_id ? `<a class="arena-talk-hear" href="/vod/${c.best_vod_id}?t=${c.best_line_sec}" onclick="return handleLinkClick(event, '/vod/${c.best_vod_id}?t=${c.best_line_sec}')"><i class="fa-solid fa-play"></i></a>` : ''}` : ''}<small>${c.status === 'cleared' ? `cleared · score ${c.score} · ${c.hits} hit${c.hits === 1 ? '' : 's'}` : 'skipped'}</small></div>`).join('')}</div>` : '<p class="arena-voice-empty">No topics cleared yet.</p>'}</div>
- </div>
- </div>`;
- root.querySelector('#arena-session-skip')?.addEventListener('click', async () => { try { await api('/arena/talk/session/skip', { method: 'POST' }); refresh(); } catch (err) { if (typeof showToast === 'function') showToast(err?.message, 'error'); } });
- root.querySelector('#arena-session-stop')?.addEventListener('click', async () => { if (!confirm('End your trash-talk session?')) return; try { await api('/arena/talk/session/stop', { method: 'POST' }); refresh(); } catch (err) { if (typeof showToast === 'function') showToast(err?.message, 'error'); } });
- root.querySelector('#arena-session-hype')?.addEventListener('click', async (e) => {
- e.target.disabled = true;
- try { const r = await api(`/arena/talk/session/${encodeURIComponent(v.user.username)}/hype`, { method: 'POST' }); e.target.innerHTML = r.added ? '<i class="fa-solid fa-fire"></i> Hyped!' : '<i class="fa-solid fa-check"></i> Already hyped'; refresh(); }
- catch (err) { e.target.disabled = false; if (typeof showToast === 'function') showToast(err?.message || 'Hype failed', 'error'); }
- });
- };
- const refresh = async () => {
- if (currentPage !== 'arena') return _aTalkStop();
- try { const nv = await api(`/arena/talk/session/${encodeURIComponent(username)}`); if (nv.session) { v = nv; render(nv.session); } } catch { /* keep */ }
- };
- render(v.session);
- if (v.session.status === 'live') _arenaTalkTimers.push(setInterval(refresh, 4000));
-}
-
function _aLevelUp(level) {
function _aLevelUp(level) {
const el = document.createElement('div');
const el = document.createElement('div');
el.className = 'arena-levelup';
el.className = 'arena-levelup';
@@ -1,77 +1,138 @@
@@ -1,77 +1,138 @@
/**
/**
- * OpenVibe.Live — Arena chat commands (viewers play along from chat)
+ * OpenVibe.Live — Arena chat commands (viewers play along from any stream chat)
*
*
- * !hype hype the streamer's latest trash-talk entry (one per person; Crowd score)
- * !talk show the current trash-talk topic + how to enter
- * !arena [user] fighter card summary + link (defaults to the streamer whose chat this is)
- * !vote a|b vote in today's Main Event (signed-in only)
- * !fight <user> post a battle link: this streamer vs <user>
+ * !topic <text> start a board topic from chat (one per person per 10 min)
+ * !bounty <user> put a bounty on a fighter: double XP for anyone who talks shit about them (1/hour)
+ * !hype hype the streamer: their newest open beef, else their active board topic
+ * !side <name|a|b> pick a side in the streamer's newest open beef (clout if your pick wins)
+ * !beef what beefs this streamer has open + clocks
+ * !board the pulse + hottest events on the board
+ * !arena [user] fighter card summary + link
*
*
* Called from ChatServer.handleBangCommand; returns true when the command was handled.
* Called from ChatServer.handleBangCommand; returns true when the command was handled.
- * Replies go to the sender as a system line; hype milestones are announced to the room.
+ * Replies go to the sender as a system line; milestones are announced to the room.
*/
*/
'use strict';
'use strict';
const db = require('../db/database');
const db = require('../db/database');
+const COMMANDS = ['!topic', '!bounty', '!hype', '!side', '!beef', '!board', '!arena'];
const RATE_MS = 4000;
const RATE_MS = 4000;
-const _last = new Map(); // voterKey → ms
+const TOPIC_RATE_MS = 10 * 60 * 1000;
+const BOUNTY_RATE_MS = 60 * 60 * 1000;
+const _last = new Map(); // voterKey → ms
+const _lastTopic = new Map(); // voterKey → ms
+const _lastBounty = new Map();
function voterKey(client) {
function voterKey(client) {
if (client.user?.id) return `user:${client.user.id}`;
if (client.user?.id) return `user:${client.user.id}`;
if (client.anonId) return `anon:${client.anonId}`;
if (client.anonId) return `anon:${client.anonId}`;
return `ip:${String(client.ip || '')}`;
return `ip:${String(client.ip || '')}`;
}
}
-
-function rateLimited(key) {
+function limited(map, key, ms) {
const now = Date.now();
const now = Date.now();
- if (now - (_last.get(key) || 0) < RATE_MS) return true;
- _last.set(key, now);
+ if (now - (map.get(key) || 0) < ms) return true;
+ map.set(key, now);
return false;
return false;
}
}
-
-function base() {
- try { const c = require('../config'); return String(c.baseUrl || '').replace(/\/$/, ''); } catch { return ''; }
+function base() { try { const c = require('../config'); return String(c.baseUrl || '').replace(/\/$/, ''); } catch { return ''; } }
+function clock(b) {
+ if (!b.on_clock || b.clock_seconds_left == null) return '';
+ const s = b.clock_seconds_left, who = (b.on_clock === 'a' ? b.a : b.b).fighter_name;
+ const t = s >= 3600 ? `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m` : `${Math.floor(s / 60)}m ${s % 60}s`;
+ return ` · ${who} on the clock (${t} to answer)`;
}
}
+function beefLine(b) { return `${b.a.fighter_name} ${b.share_a}% — ${100 - b.share_a}% ${b.b.fighter_name}${clock(b)} → ${base()}/arena/beef/${b.id}`; }
function handle(chat, ws, client, cmd, parts) {
function handle(chat, ws, client, cmd, parts) {
- if (!['!hype', '!talk', '!arena', '!vote', '!fight'].includes(cmd)) return false;
+ if (!COMMANDS.includes(cmd)) return false;
const reply = (message) => chat.sendTo(ws, { type: 'system', message });
const reply = (message) => chat.sendTo(ws, { type: 'system', message });
- let arena, talk;
- try { arena = require('./arena-service'); talk = require('./trash-talk'); } catch { reply('The Arena is closed right now.'); return true; }
+ const room = (message) => client.streamId && chat.broadcastToStream(client.streamId, { type: 'system', message });
+ let arena, board, beef;
+ try { arena = require('./arena-service'); board = require('./board'); beef = require('./beef'); } catch { reply('The Arena is closed right now.'); return true; }
if (!arena.arenaEnabled()) { reply('The Arena is closed right now.'); return true; }
if (!arena.arenaEnabled()) { reply('The Arena is closed right now.'); return true; }
const key = voterKey(client);
const key = voterKey(client);
- if (rateLimited(key)) { reply('Easy — one Arena command every few seconds.'); return true; }
+ if (limited(_last, key, RATE_MS)) { reply('Easy — one Arena command every few seconds.'); return true; }
const stream = client.streamId ? db.getStreamById(client.streamId) : null;
const stream = client.streamId ? db.getStreamById(client.streamId) : null;
const streamer = stream ? db.getUserById(stream.user_id) : null;
const streamer = stream ? db.getUserById(stream.user_id) : null;
+ const who = client.user?.display_name || client.user?.username || 'Chat';
(async () => {
(async () => {
try {
try {
+ if (cmd === '!topic') {
+ if (!client.user?.id) return reply('Sign in to start a topic.');
+ const text = parts.slice(1).join(' ').trim();
+ if (!text) return reply('Usage: !topic <something the streamers should talk shit about>');
+ if (limited(_lastTopic, key, TOPIC_RATE_MS)) return reply('One topic per 10 minutes — let the last one cook.');
+ const onRoster = !!arena.loadRoster().byId[client.user.id];
+ const t = board.createTopic({ text, createdBy: onRoster ? 'streamer' : 'chat', creatorUserId: client.user.id, creatorName: who });
+ board.ensureAngles(t).catch(() => {});
+ reply(`📌 Topic is on the board: “${t.text}” → ${base()}/arena/topic/${t.id}`);
+ room(`📌 ${who} put a topic on the Arena board: “${t.text}” — streamers, click it and start talking. ${base()}/arena/topic/${t.id}`);
+ return;
+ }
+ if (cmd === '!bounty') {
+ if (!client.user?.id) return reply('Sign in to put up a bounty.');
+ const target = parts[1] ? db.getUserByUsername(String(parts[1]).replace(/^@/, '')) : null;
+ if (!target) return reply('Usage: !bounty <username> — double XP for anyone who talks shit about them on stream.');
+ const roster = arena.loadRoster();
+ if (!roster.byId[target.id]) return reply(`${target.display_name || target.username} isn't on the Arena roster.`);
+ if (target.id === client.user.id) return reply("You can't put a bounty on yourself. Nice try.");
+ if (board.openBountyOn(target.id)) return reply(`There's already a bounty on ${target.display_name || target.username}. Go collect it.`);
+ if (limited(_lastBounty, key, BOUNTY_RATE_MS)) return reply('One bounty per hour per person.');
+ const name = board.fighterBrief(target.id, roster).fighter_name;
+ const t = board.createTopic({ text: `Bounty: ${name}. Anyone who talks shit about them on stream gets double XP.`, hint: `Put up by ${who}. Say the name, collect the bag.`, createdBy: 'chat', creatorUserId: client.user.id, creatorName: who, kind: 'bounty', targetUserId: target.id, headline: `WANTED: ${name} — chat wants smoke` });
+ reply(`💰 Bounty posted on ${name} → ${base()}/arena/topic/${t.id}`);
+ room(`💰 ${who} put a bounty on ${name}. Any streamer who talks shit about them on mic gets double XP for the next ${board.KIND_TTL_HOURS.bounty} hours.`);
+ return;
+ }
if (cmd === '!hype') {
if (cmd === '!hype') {
- if (!streamer) return reply('!hype works inside a streamer\'s chat.');
- // A live session (they are talking trash on stream right now) takes priority.
- try {
- const s = require('./talk-session').hypeSession(streamer.id, key);
- if (s) {
- if (!s.added) return reply(`You already hyped this topic. ${s.hypers} hyping · level ${s.level} · ${s.progress}% to the next topic.`);
- reply(`🔥 Hyped the live session! ${s.hypers} hyping · level ${s.level} (${s.xp} XP) · topic ${s.progress}% cleared → ${base()}/arena/talk/${encodeURIComponent(streamer.username)}`);
- if (s.hypers === 1 || s.hypers % 5 === 0) chat.broadcastToStream(client.streamId, { type: 'system', message: `🎤 ${streamer.display_name || streamer.username} is talking trash live — ${s.hypers} hyping. Type !hype to push the topic. ${base()}/arena/talk/${encodeURIComponent(streamer.username)}` });
- return;
- }
- } catch (e) { return reply(`Arena: ${e.message}`); }
- const entry = talk.latestEntryFor(streamer.id);
- if (!entry) return reply(`${streamer.display_name || streamer.username} hasn't entered the current Trash Talk topic yet — ${base()}/arena/talk`);
- const r = talk.hype(entry.id, key);
- if (!r.added) return reply(`You already hyped this one. Crowd score ${r.crowd}/10 (${r.crowd_uniques} hyped).`);
- reply(`🔥 Hyped! Crowd score ${r.crowd}/10 (${r.crowd_uniques} hyped) · total ${r.total}/50 · ${r.stamp}`);
- if (r.crowd_uniques === 1 || r.crowd_uniques % 5 === 0) {
- chat.broadcastToStream(client.streamId, { type: 'system', message: `🎤 Chat is hyping ${streamer.display_name || streamer.username}'s trash talk — ${r.crowd_uniques} so far. Type !hype to add yours.` });
+ if (!streamer) return reply("!hype works inside a streamer's chat.");
+ const open = beef.openBeefsFor(streamer.id);
+ if (open.length) {
+ const b = open[0];
+ const side = b.a_user_id === streamer.id ? 'a' : 'b';
+ const r = beef.hype(b.id, side, key);
+ const v = beef.get(b.id);
+ if (!r.added) return reply(`You already hyped this beef. ${beefLine(v)}`);
+ reply(`🔥 Hyped ${streamer.display_name || streamer.username} in their beef. ${beefLine(v)}`);
+ const n = side === 'a' ? v.a.crowd : v.b.crowd;
+ if (n === 1 || n % 5 === 0) room(`🔥 Chat is hyping ${streamer.display_name || streamer.username}'s beef with ${(side === 'a' ? v.b : v.a).fighter_name} — ${n} so far. Type !hype to add yours. ${base()}/arena/beef/${v.id}`);
+ return;
}
}
+ const t = board.activeTopicFor(streamer.id);
+ if (!t) return reply(`${streamer.display_name || streamer.username} has no beef open and isn't on a board topic. Start one: !topic <text> · ${base()}/arena`);
+ const r = board.hypeTopic(t.id, streamer.id, key);
+ if (!r.added) return reply(`You already hyped them on “${t.text}”. ${r.hypers} hyping.`);
+ reply(`🔥 Hyped ${streamer.display_name || streamer.username} on “${t.text}” — ${r.hypers} hyping → ${base()}/arena/topic/${t.id}`);
+ if (r.hypers === 1 || r.hypers % 5 === 0) room(`🎤 ${streamer.display_name || streamer.username} is talking shit on “${t.text}” — ${r.hypers} hyping. Type !hype. ${base()}/arena/topic/${t.id}`);
return;
return;
}
}
- if (cmd === '!talk') {
- const t = await talk.getTopic({ generate: false });
- return reply(`🎤 Trash Talk topic: "${t.topic}" — ${t.hint || ''} Streamers enter at ${base()}/arena/talk; viewers type !hype in their chat to boost the Crowd score.`);
+ if (cmd === '!side') {
+ if (!streamer) return reply("!side works inside a streamer's chat.");
+ const open = beef.openBeefsFor(streamer.id);
+ if (!open.length) return reply(`${streamer.display_name || streamer.username} has no open beef to pick a side in.`);
+ const b = beef.get(open[0].id);
+ const arg = String(parts[1] || '').toLowerCase().replace(/^@/, '');
+ const matches = (side) => [side.user.username, side.user.display_name, side.fighter_name].filter(Boolean).some(n => String(n).toLowerCase() === arg);
+ const pick = arg === 'a' || matches(b.a) ? 'a' : arg === 'b' || matches(b.b) ? 'b' : !arg ? (b.a.user.id === streamer.id ? 'a' : 'b') : null;
+ if (!pick) return reply(`Usage: !side <name> — (a) ${b.a.fighter_name} vs (b) ${b.b.fighter_name}`);
+ const r = beef.pickSide(b.id, key, pick, client.user?.id || null);
+ return reply(`🗳️ You're with ${(pick === 'a' ? b.a : b.b).fighter_name}. Crowd: ${r.a}–${r.b}. Pick right and you earn clout. ${base()}/arena/beef/${b.id}`);
+ }
+ if (cmd === '!beef') {
+ if (!streamer) return reply(`Open beefs → ${base()}/arena`);
+ const open = beef.openBeefsFor(streamer.id).map(b => beef.get(b.id));
+ if (!open.length) {
+ const lvl = board.levelView(streamer.id);
+ return reply(`${streamer.display_name || streamer.username} has no beef open right now (Trash Level ${lvl.level}). Another streamer only has to say their name on mic… ${base()}/arena`);
+ }
+ return reply(open.map(b => `🥊 ${b.headline || `${b.a.fighter_name} vs ${b.b.fighter_name}`}: ${beefLine(b)}`).join(' · '));
+ }
+ if (cmd === '!board') {
+ const v = board.boardView();
+ const top = v.open.slice(0, 3).map(t => `${t.hot ? '🔥 ' : ''}${t.kind === 'debate' ? `${t.side_a} vs ${t.side_b}` : t.kind === 'bounty' ? `Bounty: ${t.target?.fighter_name}` : t.kind === 'phrase' ? `Say “${t.phrase}”` : t.text} (${t.heat} heat)`).join(' · ');
+ return reply(`📰 ${v.pulse?.text || 'The board is quiet.'} ${top ? `Hottest: ${top}` : ''} → ${base()}/arena · !topic <text> to add one`);
}
}
if (cmd === '!arena') {
if (cmd === '!arena') {
const target = parts[1] ? db.getUserByUsername(String(parts[1]).replace(/^@/, '')) : streamer;
const target = parts[1] ? db.getUserByUsername(String(parts[1]).replace(/^@/, '')) : streamer;
@@ -79,26 +140,7 @@ function handle(chat, ws, client, cmd, parts) {
@@ -79,26 +140,7 @@ function handle(chat, ws, client, cmd, parts) {
const card = await arena.getFighter(target.id, { generate: false });
const card = await arena.getFighter(target.id, { generate: false });
if (!card || card.not_on_roster) return reply(`${target.display_name || target.username} isn't on the Arena roster yet.`);
if (!card || card.not_on_roster) return reply(`${target.display_name || target.username} isn't on the Arena roster yet.`);
const r = card.ratings;
const r = card.ratings;
- return reply(`🥊 ${card.persona.fighter_name} — ${card.persona.title} · #${card.rank} of ${card.roster_size} · PWR ${r.power}${r.talk_bonus ? ` (+${r.talk_bonus} trash talk)` : ''} · ${card.record.wins}W–${card.record.losses}L · "${card.persona.taunt}" → ${base()}/arena/${encodeURIComponent(target.username)}`);
- }
- if (cmd === '!vote') {
- if (!client.user?.id) return reply('Sign in to vote in the Main Event.');
- const side = String(parts[1] || '').toLowerCase();
- const me = await arena.getMainEvent({ generate: false });
- if (!me) return reply('No Main Event today.');
- const pick = side === 'a' || side === me.a.user.username.toLowerCase() ? 'a' : side === 'b' || side === me.b.user.username.toLowerCase() ? 'b' : null;
- if (!pick) return reply(`Usage: !vote a|b — today: (a) ${me.a.persona.fighter_name} vs (b) ${me.b.persona.fighter_name}`);
- const r = arena.castVote(me.id, `user:${client.user.id}`, pick);
- return reply(`🗳️ Voted ${(pick === 'a' ? me.a : me.b).persona.fighter_name}. Crowd: ${r.votes.a}–${r.votes.b} · score ${r.outcome.a}–${r.outcome.b}${r.outcome.winner ? ` · ${(r.outcome.winner === 'a' ? me.a : me.b).persona.fighter_name} leads` : ''} → ${base()}/arena/battle/${encodeURIComponent(me.a.user.username)}/${encodeURIComponent(me.b.user.username)}`);
- }
- if (cmd === '!fight') {
- if (!streamer) return reply('!fight works inside a streamer\'s chat: !fight <username>');
- const opp = parts[1] ? db.getUserByUsername(String(parts[1]).replace(/^@/, '')) : null;
- if (!opp) return reply('Usage: !fight <username>');
- if (opp.id === streamer.id) return reply('They can\'t fight themselves. Yet.');
- const url = `${base()}/arena/battle/${encodeURIComponent(streamer.username)}/${encodeURIComponent(opp.username)}`;
- chat.broadcastToStream(client.streamId, { type: 'system', message: `🥊 ${client.user?.display_name || client.user?.username || 'Chat'} called out ${opp.display_name || opp.username}: ${streamer.display_name || streamer.username} vs ${opp.display_name || opp.username} → ${url}` });
- return;
+ return reply(`🥊 ${card.persona.fighter_name} — ${card.persona.title} · #${card.rank} of ${card.roster_size} · PWR ${r.power}${r.talk_bonus ? ` (+${r.talk_bonus} mouth)` : ''} · Trash Level ${card.level.level} · beefs ${card.record.wins}W–${card.record.losses}L · “${card.persona.taunt}” → ${base()}/arena/${encodeURIComponent(target.username)}`);
}
}
} catch (err) {
} catch (err) {
reply(`Arena: ${err.message}`);
reply(`Arena: ${err.message}`);
@@ -107,4 +149,4 @@ function handle(chat, ws, client, cmd, parts) {
@@ -107,4 +149,4 @@ function handle(chat, ws, client, cmd, parts) {
return true;
return true;
}
}
-module.exports = { handle, _voterKey: voterKey };
+module.exports = { handle, COMMANDS, _voterKey: voterKey };
@@ -14,7 +14,9 @@ const arena = require('./arena-service');
@@ -14,7 +14,9 @@ const arena = require('./arena-service');
const INTERVAL_MS = 20 * 60 * 1000;
const INTERVAL_MS = 20 * 60 * 1000;
const BATCH = 6;
const BATCH = 6;
const IMAGE_BATCH = 2;
const IMAGE_BATCH = 2;
+const CLOCK_MS = 60 * 1000;
let _timer = null;
let _timer = null;
+let _clock = null;
let _busy = false;
let _busy = false;
async function tick() {
async function tick() {
@@ -49,11 +51,28 @@ function start() {
@@ -49,11 +51,28 @@ function start() {
_timer = setInterval(() => tick().catch(e => console.warn('[Arena] job:', e.message)), INTERVAL_MS);
_timer = setInterval(() => tick().catch(e => console.warn('[Arena] job:', e.message)), INTERVAL_MS);
if (_timer.unref) _timer.unref();
if (_timer.unref) _timer.unref();
setTimeout(() => tick().catch(() => {}), 90_000).unref?.();
setTimeout(() => tick().catch(() => {}), 90_000).unref?.();
- // Live trash-talk sessions read the transcript every 15 s (no-op when none are live).
- try { require('./talk-session').start(); } catch (e) { console.warn('[Arena] session ticker not started:', e.message); }
- console.log('[Arena] job started (every 20 min; session ticker every 15 s)');
+ // The ears: every 15 s the listener reads live transcripts for name-drops (beefs) and topic talk.
+ try { require('./listener').start(); } catch (e) { console.warn('[Arena] listener not started:', e.message); }
+ // Clocks + pulse: forfeit beefs whose clock ran out, settle debates/bounties/phrases, and
+ // re-read the community every 30 min so the board follows what people are actually saying.
+ _clock = setInterval(() => housekeeping().catch(e => console.warn('[Arena] housekeeping:', e.message)), CLOCK_MS);
+ if (_clock.unref) _clock.unref();
+ setTimeout(() => housekeeping().catch(() => {}), 20_000).unref?.();
+ console.log('[Arena] job started (personas every 20 min; listener every 15 s; clocks every 60 s; pulse every 30 min)');
}
}
-function stop() { if (_timer) { clearInterval(_timer); _timer = null; } }
+async function housekeeping() {
+ if (!arena.arenaEnabled()) return;
+ const beef = require('./beef'), board = require('./board');
+ try { beef.tick(); } catch (e) { console.warn('[Arena] beef tick:', e.message); }
+ try { board.resolveExpired(); } catch (e) { console.warn('[Arena] resolve:', e.message); }
+ try { await board.refreshPulse(); } catch (e) { console.warn('[Arena] pulse:', e.message); }
+}
+
+function stop() {
+ if (_timer) { clearInterval(_timer); _timer = null; }
+ if (_clock) { clearInterval(_clock); _clock = null; }
+ try { require('./listener').stop(); } catch { /* */ }
+}
-module.exports = { start, stop, tick };
+module.exports = { start, stop, tick, housekeeping };
@@ -1,30 +1,20 @@
@@ -1,30 +1,20 @@
/**
/**
- * OpenVibe.Live — Arena (streamer vs streamer)
+ * OpenVibe.Live — Arena (roster, ratings, personas, portraits, quotes)
*
*
- * Turns the analytics + AI data the site already collects about each streamer into a
- * tongue-in-cheek fighting-game roster:
+ * The Arena turns the analytics + AI data the site already collects about each streamer
+ * into a fighting-game roster. Everything competitive is driven by what streamers SAY on
+ * stream and what chat does — see listener.js (the ears), beef.js (streamer vs streamer)
+ * and board.js (topics, angles, Trash Levels). This module owns the roster:
*
*
* stats → seven 40–99 ratings (HYPE, GRIND, CHAT, LOYALTY, CLUTCH, VIBE, MIC)
* stats → seven 40–99 ratings (HYPE, GRIND, CHAT, LOYALTY, CLUTCH, VIBE, MIC)
- * computed as percentiles across the active roster, plus an overall POWER.
- * Deterministic and free — no AI involved. MIC comes from the continuous
- * audio transcription (stream_timeline_events): how much they talk, how
- * hyped they sound, how often the room laughs.
- * persona → AI "character select" bio (fighter name, class, signature move, taunt,
- * lore …) written from the streamer's AI overview, stream memories, chat-AI
- * notes, VOD overviews and the numbers. Cached 24 h per streamer.
- * quotes → AI-picked "things they actually said" from the transcripts, each linked
- * to the VOD at that second so people can go hear it. Cached 24 h.
- * image → optional AI character portrait. Deliberately NOT a likeness: the image
- * model gets a description of the persona, category, colours and the
- * *scene* of the latest thumbnail (setting/objects/mood — never faces or
- * 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.
+ * computed as percentiles across the active roster, plus an overall POWER
+ * that also carries the Trash Talk bonus (recent XP + beef wins).
+ * persona → AI "character select" bio, cached 24 h.
+ * quotes → AI-picked "things they actually said" from the transcripts, VOD-linked.
+ * image → optional AI character portrait (never a likeness — persona + an
+ * identity-free scene description of the latest thumbnail).
*
*
- * 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.
+ * Nothing here touches the AI unless AI is enabled and within budget (server/ai/llm.js).
*/
*/
'use strict';
'use strict';
@@ -35,13 +25,14 @@ const db = require('../db/database');
@@ -35,13 +25,14 @@ const db = require('../db/database');
const llm = require('../ai/llm');
const llm = require('../ai/llm');
const ARENA_DIR = path.resolve(process.env.ARENA_IMAGE_PATH || './data/arena');
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 ACTIVE_DAYS = 45;
+const STATS_WINDOW_DAYS = 90;
const PERSONA_TTL_MS = 24 * 60 * 60 * 1000;
const PERSONA_TTL_MS = 24 * 60 * 60 * 1000;
const QUOTES_TTL_MS = 24 * 60 * 60 * 1000;
const QUOTES_TTL_MS = 24 * 60 * 60 * 1000;
const IMAGE_TTL_MS = 7 * 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 MIN_QUOTE_LINES = 20; // need this many transcript lines before picking quotes
+const RATING_MIN = 40;
+const MIN_QUOTE_LINES = 20;
+const TALK_BONUS_MAX = 12;
const STAT_KEYS = ['hype', 'grind', 'chat', 'loyalty', 'clutch', 'vibe', 'mic'];
const STAT_KEYS = ['hype', 'grind', 'chat', 'loyalty', 'clutch', 'vibe', 'mic'];
const STAT_META = {
const STAT_META = {
hype: { label: 'Hype', desc: 'peak concurrent viewers' },
hype: { label: 'Hype', desc: 'peak concurrent viewers' },
@@ -53,49 +44,30 @@ const STAT_META = {
@@ -53,49 +44,30 @@ const STAT_META = {
mic: { label: 'Mic', desc: 'how much (and how loud) they talk — from the transcripts' },
mic: { label: 'Mic', desc: 'how much (and how loud) they talk — from the transcripts' },
};
};
const STAT_WEIGHTS = { hype: 0.22, vibe: 0.18, chat: 0.14, loyalty: 0.13, grind: 0.13, clutch: 0.1, mic: 0.1 };
const STAT_WEIGHTS = { hype: 0.22, vibe: 0.18, chat: 0.14, loyalty: 0.13, grind: 0.13, clutch: 0.1, mic: 0.1 };
-const ROUNDS = [
- { key: 'hype', label: 'Hype Check', stat: 'hype' },
- { key: 'chat', label: 'Chat War', stat: 'chat' },
- { key: 'grind', label: 'Endurance', stat: 'grind' },
- { key: 'clutch', label: 'Clutch Time', stat: 'clutch' },
- { key: 'mic', label: 'Mic Drop', stat: 'mic' },
-];
-// Words that count as "hyped" when they show up in the transcript.
const HYPE_PATTERNS = ["let's go", 'lets go', 'no way', 'oh my god', 'insane', 'clutch', 'holy', 'gg', 'unreal', 'what the', 'bro', 'chat,', 'chat ', 'yo ', 'welcome', 'lfg', 'poggers', 'pog'];
const HYPE_PATTERNS = ["let's go", 'lets go', 'no way', 'oh my god', 'insane', 'clutch', 'holy', 'gg', 'unreal', 'what the', 'bro', 'chat,', 'chat ', 'yo ', 'welcome', 'lfg', 'poggers', 'pog'];
-// Transcript text that must never be surfaced on the site, no matter what a model
-// "picks": slurs, hate terms, threats and hard sexual profanity. Applied to quote
-// candidates BEFORE the AI sees them, to the persona prompt's "things they said", and to
-// the live hot-mic line. Deliberately broad — a false positive costs one quote, a false
-// negative puts a slur on a public page. (Spelling variants: repeated letters, common
-// leetspeak, and word-boundary aware so "hell" does not catch "shell".)
-// Stems are matched on a NORMALISED copy of the text: lower-cased, leetspeak folded
-// (1→i 3→e 4→a 0→o @→a $→s !→i), runs of 3+ identical letters collapsed to 2, and
-// punctuation stripped — so "f4gg0tz", "niggaaa", "r3tards" all reduce to the stems
-// below. Each stem may carry a plural/verb suffix.
-const BANNED_STEMS = [
- 'nigga', 'nigger', 'niggu', 'fag', 'faggot', 'fagot', 'retard', 'tranny', 'trannie', 'kike', 'spic', 'spick', 'chink', 'gook',
- 'wetback', 'dyke', 'cunt', 'whore', 'slut', 'pussy', 'pussies', 'dick', 'cock', 'rape', 'rapist', 'kys', 'hitler', 'nazi',
- 'molest', 'molester', 'pedo', 'pedophile', 'paedo', 'coon', 'raghead', 'towelhead', 'beaner',
+// ── Behavior line (NOT a vocabulary filter) ────────────────
+// This platform does not censor words: offensive, controversial, provocative and taboo
+// language — slurs included — is allowed and is never a reason to hide transcript text
+// or void an Arena entry. What is filtered is BEHAVIOR, not speech: direct threats of
+// violence, incitement to self-harm, sexual content involving minors, and doxxing.
+// Matched on a lightly normalised copy (lower-case, leetspeak folded, punctuation
+// stripped). Applied before model calls on transcript text and before rendering.
+const BANNED_PHRASES = [
+ /\bkill\s+(?:your|ur)\s*self\b/i, /\bkys\b/i, /\bgo\s+(?:die|hang\s+yourself|drink\s+bleach)\b/i,
+ /\b(?:i'?ll|i will|i'?m gonna|i am going to|gonna|going to|we'?ll|we will)\s+(?:kill|shoot|stab|murder|beat\s+(?:you|him|her|them)\s+to\s+death|find\s+and\s+kill|burn)\s+(?:you|him|her|them|your\s+family|his\s+family|her\s+family)\b/i,
+ /\b(?:child|kid|minor|underage|preteen)\s*(?:porn|sex|nude|nudes)\b/i, /\bcp\s+(?:link|links|pics|vid|vids)\b/i, /\b(?:molest|rape)\s+(?:a\s+|that\s+|the\s+)?(?:kid|child|minor|baby)\b/i,
+ /\b(?:home|house)\s+address\s+is\s+\d/i, /\b(?:his|her|their|your)\s+(?:real\s+)?(?:home\s+)?(?:address|phone\s+number|social\s+security(?:\s+number)?)\s+is\s+(?:\d|[a-z]+\s+\d)/i, /\bdox+(?:ed|ing|x)?\s+(?:him|her|them|you)\b/i,
];
];
-const SUFFIX = '(?:s|es|z|ed|ing|er|ers)?';
-const BANNED_STEM_RE = new RegExp(`\\b(?:${BANNED_STEMS.join('|')})${SUFFIX}\\b`, 'i');
-// Fully collapsed variants ("niggaaaa" → "niga", "faggot" → "fagot") catch stretched spellings.
-const collapseRepeats = (t) => t.replace(/([a-z])\1+/g, '$1');
-const BANNED_STEM_COLLAPSED_RE = new RegExp(`\\b(?:${[...new Set(BANNED_STEMS.map(collapseRepeats))].join('|')})${SUFFIX}\\b`, 'i');
-const BANNED_PHRASES = [/\bkill\s+(?:your|ur|him|her|them)\s*sel(?:f|ves)\b/i, /\bgo\s+die\b/i];
-
function normalizeForFilter(text) {
function normalizeForFilter(text) {
- return String(text || '')
- .toLowerCase()
+ return String(text || '').toLowerCase()
.replace(/[1!]/g, 'i').replace(/3/g, 'e').replace(/[4@]/g, 'a').replace(/0/g, 'o').replace(/\$/g, 's')
.replace(/[1!]/g, 'i').replace(/3/g, 'e').replace(/[4@]/g, 'a').replace(/0/g, 'o').replace(/\$/g, 's')
- .replace(/[^a-z\s]/g, ' ');
+ .replace(/[^a-z\s']/g, ' ').replace(/\s+/g, ' ');
}
}
function isBannedText(text) {
function isBannedText(text) {
const raw = String(text || '');
const raw = String(text || '');
- if (BANNED_PHRASES.some(re => re.test(raw))) return true;
const norm = normalizeForFilter(raw);
const norm = normalizeForFilter(raw);
- return BANNED_STEM_RE.test(norm) || BANNED_STEM_COLLAPSED_RE.test(collapseRepeats(norm));
+ return BANNED_PHRASES.some(re => re.test(raw) || re.test(norm));
}
}
// ── Tables ───────────────────────────────────────────────────
// ── Tables ───────────────────────────────────────────────────
@@ -116,30 +88,11 @@ function ensureTables() {
@@ -116,30 +88,11 @@ function ensureTables() {
image_error TEXT,
image_error TEXT,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
)`);
- // Added later — inline ALTERs wrapped in try/catch, like the rest of the codebase.
for (const col of ['quotes_json TEXT', 'quotes_generated_at DATETIME']) {
for (const col of ['quotes_json TEXT', 'quotes_generated_at DATETIME']) {
try { db.run(`ALTER TABLE arena_profiles ADD COLUMN ${col}`); } catch { /* exists */ }
try { db.run(`ALTER TABLE arena_profiles ADD COLUMN ${col}`); } catch { /* exists */ }
}
}
- db.run(`CREATE TABLE IF NOT EXISTS arena_battles (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- a_user_id INTEGER NOT NULL,
- 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 { /* */ }
try { fs.mkdirSync(ARENA_DIR, { recursive: true }); } catch { /* */ }
+ try { require('./board').ensureTables(); require('./beef').ensureTables(); } catch { /* */ }
_tablesReady = true;
_tablesReady = true;
}
}
@@ -164,15 +117,11 @@ function imageGenAvailable() {
@@ -164,15 +117,11 @@ function imageGenAvailable() {
function activeStreamerIds() {
function activeStreamerIds() {
return db.all(`
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
+ 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);
`, [`-${ACTIVE_DAYS} days`]).map(r => r.user_id);
}
}
-/** Voice metrics from the continuous transcription (stream_timeline_events). */
function voiceStatsFor(userId, win) {
function voiceStatsFor(userId, win) {
const hypeSql = HYPE_PATTERNS.map(() => "(LOWER(text) LIKE ?)").join(' + ');
const hypeSql = HYPE_PATTERNS.map(() => "(LOWER(text) LIKE ?)").join(' + ');
const hypeParams = HYPE_PATTERNS.map(p => `%${p}%`);
const hypeParams = HYPE_PATTERNS.map(p => `%${p}%`);
@@ -192,11 +141,7 @@ function voiceStatsFor(userId, win) {
@@ -192,11 +141,7 @@ function voiceStatsFor(userId, win) {
} catch { speech = {}; }
} catch { speech = {}; }
let covered = 0, laughs = 0, topSounds = [];
let covered = 0, laughs = 0, topSounds = [];
try {
try {
- covered = db.get(`
- SELECT COALESCE(SUM(duration_seconds), 0) AS sec FROM streams
- WHERE user_id = ? AND duration_seconds > 0
- AND id IN (SELECT DISTINCT stream_id FROM stream_timeline_events WHERE user_id = ? AND created_at >= datetime('now', ?))
- `, [userId, userId, win])?.sec || 0;
+ covered = db.get(`SELECT COALESCE(SUM(duration_seconds), 0) AS sec FROM streams WHERE user_id = ? AND duration_seconds > 0 AND id IN (SELECT DISTINCT stream_id FROM stream_timeline_events WHERE user_id = ? AND created_at >= datetime('now', ?))`, [userId, userId, win])?.sec || 0;
laughs = db.get(`SELECT COUNT(*) AS n FROM stream_timeline_events WHERE user_id = ? AND kind = 'sound' AND (LOWER(label) LIKE '%laugh%' OR LOWER(label) LIKE '%giggle%' OR LOWER(label) LIKE '%chuckle%') AND created_at >= datetime('now', ?)`, [userId, win])?.n || 0;
laughs = db.get(`SELECT COUNT(*) AS n FROM stream_timeline_events WHERE user_id = ? AND kind = 'sound' AND (LOWER(label) LIKE '%laugh%' OR LOWER(label) LIKE '%giggle%' OR LOWER(label) LIKE '%chuckle%') AND created_at >= datetime('now', ?)`, [userId, win])?.n || 0;
topSounds = db.all(`SELECT label, COUNT(*) AS n FROM stream_timeline_events WHERE user_id = ? AND kind = 'sound' AND label IS NOT NULL AND created_at >= datetime('now', ?) GROUP BY label ORDER BY n DESC LIMIT 5`, [userId, win]);
topSounds = db.all(`SELECT label, COUNT(*) AS n FROM stream_timeline_events WHERE user_id = ? AND kind = 'sound' AND label IS NOT NULL AND created_at >= datetime('now', ?) GROUP BY label ORDER BY n DESC LIMIT 5`, [userId, win]);
} catch { /* */ }
} catch { /* */ }
@@ -209,20 +154,11 @@ function voiceStatsFor(userId, win) {
@@ -209,20 +154,11 @@ function voiceStatsFor(userId, win) {
const laughsPerHour = laughs / coveredHours;
const laughsPerHour = laughs / coveredHours;
const hasData = (speech.lines || 0) >= 5;
const hasData = (speech.lines || 0) >= 5;
return {
return {
- has_data: hasData,
- lines: speech.lines || 0,
- streams_heard: speech.streams_heard || 0,
- speech_minutes: Number((speechSec / 60).toFixed(1)),
- talk_ratio_pct: Number((talkRatio * 100).toFixed(1)),
- wpm: Number(wpm.toFixed(0)),
- words: speech.words || 0,
- exclaims: speech.exclaims || 0,
- questions: speech.questions || 0,
- hype_hits: speech.hype_hits || 0,
- hype_per_hour: Number(hypePerHour.toFixed(1)),
- laughs, laughs_per_hour: Number(laughsPerHour.toFixed(1)),
+ has_data: hasData, lines: speech.lines || 0, streams_heard: speech.streams_heard || 0,
+ speech_minutes: Number((speechSec / 60).toFixed(1)), talk_ratio_pct: Number((talkRatio * 100).toFixed(1)),
+ wpm: Number(wpm.toFixed(0)), words: speech.words || 0, exclaims: speech.exclaims || 0, questions: speech.questions || 0,
+ hype_hits: speech.hype_hits || 0, hype_per_hour: Number(hypePerHour.toFixed(1)), laughs, laughs_per_hour: Number(laughsPerHour.toFixed(1)),
top_sounds: topSounds.map(s => ({ label: s.label, n: s.n })),
top_sounds: topSounds.map(s => ({ label: s.label, n: s.n })),
- // The single number MIC is ranked on: talk share carries it, hype + laughter spice it.
voice_score: hasData ? Number((talkRatio * 100 + Math.min(hypePerHour, 60) * 0.5 + Math.min(laughsPerHour, 30) + Math.min(wpm, 200) / 20).toFixed(2)) : 0,
voice_score: hasData ? Number((talkRatio * 100 + Math.min(hypePerHour, 60) * 0.5 + Math.min(laughsPerHour, 30) + Math.min(wpm, 200) / 20).toFixed(2)) : 0,
};
};
}
}
@@ -230,69 +166,33 @@ function voiceStatsFor(userId, win) {
@@ -230,69 +166,33 @@ function voiceStatsFor(userId, win) {
function rawStatsFor(userId) {
function rawStatsFor(userId) {
const win = `-${STATS_WINDOW_DAYS} days`;
const win = `-${STATS_WINDOW_DAYS} days`;
const agg = db.get(`
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
+ 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
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', ?)
WHERE s.user_id = ? AND s.duration_seconds > 0 AND s.started_at >= datetime('now', ?)
`, [userId, win]) || {};
`, [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 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 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 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 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);
const hours = Math.max(Number(agg.hours) || 0, 0.1);
- const voice = voiceStatsFor(userId, win);
return {
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(2)),
- 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,
- voice,
+ 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(2)), 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, voice: voiceStatsFor(userId, win),
};
};
}
}
const METRIC_FOR_STAT = {
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,
- mic: (r) => (r.voice ? r.voice.voice_score : 0),
+ 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, mic: (r) => (r.voice ? r.voice.voice_score : 0),
};
};
-/**
- * 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).
- */
+/** Percentile ratings across a roster: rating = 40 + 59 × percentile; a roster of one is a flat 70. */
function computeRatings(rosterRaw) {
function computeRatings(rosterRaw) {
const ids = Object.keys(rosterRaw);
const ids = Object.keys(rosterRaw);
const out = {};
const out = {};
@@ -304,23 +204,25 @@ function computeRatings(rosterRaw) {
@@ -304,23 +204,25 @@ function computeRatings(rosterRaw) {
const v = values[i];
const v = values[i];
let pct;
let pct;
if (ids.length < 2) pct = 0.5;
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);
- }
+ 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)));
out[id][stat] = Math.round(RATING_MIN + (99 - RATING_MIN) * Math.max(0, Math.min(1, pct)));
});
});
}
}
- for (const id of ids) {
- out[id].power = Math.round(STAT_KEYS.reduce((sum, k) => sum + out[id][k] * STAT_WEIGHTS[k], 0));
- }
+ for (const id of ids) out[id].power = Math.round(STAT_KEYS.reduce((sum, k) => sum + out[id][k] * STAT_WEIGHTS[k], 0));
return out;
return out;
}
}
-// ── Roster cache (stats are cheap but not free — recompute every few minutes) ──
+/** Trash Talk bonus on POWER: recent XP (7 days) + beef wins, capped. */
+function talkBonus(userId) {
+ try {
+ const board = require('./board'), beef = require('./beef');
+ return Math.min(TALK_BONUS_MAX, Math.round(board.recentXp(userId) / 25) + beef.recentWins(userId) * 3);
+ } catch { return 0; }
+}
+
+// ── Roster cache ─────────────────────────────────────────────
-let _roster = null; // { at, byId: { [userId]: { user, raw, ratings } }, order: [userId…] }
+let _roster = null;
const ROSTER_TTL_MS = 3 * 60 * 1000;
const ROSTER_TTL_MS = 3 * 60 * 1000;
function loadRoster(force = false) {
function loadRoster(force = false) {
@@ -330,18 +232,14 @@ function loadRoster(force = false) {
@@ -330,18 +232,14 @@ function loadRoster(force = false) {
const rawById = {};
const rawById = {};
for (const id of ids) rawById[id] = rawStatsFor(id);
for (const id of ids) rawById[id] = rawStatsFor(id);
const ratings = computeRatings(rawById);
const ratings = computeRatings(rawById);
- // Trash Talk bonus (server/arena/trash-talk.js): recent judged entries add up to
- // TALK_BONUS_MAX POWER, decaying over a week — the one lever streamers pull themselves.
- let bonuses = {};
- try { bonuses = require('./trash-talk').talkBonuses(); } catch { bonuses = {}; }
const byId = {};
const byId = {};
for (const id of ids) {
for (const id of ids) {
const user = db.getUserById(id);
const user = db.getUserById(id);
if (!user) continue;
if (!user) continue;
- const talkBonus = bonuses[id] || 0;
+ const bonus = talkBonus(id);
ratings[id].base_power = ratings[id].power;
ratings[id].base_power = ratings[id].power;
- ratings[id].talk_bonus = talkBonus;
- ratings[id].power = Math.min(99 + require('./trash-talk').TALK_BONUS_MAX, ratings[id].power + talkBonus);
+ ratings[id].talk_bonus = bonus;
+ ratings[id].power = Math.min(99 + TALK_BONUS_MAX, ratings[id].power + bonus);
byId[id] = { user: publicUser(user), raw: rawById[id], ratings: ratings[id] };
byId[id] = { user: publicUser(user), raw: rawById[id], ratings: ratings[id] };
try { db.run('INSERT INTO arena_profiles (user_id, stats_json, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP) ON CONFLICT(user_id) DO UPDATE SET stats_json = excluded.stats_json, updated_at = CURRENT_TIMESTAMP', [id, JSON.stringify({ raw: rawById[id], ratings: ratings[id] })]); } catch { /* */ }
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 { /* */ }
}
}
@@ -351,245 +249,98 @@ function loadRoster(force = false) {
@@ -351,245 +249,98 @@ function loadRoster(force = false) {
}
}
function publicUser(u) {
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, history, rivalries ──────────────────────────────
-
-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 };
-}
-
-function recentBattles(userId, roster, limit = 8) {
- const rows = db.all(`
- SELECT id, a_user_id, b_user_id, day, winner_user_id, result_json FROM arena_battles
- WHERE a_user_id = ? OR b_user_id = ? ORDER BY day DESC, id DESC LIMIT ?
- `, [userId, userId, limit]);
- return rows.map(b => {
- const oppId = b.a_user_id === userId ? b.b_user_id : b.a_user_id;
- const opp = roster.byId[oppId];
- const oppUser = opp ? opp.user : publicUser(db.getUserById(oppId) || { id: oppId, username: `user${oppId}` });
- const rounds = parseJson(b.result_json, { rounds: [] }).rounds || [];
- const mine = b.a_user_id === userId ? 'a' : 'b';
- return {
- id: b.id, day: b.day,
- opponent: oppUser,
- opponent_fighter_name: opp ? (parseJson(profileRow(oppId)?.persona_json) || fallbackPersona(opp)).fighter_name : oppUser.display_name,
- result: b.winner_user_id == null ? 'draw' : (b.winner_user_id === userId ? 'win' : 'loss'),
- rounds_won: rounds.filter(r => r.winner === mine).length,
- rounds_total: rounds.length,
- };
- });
-}
-
-function rivalryFor(userId, roster) {
- const row = db.get(`
- SELECT CASE WHEN a_user_id = ? THEN b_user_id ELSE a_user_id END AS opp, COUNT(*) AS fights,
- SUM(CASE WHEN winner_user_id = ? THEN 1 ELSE 0 END) AS wins,
- SUM(CASE WHEN winner_user_id IS NOT NULL AND winner_user_id != ? THEN 1 ELSE 0 END) AS losses
- FROM arena_battles WHERE a_user_id = ? OR b_user_id = ?
- GROUP BY opp ORDER BY fights DESC, wins ASC LIMIT 1
- `, [userId, userId, userId, userId, userId]);
- if (!row || row.fights < 2) return null;
- const opp = roster.byId[row.opp];
- if (!opp) return null;
- return { opponent: opp.user, fighter_name: (parseJson(profileRow(row.opp)?.persona_json) || fallbackPersona(opp)).fighter_name, fights: row.fights, wins: row.wins || 0, losses: row.losses || 0 };
-}
-
-function headToHead(ida, idb) {
- const r = db.get(`
- SELECT COUNT(*) AS fights,
- SUM(CASE WHEN winner_user_id = ? THEN 1 ELSE 0 END) AS a_wins,
- SUM(CASE WHEN winner_user_id = ? THEN 1 ELSE 0 END) AS b_wins
- FROM arena_battles WHERE a_user_id = ? AND b_user_id = ?
- `, [ida, idb, ida, idb]) || {};
- return { fights: r.fights || 0, a_wins: r.a_wins || 0, b_wins: r.b_wins || 0 };
+ 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 || '' };
}
}
// ── Persona (AI) ─────────────────────────────────────────────
// ── 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 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 freshWithin(ts, ttl) { return !!ts && Date.now() - Date.parse(ts + 'Z') < ttl; }
function freshWithin(ts, ttl) { return !!ts && Date.now() - Date.parse(ts + 'Z') < ttl; }
function personaIsFresh(row) { return !!(row && row.persona_json) && freshWithin(row.persona_generated_at, PERSONA_TTL_MS); }
function personaIsFresh(row) { return !!(row && row.persona_json) && freshWithin(row.persona_generated_at, PERSONA_TTL_MS); }
function quotesAreFresh(row) { return !!(row && row.quotes_json) && freshWithin(row.quotes_generated_at, QUOTES_TTL_MS); }
function quotesAreFresh(row) { return !!(row && row.quotes_json) && freshWithin(row.quotes_generated_at, QUOTES_TTL_MS); }
-/** Everything the site knows about a streamer that a writer could riff on. */
function gatherContext(userId) {
function gatherContext(userId) {
const ctx = {};
const ctx = {};
try { ctx.overview = db.getStreamerOverview(userId)?.overview || null; } catch { /* */ }
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 { ctx.memories = db.all('SELECT description FROM stream_memories WHERE user_id = ? ORDER BY captured_at DESC LIMIT 4', [userId]).map(m => m.description).filter(Boolean); } catch { ctx.memories = []; }
try {
try {
const chatAi = db.getChatAiSummary('user', userId, 'rolling');
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 || '');
- }
+ if (chatAi) { const ov = parseJson(chatAi.overview, null); ctx.chat_notes = ov ? [ov.alltime, ov.today].filter(Boolean).join(' ') : String(chatAi.overview || ''); }
} catch { /* */ }
} catch { /* */ }
- try {
- ctx.vods = db.all(`
- SELECT v.title, va.ai_overview_short AS overview FROM vods v
- LEFT JOIN vod_ai_state va ON va.vod_id = v.id
- WHERE v.user_id = ? AND v.is_public = 1 ORDER BY v.created_at DESC LIMIT 5
- `, [userId]).map(v => ({ title: v.title, overview: v.overview || null }));
- } 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.vods = db.all(`SELECT v.title, va.ai_overview_short AS overview FROM vods v LEFT JOIN vod_ai_state va ON va.vod_id = v.id WHERE v.user_id = ? AND v.is_public = 1 ORDER BY v.created_at DESC LIMIT 5`, [userId]).map(v => ({ title: v.title, overview: v.overview || null })); }
+ 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 = []; }
try { ctx.titles = db.all('SELECT DISTINCT title FROM streams WHERE user_id = ? AND duration_seconds > 0 ORDER BY started_at DESC LIMIT 8', [userId]).map(r => r.title).filter(Boolean); } catch { ctx.titles = []; }
- try {
- // A taste of how they actually talk — short, recent, non-empty lines.
- ctx.said = db.all(`SELECT text FROM stream_timeline_events WHERE user_id = ? AND kind = 'speech' AND LENGTH(text) BETWEEN 30 AND 140 ORDER BY created_at DESC LIMIT 24`, [userId]).map(r => r.text).filter(t => !isBannedText(t)).slice(0, 12);
- } catch { ctx.said = []; }
+ try { ctx.said = db.all(`SELECT text FROM stream_timeline_events WHERE user_id = ? AND kind = 'speech' AND LENGTH(text) BETWEEN 30 AND 140 ORDER BY created_at DESC LIMIT 24`, [userId]).map(r => r.text).filter(t => !isBannedText(t)).slice(0, 12); } catch { ctx.said = []; }
return ctx;
return ctx;
}
}
const PERSONA_SCHEMA = {
const PERSONA_SCHEMA = {
name: 'arena_persona',
name: 'arena_persona',
schema: {
schema: {
- type: 'object',
- additionalProperties: false,
+ type: 'object', additionalProperties: false,
required: ['fighter_name', 'title', 'class', 'element', 'signature_move', 'special', 'weakness', 'taunt', 'lore', 'catchphrase', 'entrance_music', 'stat_quips'],
required: ['fighter_name', 'title', 'class', 'element', 'signature_move', 'special', 'weakness', 'taunt', 'lore', 'catchphrase', 'entrance_music', 'stat_quips'],
properties: {
properties: {
fighter_name: { type: 'string', description: 'Arena ring name, 2–5 words, based on the streamer' },
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"' },
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' },
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' },
+ element: { type: 'string', description: 'Single word element/vibe' },
signature_move: { type: 'object', additionalProperties: false, required: ['name', 'description'], properties: { name: { type: 'string' }, description: { type: 'string' } } },
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' } } },
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` }])),
- },
+ 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' },
+ 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, how they talk (transcript lines are provided), their running jokes, chat culture, schedule and the numbers you are given.
-Hard rules: never mock appearance, body, voice quality, accent, disability, age, ethnicity, gender, religion, health, money troubles or anything a streamer cannot laugh at. Never invent real-life facts; everything is Arena lore. Keep it PG-13. Do not use the word "streamer" more than once. Output only the JSON.`;
+const PERSONA_SYSTEM = `You write fighting-game "character select" bios for a live-streaming site's Arena, where streamers rank each other and talk shit about each other on stream. This is a banter arena: be savage, profane, petty and funny — roast their habits, their chat, their schedule, their takes, their gimmicks, how they talk (transcript lines are provided). Trolling energy is welcome; make it quotable.
+This platform does not censor language — offensive and taboo words are allowed. The only hard line: no direct threats of violence, nothing sexual about minors, no doxxing. Everything is Arena lore, not real-life claims. Output only the JSON.`;
async function generatePersona(userId, { force = false } = {}) {
async function generatePersona(userId, { force = false } = {}) {
ensureTables();
ensureTables();
const row = profileRow(userId);
const row = profileRow(userId);
if (!force && personaIsFresh(row)) return parseJson(row.persona_json);
if (!force && personaIsFresh(row)) return parseJson(row.persona_json);
if (!aiOn()) return row ? parseJson(row.persona_json) : null;
if (!aiOn()) return row ? parseJson(row.persona_json) : null;
-
const roster = loadRoster();
const roster = loadRoster();
const entry = roster.byId[userId] || { user: publicUser(db.getUserById(userId) || { id: userId, username: `user${userId}` }), raw: rawStatsFor(userId), ratings: null };
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 ctx = gatherContext(userId);
const stats = entry.ratings || Object.fromEntries(STAT_KEYS.map(k => [k, 70]).concat([['power', 70]]));
const stats = entry.ratings || Object.fromEntries(STAT_KEYS.map(k => [k, 70]).concat([['power', 70]]));
const facts = {
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,
- on_mic: entry.raw.voice.has_data ? { talk_share_pct: entry.raw.voice.talk_ratio_pct, words_per_minute: entry.raw.voice.wpm, hype_words_per_hour: entry.raw.voice.hype_per_hour, laughs_per_hour: entry.raw.voice.laughs_per_hour, stream_sounds: entry.raw.voice.top_sounds.map(s => s.label) } : 'no transcript data yet',
- },
- ai_overview: ctx.overview,
- recent_stream_titles: ctx.titles,
- what_the_camera_saw_recently: ctx.memories,
- chat_ai_notes: ctx.chat_notes,
- recent_vods: ctx.vods,
- things_they_said_on_stream: ctx.said,
+ 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,
+ on_mic: entry.raw.voice.has_data ? { talk_share_pct: entry.raw.voice.talk_ratio_pct, words_per_minute: entry.raw.voice.wpm, hype_words_per_hour: entry.raw.voice.hype_per_hour, laughs_per_hour: entry.raw.voice.laughs_per_hour, stream_sounds: entry.raw.voice.top_sounds.map(s => s.label) } : 'no transcript data yet' },
+ ai_overview: ctx.overview, recent_stream_titles: ctx.titles, what_the_camera_saw_recently: ctx.memories, chat_ai_notes: ctx.chat_notes, recent_vods: ctx.vods, things_they_said_on_stream: ctx.said,
};
};
- const r = await llm.complete({
- role: 'summary', kind: 'arena_persona', source: 'arena', ownerUserId: userId,
- system: PERSONA_SYSTEM,
- user: `Write the Arena persona for this fighter. Facts (JSON):\n${JSON.stringify(facts)}`,
- json: PERSONA_SCHEMA, maxTokens: 950, temperature: 0.95, timeoutMs: 30000,
- });
+ 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: 950, temperature: 0.95, timeoutMs: 30000 });
const persona = r && r.json && r.json.fighter_name ? r.json : null;
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]);
+ 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})`);
console.log(`[Arena] persona for ${entry.user.username}: "${persona.fighter_name}" (${persona.class})`);
return persona;
return persona;
}
}
-/** Non-AI stand-in so the tab is never empty. */
function fallbackPersona(entry) {
function fallbackPersona(entry) {
const r = entry.ratings || {};
const r = entry.ratings || {};
const best = STAT_KEYS.reduce((a, b) => ((r[b] || 0) > (r[a] || 0) ? b : a), STAT_KEYS[0]);
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', mic: 'Caster' }[best];
const cls = { hype: 'Rushdown', grind: 'Tank', chat: 'Bard', loyalty: 'Summoner', clutch: 'Assassin', vibe: 'Zoner', mic: 'Caster' }[best];
return {
return {
- fighter_name: entry.user.display_name,
- title: `The ${STAT_META[best].label} Specialist`,
- class: cls,
+ 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',
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.` },
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?',
+ 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.`,
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,
+ 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,
};
};
}
}
-// ── Quotes (from the transcripts; AI picks the good ones) ────
+// ── Quotes ───────────────────────────────────────────────────
function quoteCandidates(userId, limit = 90) {
function quoteCandidates(userId, limit = 90) {
let rows = [];
let rows = [];
- try {
- rows = db.all(`
- SELECT id, stream_id, vod_id, start_sec, text FROM stream_timeline_events
- WHERE user_id = ? AND kind = 'speech' AND created_at >= datetime('now', ?)
- AND LENGTH(text) BETWEEN 25 AND 220
- ORDER BY created_at DESC LIMIT 1500
- `, [userId, `-${STATS_WINDOW_DAYS} days`]);
- } catch { rows = []; }
- const score = (t) => {
- const s = t.toLowerCase();
- let n = (t.match(/!/g) || []).length * 2 + (t.match(/\?/g) || []).length;
- for (const p of HYPE_PATTERNS) if (s.includes(p)) n += 3;
- if (/\b(i|we|you|chat)\b/.test(s)) n += 1;
- if (/\b(um+|uh+|like like)\b/.test(s)) n -= 1;
- return n;
- };
- // Hard filter first — nothing on the banned list is ever a candidate, so the model
- // cannot pick it. Then keep the spiciest, and fill with a spread across time so one
- // stream cannot dominate.
+ try { rows = db.all(`SELECT id, stream_id, vod_id, start_sec, text FROM stream_timeline_events WHERE user_id = ? AND kind = 'speech' AND created_at >= datetime('now', ?) AND LENGTH(text) BETWEEN 25 AND 220 ORDER BY created_at DESC LIMIT 1500`, [userId, `-${STATS_WINDOW_DAYS} days`]); } catch { rows = []; }
+ const score = (t) => { const s = t.toLowerCase(); let n = (t.match(/!/g) || []).length * 2 + (t.match(/\?/g) || []).length; for (const p of HYPE_PATTERNS) if (s.includes(p)) n += 3; if (/\b(i|we|you|chat)\b/.test(s)) n += 1; if (/\b(um+|uh+|like like)\b/.test(s)) n -= 1; return n; };
const scored = rows.filter(r => !isBannedText(r.text)).map(r => ({ ...r, score: score(r.text) })).sort((x, y) => y.score - x.score);
const scored = rows.filter(r => !isBannedText(r.text)).map(r => ({ ...r, score: score(r.text) })).sort((x, y) => y.score - x.score);
const picked = scored.slice(0, Math.ceil(limit / 2));
const picked = scored.slice(0, Math.ceil(limit / 2));
const rest = scored.slice(Math.ceil(limit / 2));
const rest = scored.slice(Math.ceil(limit / 2));
@@ -597,36 +348,13 @@ function quoteCandidates(userId, limit = 90) {
@@ -597,36 +348,13 @@ function quoteCandidates(userId, limit = 90) {
for (let i = 0; i < rest.length && picked.length < limit; i += step) picked.push(rest[i]);
for (let i = 0; i < rest.length && picked.length < limit; i += step) picked.push(rest[i]);
return picked;
return picked;
}
}
-
-const QUOTES_SCHEMA = {
- name: 'arena_quotes',
- schema: {
- type: 'object', additionalProperties: false,
- required: ['picks', 'walkout', 'voice_verdict', 'mic_style'],
- properties: {
- picks: { type: 'array', minItems: 1, maxItems: 6, items: { type: 'object', additionalProperties: false, required: ['index', 'why'], properties: { index: { type: 'integer' }, why: { type: 'string', description: '≤ 10 words: why this line is a banger' } } } },
- walkout: { type: 'integer', description: 'index of the line that should play as their walkout' },
- voice_verdict: { type: 'string', description: 'One sentence on how they sound on the mic, announcer voice' },
- mic_style: { type: 'string', description: '2–3 words, e.g. "Rapid-fire tech sermon"' },
- },
- },
-};
-
-const QUOTES_SYSTEM = `You pick the most quotable lines a live-streamer actually said, from raw speech-to-text (expect transcription noise). Choose lines that are funny, hype, weirdly profound, or perfect trash talk out of context. Skip anything that would embarrass them or anyone else: no insults about people, nothing about health/money/family, nothing offensive. Prefer complete sentences. Return indexes into the list you are given. Output only the JSON.`;
-
-function fallbackQuotes(candidates) {
- const picks = candidates.slice(0, 5).map(c => ({ ...c, why: 'straight from the transcript' }));
- return { picks, walkout: picks[0] || null, voice_verdict: null, mic_style: null, _fallback: true };
-}
-
+const QUOTES_SCHEMA = { name: 'arena_quotes', schema: { type: 'object', additionalProperties: false, required: ['picks', 'walkout', 'voice_verdict', 'mic_style'], properties: { picks: { type: 'array', minItems: 1, maxItems: 6, items: { type: 'object', additionalProperties: false, required: ['index', 'why'], properties: { index: { type: 'integer' }, why: { type: 'string' } } } }, walkout: { type: 'integer' }, voice_verdict: { type: 'string' }, mic_style: { type: 'string' } } } };
+const QUOTES_SYSTEM = `You pick the most quotable lines a live-streamer actually said, from raw speech-to-text (expect transcription noise). Choose lines that are funny, savage, unhinged, weirdly profound, or perfect trash talk out of context — swearing and disrespect are a plus. Offensive or taboo language is allowed on this platform and is not a reason to skip a line. Skip only direct threats of violence, anything sexual about minors, doxxing, and lines that are pure transcription garbage. Prefer complete sentences. Return indexes into the list you are given. Output only the JSON.`;
function materializeQuotes(candidates, sel) {
function materializeQuotes(candidates, sel) {
- // Belt and braces: candidates are pre-filtered, but never trust an index blindly.
const pick = (i, why) => { const c = candidates[i]; return c && !isBannedText(c.text) ? { text: c.text, stream_id: c.stream_id, vod_id: c.vod_id, start_sec: Math.max(0, Math.floor(Number(c.start_sec) || 0) - 2), why } : null; };
const pick = (i, why) => { const c = candidates[i]; return c && !isBannedText(c.text) ? { text: c.text, stream_id: c.stream_id, vod_id: c.vod_id, start_sec: Math.max(0, Math.floor(Number(c.start_sec) || 0) - 2), why } : null; };
const picks = (sel.picks || []).map(p => pick(p.index, p.why)).filter(Boolean);
const picks = (sel.picks || []).map(p => pick(p.index, p.why)).filter(Boolean);
- const walkout = pick(sel.walkout, 'walkout line') || picks[0] || null;
- return { picks, walkout, voice_verdict: sel.voice_verdict || null, mic_style: sel.mic_style || null };
+ return { picks, walkout: pick(sel.walkout, 'walkout line') || picks[0] || null, voice_verdict: sel.voice_verdict || null, mic_style: sel.mic_style || null };
}
}
-
async function generateQuotes(userId, { force = false } = {}) {
async function generateQuotes(userId, { force = false } = {}) {
ensureTables();
ensureTables();
const row = profileRow(userId);
const row = profileRow(userId);
@@ -636,20 +364,13 @@ async function generateQuotes(userId, { force = false } = {}) {
@@ -636,20 +364,13 @@ async function generateQuotes(userId, { force = false } = {}) {
let result = null;
let result = null;
if (aiOn()) {
if (aiOn()) {
try {
try {
- const r = await llm.complete({
- role: 'summary', kind: 'arena_quotes', source: 'arena', ownerUserId: userId,
- system: QUOTES_SYSTEM,
- user: `Lines (index: text):\n${candidates.map((c, i) => `${i}: ${c.text}`).join('\n')}`,
- json: QUOTES_SCHEMA, maxTokens: 500, temperature: 0.7, timeoutMs: 30000,
- });
+ const r = await llm.complete({ role: 'summary', kind: 'arena_quotes', source: 'arena', ownerUserId: userId, system: QUOTES_SYSTEM, user: `Lines (index: text):\n${candidates.map((c, i) => `${i}: ${c.text}`).join('\n')}`, json: QUOTES_SCHEMA, maxTokens: 500, temperature: 0.7, timeoutMs: 30000 });
if (r && r.json && Array.isArray(r.json.picks)) result = materializeQuotes(candidates, r.json);
if (r && r.json && Array.isArray(r.json.picks)) result = materializeQuotes(candidates, r.json);
} catch (e) { console.warn('[Arena] quotes:', e.message); }
} catch (e) { console.warn('[Arena] quotes:', e.message); }
}
}
- if (!result || !result.picks.length) result = materializeQuotes(candidates, { picks: fallbackQuotes(candidates).picks.map((p, i) => ({ index: i, why: p.why })), walkout: 0 });
+ if (!result || !result.picks.length) result = materializeQuotes(candidates, { picks: candidates.slice(0, 5).map((c, i) => ({ index: i, why: 'straight from the transcript' })), walkout: 0 });
if (!aiOn()) result._fallback = true;
if (!aiOn()) result._fallback = true;
- db.run(`INSERT INTO arena_profiles (user_id, quotes_json, quotes_generated_at, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
- ON CONFLICT(user_id) DO UPDATE SET quotes_json = excluded.quotes_json, quotes_generated_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP`,
- [userId, JSON.stringify(result)]);
+ db.run(`INSERT INTO arena_profiles (user_id, quotes_json, quotes_generated_at, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) ON CONFLICT(user_id) DO UPDATE SET quotes_json = excluded.quotes_json, quotes_generated_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP`, [userId, JSON.stringify(result)]);
return result;
return result;
}
}
@@ -660,58 +381,38 @@ function imageIsFresh(row) {
@@ -660,58 +381,38 @@ function imageIsFresh(row) {
if (!fs.existsSync(path.join(ARENA_DIR, path.basename(row.image_path)))) 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;
return Date.now() - Date.parse(row.image_generated_at + 'Z') < IMAGE_TTL_MS;
}
}
-
function imageUrlFor(row) {
function imageUrlFor(row) {
if (!row || !row.image_path) return null;
if (!row || !row.image_path) return null;
const base = path.basename(row.image_path);
const base = path.basename(row.image_path);
- if (!fs.existsSync(path.join(ARENA_DIR, base))) return null;
- return `/data/arena/${base}`;
+ return fs.existsSync(path.join(ARENA_DIR, base)) ? `/data/arena/${base}` : null;
}
}
-
-/** Latest visual of the streamer's stream: live thumb if live, else newest VOD thumbnail. */
function latestThumbnailFor(userId) {
function latestThumbnailFor(userId) {
try {
try {
const live = db.get('SELECT id FROM streams WHERE user_id = ? AND is_live = 1 ORDER BY started_at DESC LIMIT 1', [userId]);
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) {
if (live) {
const thumbs = require('../media-proxy/live-thumbs');
const thumbs = require('../media-proxy/live-thumbs');
const url = thumbs.getCurrentLiveThumbnailUrl(live.id);
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;
- }
+ 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 { /* */ }
} 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 { /* */ }
+ 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;
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 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
-
+const _imageInFlight = new Map();
async function generateImage(userId, { force = false } = {}) {
async function generateImage(userId, { force = false } = {}) {
ensureTables();
ensureTables();
const row = profileRow(userId);
const row = profileRow(userId);
if (!force && imageIsFresh(row)) return imageUrlFor(row);
if (!force && imageIsFresh(row)) return imageUrlFor(row);
if (!imageGenAvailable()) return imageUrlFor(row);
if (!imageGenAvailable()) return imageUrlFor(row);
if (_imageInFlight.has(userId)) return _imageInFlight.get(userId);
if (_imageInFlight.has(userId)) return _imageInFlight.get(userId);
-
const task = (async () => {
const task = (async () => {
const entry = loadRoster().byId[userId];
const entry = loadRoster().byId[userId];
const persona = parseJson(row?.persona_json) || (entry ? fallbackPersona(entry) : null);
const persona = parseJson(row?.persona_json) || (entry ? fallbackPersona(entry) : null);
if (!persona) return null;
if (!persona) return null;
let scene = '';
let scene = '';
const thumb = latestThumbnailFor(userId);
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 = ''; }
- }
+ 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 color = entry?.user?.profile_color || '#8b5cf6';
const prompt = [
const prompt = [
`Fighting-game character-select portrait of an original stylised hero called "${persona.fighter_name}" — ${persona.title}.`,
`Fighting-game character-select portrait of an original stylised hero called "${persona.fighter_name}" — ${persona.title}.`,
@@ -721,29 +422,19 @@ async function generateImage(userId, { force = false } = {}) {
@@ -721,29 +422,19 @@ async function generateImage(userId, { force = false } = {}) {
`Colour palette led by ${color}. Bold comic-book line art, dramatic rim light, dynamic pose, three-quarter view, waist up.`,
`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.',
'Fictional character, not a real person. No text, no letters, no logos, no watermark.',
].filter(Boolean).join(' ');
].filter(Boolean).join(' ');
-
const p = llm.resolveProvider('vision');
const p = llm.resolveProvider('vision');
const model = String(setting('ai_image_model', 'gpt-image-1'));
const model = String(setting('ai_image_model', 'gpt-image-1'));
const body = { model, prompt, n: 1, size: '1024x1024' };
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'));
+ if (/^dall-e/i.test(model)) body.response_format = 'b64_json'; else body.quality = String(setting('ai_image_quality', 'low'));
const started = Date.now();
const started = Date.now();
let b64 = null;
let b64 = null;
try {
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 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(() => ({}));
const j = await res.json().catch(() => ({}));
if (!res.ok) throw new Error((j.error && (j.error.message || j.error)) || `HTTP ${res.status}`);
if (!res.ok) throw new Error((j.error && (j.error.message || j.error)) || `HTTP ${res.status}`);
const item = j.data && j.data[0];
const item = j.data && j.data[0];
if (item?.b64_json) b64 = item.b64_json;
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');
- }
+ 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');
if (!b64) throw new Error('no image in response');
} catch (err) {
} catch (err) {
console.warn(`[Arena] image generation failed for user ${userId}:`, err.message);
console.warn(`[Arena] image generation failed for user ${userId}:`, err.message);
@@ -753,14 +444,9 @@ async function generateImage(userId, { force = false } = {}) {
@@ -753,14 +444,9 @@ async function generateImage(userId, { force = false } = {}) {
const file = `u${userId}-${Date.now().toString(36)}.png`;
const file = `u${userId}-${Date.now().toString(36)}.png`;
fs.writeFileSync(path.join(ARENA_DIR, file), Buffer.from(b64, 'base64'));
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 { /* */ } }
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 { /* */ }
+ 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)`);
console.log(`[Arena] portrait generated for user ${userId} (${model}, ${Date.now() - started} ms)`);
return `/data/arena/${file}`;
return `/data/arena/${file}`;
})().finally(() => _imageInFlight.delete(userId));
})().finally(() => _imageInFlight.delete(userId));
@@ -774,34 +460,20 @@ function resolveUser(usernameOrId) {
@@ -774,34 +460,20 @@ function resolveUser(usernameOrId) {
if (/^\d+$/.test(String(usernameOrId))) return db.getUserById(Number(usernameOrId));
if (/^\d+$/.test(String(usernameOrId))) return db.getUserById(Number(usernameOrId));
return db.getUserByUsername(String(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 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, includeQuotes = false } = {}) {
function cardFor(userId, roster, { includeRaw = true, includeQuotes = false } = {}) {
const entry = roster.byId[userId];
const entry = roster.byId[userId];
if (!entry) return null;
if (!entry) return null;
const row = profileRow(userId);
const row = profileRow(userId);
const persona = parseJson(row?.persona_json) || fallbackPersona(entry);
const persona = parseJson(row?.persona_json) || fallbackPersona(entry);
- const rank = roster.order.indexOf(userId) + 1;
+ const board = require('./board'), beef = require('./beef');
const card = {
const card = {
- user: entry.user,
- rank,
- roster_size: roster.order.length,
- ratings: entry.ratings,
- stat_meta: STAT_META,
- raw: includeRaw ? entry.raw : undefined,
- voice: entry.raw.voice,
- persona,
- persona_is_fallback: !!persona._fallback,
- persona_generated_at: row?.persona_generated_at || null,
- image_url: imageUrlFor(row),
- image_prompt: row?.image_prompt || null,
- image_model: row?.image_model || null,
- image_pending: false,
- record: recordFor(userId),
- live: isLive(userId),
+ user: entry.user, rank: roster.order.indexOf(userId) + 1, roster_size: roster.order.length,
+ ratings: entry.ratings, stat_meta: STAT_META, raw: includeRaw ? entry.raw : undefined, voice: entry.raw.voice,
+ persona, persona_is_fallback: !!persona._fallback, persona_generated_at: row?.persona_generated_at || null,
+ image_url: imageUrlFor(row), image_prompt: row?.image_prompt || null, image_model: row?.image_model || null, image_pending: false,
+ record: beef.recordFor(userId), level: board.levelView(userId), live: isLive(userId),
};
};
if (includeQuotes) card.quotes = parseJson(row?.quotes_json) || null;
if (includeQuotes) card.quotes = parseJson(row?.quotes_json) || null;
return card;
return card;
@@ -816,17 +488,13 @@ async function getFighter(usernameOrId, { generate = true } = {}) {
@@ -816,17 +488,13 @@ async function getFighter(usernameOrId, { generate = true } = {}) {
const row = profileRow(user.id);
const row = profileRow(user.id);
if (aiOn() && !personaIsFresh(row)) { try { await generatePersona(user.id); } catch (e) { console.warn('[Arena] persona:', e.message); } }
if (aiOn() && !personaIsFresh(row)) { try { await generatePersona(user.id); } catch (e) { console.warn('[Arena] persona:', e.message); } }
if (!quotesAreFresh(profileRow(user.id))) { try { await generateQuotes(user.id); } catch (e) { console.warn('[Arena] quotes:', e.message); } }
if (!quotesAreFresh(profileRow(user.id))) { try { await generateQuotes(user.id); } catch (e) { console.warn('[Arena] quotes:', e.message); } }
- if (aiOn() && !imageIsFresh(profileRow(user.id)) && imageGenAvailable()) {
- // Fire and forget — the client polls until image_url is present.
- generateImage(user.id).catch(() => {});
- }
+ if (aiOn() && !imageIsFresh(profileRow(user.id)) && imageGenAvailable()) generateImage(user.id).catch(() => {});
}
}
const card = cardFor(user.id, roster, { includeQuotes: true });
const card = cardFor(user.id, roster, { includeQuotes: true });
card.image_pending = !card.image_url && _imageInFlight.has(user.id);
card.image_pending = !card.image_url && _imageInFlight.has(user.id);
card.image_generation = imageGenAvailable() ? 'ai' : 'off';
card.image_generation = imageGenAvailable() ? 'ai' : 'off';
- card.recent_battles = recentBattles(user.id, roster);
- card.rivalry = rivalryFor(user.id, roster);
- try { card.trash_talk = require('./trash-talk').entriesFor(user.id, 5); } catch { card.trash_talk = []; }
+ try { card.beefs = require('./beef').forUser(user.id, 8); } catch { card.beefs = []; }
+ try { const t = require('./board').activeTopicFor(user.id); card.active_topic = t ? { id: t.id, text: t.text } : null; } catch { card.active_topic = null; }
return card;
return card;
}
}
@@ -835,17 +503,14 @@ function listFighters() {
@@ -835,17 +503,14 @@ function listFighters() {
return roster.order.map(id => {
return roster.order.map(id => {
const c = cardFor(id, roster, { includeRaw: false });
const c = cardFor(id, roster, { includeRaw: false });
return {
return {
- user: c.user, rank: c.rank, ratings: c.ratings, record: c.record, live: c.live, image_url: c.image_url,
+ user: c.user, rank: c.rank, ratings: c.ratings, record: c.record, live: c.live, image_url: c.image_url, level: { level: c.level.level, xp: c.level.xp },
persona: { fighter_name: c.persona.fighter_name, title: c.persona.title, class: c.persona.class, element: c.persona.element, taunt: c.persona.taunt, lore: c.persona.lore, signature_move: c.persona.signature_move, stat_quips: c.persona.stat_quips },
persona: { fighter_name: c.persona.fighter_name, title: c.persona.title, class: c.persona.class, element: c.persona.element, taunt: c.persona.taunt, lore: c.persona.lore, signature_move: c.persona.signature_move, stat_quips: c.persona.stat_quips },
- persona_is_fallback: c.persona_is_fallback,
- category: roster.byId[id].raw.category,
- last_live_at: roster.byId[id].raw.last_live_at,
+ persona_is_fallback: c.persona_is_fallback, category: roster.byId[id].raw.category, last_live_at: roster.byId[id].raw.last_live_at,
voice: { has_data: c.voice.has_data, talk_ratio_pct: c.voice.talk_ratio_pct, speech_minutes: c.voice.speech_minutes },
voice: { has_data: c.voice.has_data, talk_ratio_pct: c.voice.talk_ratio_pct, speech_minutes: c.voice.speech_minutes },
};
};
});
});
}
}
-/** Per-stream series for one stat (for the expandable stat view) + where they rank on it. */
function getStatDetail(userId, stat) {
function getStatDetail(userId, stat) {
if (!STAT_KEYS.includes(stat)) return null;
if (!STAT_KEYS.includes(stat)) return null;
const roster = loadRoster();
const roster = loadRoster();
@@ -855,299 +520,77 @@ function getStatDetail(userId, stat) {
@@ -855,299 +520,77 @@ function getStatDetail(userId, stat) {
let series = [];
let series = [];
try {
try {
const rows = db.all(`
const rows = db.all(`
- SELECT s.id, s.title, s.started_at, s.duration_seconds, s.peak_viewers,
- sa.avg_viewers, sa.total_messages, sa.unique_chatters, sa.new_followers, sa.clips_created,
+ SELECT s.id, s.title, s.started_at, s.duration_seconds, s.peak_viewers, sa.avg_viewers, sa.total_messages, sa.unique_chatters, sa.new_followers, sa.clips_created,
(SELECT COALESCE(SUM(COALESCE(e.end_sec, e.start_sec + 3) - e.start_sec), 0) FROM stream_timeline_events e WHERE e.stream_id = s.id AND e.kind = 'speech') AS speech_sec
(SELECT COALESCE(SUM(COALESCE(e.end_sec, e.start_sec + 3) - e.start_sec), 0) FROM stream_timeline_events e WHERE e.stream_id = s.id AND e.kind = 'speech') AS speech_sec
FROM streams s LEFT JOIN stream_analytics sa ON sa.stream_id = s.id
FROM streams s LEFT JOIN stream_analytics sa ON sa.stream_id = s.id
- WHERE s.user_id = ? AND s.duration_seconds > 0 AND s.started_at >= datetime('now', ?)
- ORDER BY s.started_at DESC LIMIT 14
- `, [userId, win]).reverse();
+ WHERE s.user_id = ? AND s.duration_seconds > 0 AND s.started_at >= datetime('now', ?) ORDER BY s.started_at DESC LIMIT 14`, [userId, win]).reverse();
const per = {
const per = {
- hype: r => r.peak_viewers || 0,
- grind: r => Number(((r.duration_seconds || 0) / 3600).toFixed(2)),
- chat: r => Number(((r.total_messages || 0) / Math.max((r.duration_seconds || 0) / 3600, 0.1)).toFixed(1)),
- loyalty: r => (r.new_followers || 0) + (r.unique_chatters || 0) / 4,
- clutch: r => Number((((r.clips_created || 0) * 3) / Math.max((r.duration_seconds || 0) / 3600, 0.1)).toFixed(2)),
- vibe: r => Number((r.avg_viewers || 0).toFixed(1)),
+ hype: r => r.peak_viewers || 0, grind: r => Number(((r.duration_seconds || 0) / 3600).toFixed(2)),
+ chat: r => Number(((r.total_messages || 0) / Math.max((r.duration_seconds || 0) / 3600, 0.1)).toFixed(1)), loyalty: r => (r.new_followers || 0) + (r.unique_chatters || 0) / 4,
+ clutch: r => Number((((r.clips_created || 0) * 3) / Math.max((r.duration_seconds || 0) / 3600, 0.1)).toFixed(2)), vibe: r => Number((r.avg_viewers || 0).toFixed(1)),
mic: r => (r.duration_seconds ? Number(((r.speech_sec || 0) / r.duration_seconds * 100).toFixed(1)) : 0),
mic: r => (r.duration_seconds ? Number(((r.speech_sec || 0) / r.duration_seconds * 100).toFixed(1)) : 0),
};
};
series = rows.map(r => ({ stream_id: r.id, title: r.title, date: r.started_at, value: per[stat](r) }));
series = rows.map(r => ({ stream_id: r.id, title: r.title, date: r.started_at, value: per[stat](r) }));
} catch { series = []; }
} catch { series = []; }
const unit = { hype: 'peak viewers', grind: 'hours', chat: 'msgs / hour', loyalty: 'loyalty points', clutch: 'clutch / hour', vibe: 'avg viewers', mic: '% of stream talking' }[stat];
const unit = { hype: 'peak viewers', grind: 'hours', chat: 'msgs / hour', loyalty: 'loyalty points', clutch: 'clutch / hour', vibe: 'avg viewers', mic: '% of stream talking' }[stat];
- // MIC is ranked on a composite voice score, but people are shown the talk-time share
- // (the same number the voice card uses) so the drill-down never contradicts the card.
const shown = (raw) => (stat === 'mic' ? (raw.voice?.talk_ratio_pct || 0) : (METRIC_FOR_STAT[stat](raw) || 0));
const shown = (raw) => (stat === 'mic' ? (raw.voice?.talk_ratio_pct || 0) : (METRIC_FOR_STAT[stat](raw) || 0));
const ranked = roster.order.map(id => ({ id, value: METRIC_FOR_STAT[stat](roster.byId[id].raw) || 0, shown: shown(roster.byId[id].raw), rating: roster.byId[id].ratings[stat] })).sort((x, y) => y.value - x.value);
const ranked = roster.order.map(id => ({ id, value: METRIC_FOR_STAT[stat](roster.byId[id].raw) || 0, shown: shown(roster.byId[id].raw), rating: roster.byId[id].ratings[stat] })).sort((x, y) => y.value - x.value);
const position = ranked.findIndex(r => r.id === userId) + 1;
const position = ranked.findIndex(r => r.id === userId) + 1;
const top = ranked.slice(0, 3).map(r => ({ user: roster.byId[r.id].user, fighter_name: (parseJson(profileRow(r.id)?.persona_json) || fallbackPersona(roster.byId[r.id])).fighter_name, value: Number(Number(r.shown).toFixed(1)), rating: r.rating }));
const top = ranked.slice(0, 3).map(r => ({ user: roster.byId[r.id].user, fighter_name: (parseJson(profileRow(r.id)?.persona_json) || fallbackPersona(roster.byId[r.id])).fighter_name, value: Number(Number(r.shown).toFixed(1)), rating: r.rating }));
- return {
- stat, label: STAT_META[stat].label, desc: STAT_META[stat].desc, unit,
- rating: entry.ratings[stat], value: Number(Number(shown(entry.raw)).toFixed(1)),
- position, roster_size: roster.order.length, weight: STAT_WEIGHTS[stat],
- series, top,
- voice: stat === 'mic' ? entry.raw.voice : undefined,
- };
-}
-
-// ── Battles ──────────────────────────────────────────────────
-
-function today() { return new Date().toISOString().slice(0, 10); }
-
-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);
- return ROUNDS.map(r => {
- const a = aRatings[r.stat] + rand() * 30;
- const b = bRatings[r.stat] + rand() * 30;
- const winner = a === b ? (aRatings.power >= bRatings.power ? 'a' : 'b') : (a > b ? 'a' : 'b');
- const margin = Math.abs(a - b);
- return { key: r.key, label: r.label, stat: r.stat, a: Math.round(a), b: Math.round(b), winner, margin: Math.round(margin), upset: (winner === 'a' ? aRatings[r.stat] < bRatings[r.stat] : bRatings[r.stat] < aRatings[r.stat]) };
- });
-}
-
-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: ROUNDS.length, maxItems: ROUNDS.length, items: { type: 'string', description: 'One vivid play-by-play sentence per round, in order' } },
- finisher: { type: 'string', description: 'The finishing move that decided it, 1 sentence' },
- verdict: { type: 'string', description: 'Announcer verdict + a wink at the crowd vote, ≤ 2 sentences' },
- },
- },
-};
-
-const COMMENTARY_SYSTEM = `You are the over-caffeinated ring announcer of a streaming site's Arena, where streamers battle as fighting-game characters. Call the fight from the round results you are given: name the signature moves, riff on their lore, quote their walkout lines if given, celebrate upsets. The "Mic Drop" round is about who talks more and louder on stream. Affectionate roast only — never cruel, never about appearance/body/voice quality/accent/health/identity. PG-13. Output only the JSON.`;
-
-function templateCommentary(A, B, rounds, outcome) {
- const name = (s) => (s === 'a' ? A.persona.fighter_name : B.persona.fighter_name);
- 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) {
- for (const id of [ida, idb]) {
- if (aiOn() && !personaIsFresh(profileRow(id))) { try { await generatePersona(id); } catch { /* */ } }
- if (!quotesAreFresh(profileRow(id))) { try { await generateQuotes(id); } catch { /* */ } }
- }
- }
- const A = cardFor(ida, roster, { includeRaw: true, includeQuotes: true });
- const B = cardFor(idb, roster, { includeRaw: true, includeQuotes: true });
-
- 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 brief = (X) => ({ name: X.persona.fighter_name, title: X.persona.title, class: X.persona.class, signature_move: X.persona.signature_move, special: X.persona.special, weakness: X.persona.weakness, lore: X.persona.lore, ratings: X.ratings, walkout_line: X.quotes?.walkout?.text || null, mic_style: X.quotes?.mic_style || null });
- const r = await llm.complete({
- role: 'chat', kind: 'arena_commentary', source: 'arena',
- system: COMMENTARY_SYSTEM,
- user: JSON.stringify({
- a: brief(A), b: brief(B),
- rounds: rounds.map(r => ({ round: r.label, stat: r.stat, a_score: r.a, b_score: r.b, winner: r.winner === 'a' ? A.persona.fighter_name : B.persona.fighter_name, upset: r.upset })),
- result_before_crowd_vote: { a_rounds: rounds.filter(r => r.winner === 'a').length, b_rounds: rounds.filter(r => r.winner === 'b').length },
- }),
- json: COMMENTARY_SCHEMA, maxTokens: 700, 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);
-
- const strip = (X) => ({ ...X, quotes: undefined, walkout: X.quotes?.walkout || null, mic_style: X.quotes?.mic_style || null, voice_verdict: X.quotes?.voice_verdict || null });
- return {
- id: row.id, day, a: strip(A), b: strip(B), rounds, votes, outcome,
- winner: winnerId ? (winnerId === ida ? A.user : B.user) : null,
- commentary,
- commentary_is_fallback: !!commentary._fallback,
- history: headToHead(ida, idb),
- stat_meta: STAT_META,
- };
-}
-
-/** Today's featured fight: a seeded pick of two fighters from the top of the ladder. */
-async function getMainEvent({ generate = true } = {}) {
- const roster = loadRoster();
- if (roster.order.length < 2) return null;
- const pool = roster.order.slice(0, Math.min(8, roster.order.length));
- const rand = seededRandom(`main-event:${today()}`);
- const i = Math.floor(rand() * pool.length);
- let j = Math.floor(rand() * (pool.length - 1)); if (j >= i) j++;
- const battle = await getBattle(String(pool[i]), String(pool[j]), { generate });
- return battle && !battle.error ? battle : null;
-}
-
-function castVote(battleId, voterKey, side) {
- if (!['a', 'b'].includes(side)) throw new Error('side must be a or b');
- ensureTables();
- 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 };
+ return { stat, label: STAT_META[stat].label, desc: STAT_META[stat].desc, unit, rating: entry.ratings[stat], value: Number(Number(shown(entry.raw)).toFixed(1)), position, roster_size: roster.order.length, weight: STAT_WEIGHTS[stat], series, top, voice: stat === 'mic' ? entry.raw.voice : undefined };
}
}
-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() {
+/** Live fighters with what the transcript last heard — the "on the mic now" strip. */
+function liveFighters() {
const roster = loadRoster();
const roster = loadRoster();
let live = [];
let live = [];
try { live = db.getLiveStreams() || []; } catch { live = []; }
try { live = db.getLiveStreams() || []; } catch { live = []; }
const byUser = new Map();
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);
- }
+ 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 { /* */ }
let thumbs = null; try { thumbs = require('../media-proxy/live-thumbs'); } catch { /* */ }
- const fighters = [...byUser.values()].map(s => {
+ const board = require('./board'), beef = require('./beef');
+ return [...byUser.values()].map(s => {
const c = cardFor(s.user_id, roster, { includeRaw: false });
const c = cardFor(s.user_id, roster, { includeRaw: false });
- // What they said most recently on this live stream — the arena "hot mic".
- let lastLine = null;
- try {
- const r = db.all(`SELECT text, start_sec FROM stream_timeline_events WHERE stream_id = ? AND kind = 'speech' AND LENGTH(text) > 15 ORDER BY start_sec DESC LIMIT 5`, [s.id])
- .find(row => !isBannedText(row.text));
- if (r) lastLine = { text: r.text, start_sec: Math.floor(r.start_sec) };
- } catch { /* */ }
+ let hotMic = null;
+ try { const r = db.all(`SELECT text, start_sec FROM stream_timeline_events WHERE stream_id = ? AND kind = 'speech' AND LENGTH(text) > 15 ORDER BY start_sec DESC LIMIT 5`, [s.id]).find(row => !isBannedText(row.text)); if (r) hotMic = { text: r.text, start_sec: Math.floor(r.start_sec) }; } catch { /* */ }
+ const transcribed = !!db.get(`SELECT 1 FROM stream_timeline_events WHERE stream_id = ? AND kind = 'speech' AND created_at >= datetime('now', '-30 minutes') LIMIT 1`, [s.id]);
+ const t = board.activeTopicFor(s.user_id);
return {
return {
- user: c.user, rank: c.rank, ratings: c.ratings, record: c.record, image_url: c.image_url,
+ user: c.user, rank: c.rank, ratings: c.ratings, record: c.record, image_url: c.image_url, level: c.level.level,
persona: { fighter_name: c.persona.fighter_name, title: c.persona.title, class: c.persona.class, taunt: c.persona.taunt },
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 },
+ stream: { id: s.id, title: s.title, category: s.category, viewer_count: s.viewer_count || 0, started_at: s.started_at },
thumbnail_url: thumbs ? (thumbs.getCurrentLiveThumbnailUrl(s.id) || null) : null,
thumbnail_url: thumbs ? (thumbs.getCurrentLiveThumbnailUrl(s.id) || null) : null,
- hot_mic: lastLine,
+ hot_mic: hotMic, transcribed, active_topic: t ? { id: t.id, text: t.text } : null, open_beefs: beef.openBeefsFor(s.user_id).length,
};
};
}).sort((x, y) => y.ratings.power - x.ratings.power);
}).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 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)}`;
}
}
function status() {
function status() {
ensureTables();
ensureTables();
const roster = loadRoster();
const roster = loadRoster();
const counts = db.get(`SELECT SUM(persona_json IS NOT NULL) AS personas, SUM(image_path IS NOT NULL) AS images, SUM(quotes_json IS NOT NULL) AS quotes FROM arena_profiles`) || {};
const counts = db.get(`SELECT SUM(persona_json IS NOT NULL) AS personas, SUM(image_path IS NOT NULL) AS images, SUM(quotes_json IS NOT NULL) AS quotes FROM arena_profiles`) || {};
- const battles = db.get('SELECT COUNT(*) AS n FROM arena_battles')?.n || 0;
- const votes = db.get('SELECT COUNT(*) AS n FROM arena_votes')?.n || 0;
- const withVoice = roster.order.filter(id => roster.byId[id].raw.voice.has_data).length;
+ let beefs = {}, topics = 0;
+ try { beefs = db.get(`SELECT SUM(status = 'open') AS open, SUM(status = 'resolved') AS resolved FROM arena_beefs`) || {}; topics = db.get(`SELECT COUNT(*) AS n FROM arena_topics WHERE status = 'open'`)?.n || 0; } catch { /* */ }
return {
return {
- enabled: arenaEnabled(),
- ai: aiOn(),
- image_generation: imageGenAvailable(),
- image_model: imageGenAvailable() ? String(setting('ai_image_model', 'gpt-image-1')) : null,
- roster: roster.order.length,
- with_voice_data: withVoice,
- personas: counts.personas || 0,
- quotes: counts.quotes || 0,
- images: counts.images || 0,
- battles, votes,
- active_days: ACTIVE_DAYS,
- rounds: ROUNDS.map(r => r.label),
+ enabled: arenaEnabled(), ai: aiOn(), image_generation: imageGenAvailable(), image_model: imageGenAvailable() ? String(setting('ai_image_model', 'gpt-image-1')) : null,
+ roster: roster.order.length, with_voice_data: roster.order.filter(id => roster.byId[id].raw.voice.has_data).length,
+ personas: counts.personas || 0, quotes: counts.quotes || 0, images: counts.images || 0,
+ beefs_open: beefs.open || 0, beefs_resolved: beefs.resolved || 0, topics_open: topics, live_fighters: liveFighters().length,
+ listener: (() => { try { return require('./listener').TICK_MS; } catch { return null; } })(), active_days: ACTIVE_DAYS,
};
};
}
}
module.exports = {
module.exports = {
- ensureTables,
- arenaEnabled,
- aiOn,
- imageGenAvailable,
- loadRoster,
- listFighters,
- getFighter,
- getStatDetail,
- generatePersona,
- generateQuotes,
- generateImage,
- getBattle,
- getMainEvent,
- castVote,
- voterKeyFor,
- getLiveMatchups,
- status,
- STAT_KEYS,
- STAT_META,
- STAT_WEIGHTS,
- ROUNDS,
- ARENA_DIR,
+ ensureTables, arenaEnabled, aiOn, imageGenAvailable, loadRoster, listFighters, getFighter, getStatDetail, liveFighters,
+ generatePersona, generateQuotes, generateImage, voterKeyFor, status, publicUser,
getFighterImageUrl: (userId) => imageUrlFor(profileRow(userId)),
getFighterImageUrl: (userId) => imageUrlFor(profileRow(userId)),
- // pure helpers (tests)
- _computeRatings: computeRatings,
- _simulateRounds: simulateRounds,
- _scoreBattle: scoreBattle,
- _seededRandom: seededRandom,
- _fallbackPersona: fallbackPersona,
- _voiceStatsFor: voiceStatsFor,
- _quoteCandidates: quoteCandidates,
- _isBannedText: isBannedText,
+ STAT_KEYS, STAT_META, STAT_WEIGHTS, ARENA_DIR, TALK_BONUS_MAX,
+ _computeRatings: computeRatings, _fallbackPersona: fallbackPersona, _voiceStatsFor: voiceStatsFor, _quoteCandidates: quoteCandidates, _isBannedText: isBannedText, _talkBonus: talkBonus,
};
};
new file mode 100644
new file mode 100644
@@ -0,0 +1,330 @@
@@ -0,0 +1,330 @@
+/**
+ * OpenVibe.Live — Arena Beefs (speech-driven streamer vs streamer)
+ *
+ * Nobody clicks "battle". The listener (listener.js) hears a live fighter talking trash
+ * about ANOTHER fighter by name; the judge confirms it was aimed at them; a beef opens:
+ *
+ * score → every judged hit aimed at the other side adds its quality (0–10) to
+ * your side of the tug-of-war. Chat adds crowd points with `!hype` in
+ * your chat (one per person per beef, max CROWD_MAX).
+ * clock → after every hit the OTHER side is on the clock: RESPONSE_LIVE_MIN
+ * minutes if they are live, RESPONSE_OFFLINE_HOURS hours if not (the
+ * clock tightens to the live window the moment they go live). Miss the
+ * clock and you ducked — forfeit.
+ * end → a beef also hard-ends MAX_BEEF_HOURS after it opened: higher score
+ * wins, equal is a draw. Records (W–L) come from here.
+ */
+'use strict';
+
+const db = require('../db/database');
+
+const RESPONSE_LIVE_MIN = 15;
+const RESPONSE_OFFLINE_HOURS = 24;
+const MAX_BEEF_HOURS = 24;
+const CROWD_MAX = 10;
+const FEED_MAX = 40;
+const XP_BEEF_HIT = 1.0; // × quality
+const XP_BEEF_WIN = 40;
+const XP_BEEF_OPEN = 5;
+
+let _ready = false;
+function ensureTables() {
+ if (_ready) return;
+ db.run(`CREATE TABLE IF NOT EXISTS arena_beefs (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ a_user_id INTEGER NOT NULL,
+ b_user_id INTEGER NOT NULL,
+ status TEXT NOT NULL DEFAULT 'open',
+ score_a REAL DEFAULT 0,
+ score_b REAL DEFAULT 0,
+ hits_a INTEGER DEFAULT 0,
+ hits_b INTEGER DEFAULT 0,
+ crowd_a INTEGER DEFAULT 0,
+ crowd_b INTEGER DEFAULT 0,
+ on_clock TEXT,
+ clock_until DATETIME,
+ responded INTEGER DEFAULT 0,
+ last_a_at DATETIME,
+ last_b_at DATETIME,
+ feed_json TEXT,
+ opener_line TEXT,
+ winner_user_id INTEGER,
+ resolution TEXT,
+ opened_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ ends_at DATETIME,
+ resolved_at DATETIME
+ )`);
+ db.run(`CREATE TABLE IF NOT EXISTS arena_beef_hype (
+ beef_id INTEGER NOT NULL,
+ side TEXT NOT NULL,
+ voter_key TEXT NOT NULL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (beef_id, voter_key)
+ )`);
+ for (const col of ['headline TEXT', 'result_headline TEXT', 'upset INTEGER DEFAULT 0', 'rematch INTEGER DEFAULT 0', 'bounty_topic_id INTEGER']) {
+ try { db.run(`ALTER TABLE arena_beefs ADD COLUMN ${col}`); } catch { /* exists */ }
+ }
+ db.run(`CREATE TABLE IF NOT EXISTS arena_beef_sides (
+ beef_id INTEGER NOT NULL,
+ voter_key TEXT NOT NULL,
+ side TEXT NOT NULL,
+ user_id INTEGER,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (beef_id, voter_key)
+ )`);
+ db.run('CREATE INDEX IF NOT EXISTS idx_arena_beefs_status ON arena_beefs (status, a_user_id, b_user_id)');
+ _ready = true;
+}
+
+function parseJson(t, f = null) { try { return t ? JSON.parse(t) : f; } catch { return f; } }
+function arena() { return require('./arena-service'); }
+function board() { return require('./board'); }
+function isLive(userId) { return !!db.get('SELECT 1 FROM streams WHERE user_id = ? AND is_live = 1 LIMIT 1', [userId]); }
+function nowIso() { return new Date().toISOString(); }
+function nameOf(userId) {
+ const p = parseJson(db.get('SELECT persona_json FROM arena_profiles WHERE user_id = ?', [userId])?.persona_json);
+ const u = db.getUserById(userId);
+ return p?.fighter_name || u?.display_name || u?.username || `user${userId}`;
+}
+const HEADLINE_SCHEMA = { name: 'arena_headline', schema: { type: 'object', additionalProperties: false, required: ['headline'], properties: { headline: { type: 'string', description: '≤ 100 chars, tabloid, inflammatory but funny, names both fighters' } } } };
+async function headlineFor(kind, ctx) {
+ try {
+ const llm = require('../ai/llm');
+ if (!(llm.isEnabled() && llm.withinBudget())) return null;
+ const r = await llm.complete({ role: 'chat', kind: 'arena_headline', source: 'arena', system: 'You write tabloid headlines for a streaming site\'s trash-talk arena: inflammatory, funny, screenshot-able, names both fighters, ≤ 100 chars. Profanity and disrespect welcome; this platform does not censor language. Hard line: no direct threats of violence, no doxxing. Output only the JSON.', user: JSON.stringify({ kind, ...ctx }), json: HEADLINE_SCHEMA, maxTokens: 80, temperature: 1.0, timeoutMs: 15000 });
+ return r && r.json && r.json.headline ? String(r.json.headline).slice(0, 120) : null;
+ } catch { return null; }
+}
+function templateHeadline(kind, ctx) {
+ if (kind === 'open') return `${ctx.a} has words for ${ctx.b}${ctx.rematch ? ' — REMATCH' : ''}${ctx.line ? `: “${String(ctx.line).slice(0, 60)}”` : ''}`;
+ if (kind === 'forfeit') return `${ctx.loser} DUCKED. ${ctx.winner} wins by silence.`;
+ if (kind === 'score') return ctx.winner ? `${ctx.winner} out-talks ${ctx.loser} ${ctx.score_a}–${ctx.score_b}${ctx.upset ? ' — UPSET' : ''}` : `${ctx.a} and ${ctx.b} talk themselves into a draw`;
+ return null;
+}
+function sqlDate(ms) { return new Date(ms).toISOString().replace('T', ' ').slice(0, 19); }
+
+function openBeefBetween(u1, u2) {
+ ensureTables();
+ return db.get(`SELECT * FROM arena_beefs WHERE status = 'open' AND ((a_user_id = ? AND b_user_id = ?) OR (a_user_id = ? AND b_user_id = ?))`, [u1, u2, u2, u1]) || null;
+}
+function openBeefsFor(userId) {
+ ensureTables();
+ return db.all(`SELECT * FROM arena_beefs WHERE status = 'open' AND (a_user_id = ? OR b_user_id = ?) ORDER BY opened_at DESC`, [userId, userId]);
+}
+
+function clockFor(targetId, fromMs = Date.now()) {
+ return sqlDate(fromMs + (isLive(targetId) ? RESPONSE_LIVE_MIN * 60 * 1000 : RESPONSE_OFFLINE_HOURS * 3600 * 1000));
+}
+
+function pushFeed(beef, event) {
+ const feed = parseJson(beef.feed_json, []);
+ feed.push({ at: nowIso(), ...event });
+ return JSON.stringify(feed.slice(-FEED_MAX));
+}
+
+/**
+ * A judged hit: `speakerId` talked trash aimed at `targetId` (both on the roster).
+ * Opens the beef if needed, scores it, and puts the other side on the clock.
+ */
+function recordHit(speakerId, targetId, hit) {
+ ensureTables();
+ if (speakerId === targetId) return null;
+ let beef = openBeefBetween(speakerId, targetId);
+ let opened = false;
+ if (!beef) {
+ const endsAt = sqlDate(Date.now() + MAX_BEEF_HOURS * 3600 * 1000);
+ const history = rivalry(speakerId, targetId);
+ const bounty = board().openBountyOn(targetId);
+ db.run(`INSERT INTO arena_beefs (a_user_id, b_user_id, on_clock, clock_until, ends_at, feed_json, opener_line, rematch, bounty_topic_id, headline) VALUES (?, ?, 'b', ?, ?, '[]', ?, ?, ?, ?)`,
+ [speakerId, targetId, clockFor(targetId), endsAt, String(hit.best_line || '').slice(0, 220), history.fights > 0 ? 1 : 0, bounty?.id || null, templateHeadline('open', { a: nameOf(speakerId), b: nameOf(targetId), line: hit.best_line, rematch: history.fights > 0 })]);
+ beef = openBeefBetween(speakerId, targetId);
+ opened = true;
+ board().addXp(speakerId, XP_BEEF_OPEN, 'beef_open', beef.id);
+ console.log(`[Arena] beef #${beef.id} opened: user ${speakerId} → user ${targetId}${history.fights ? ' (rematch)' : ''}${bounty ? ' (bounty!)' : ''}`);
+ const id = beef.id;
+ headlineFor('open', { a: nameOf(speakerId), b: nameOf(targetId), opening_line: hit.best_line, about: hit.about, rematch: history.fights > 0, record_between: history, bounty: !!bounty }).then(h => { if (h) db.run('UPDATE arena_beefs SET headline = ? WHERE id = ?', [h, id]); }).catch(() => {});
+ }
+ const side = beef.a_user_id === speakerId ? 'a' : 'b';
+ const other = side === 'a' ? 'b' : 'a';
+ const otherId = side === 'a' ? beef.b_user_id : beef.a_user_id;
+ const quality = Math.max(0, Math.min(10, Number(hit.quality) || 0));
+ const firstResponse = side === 'b' && !beef.responded;
+ const feed = pushFeed(beef, { kind: opened ? 'open' : (firstResponse ? 'respond' : 'hit'), side, text: String(hit.best_line || '').slice(0, 220), quality, about: hit.about || null, announcer: hit.announcer || null, vod_id: hit.vod_id || null, sec: hit.sec ?? null });
+ db.run(`UPDATE arena_beefs SET score_${side} = score_${side} + ?, hits_${side} = hits_${side} + 1, last_${side}_at = CURRENT_TIMESTAMP, responded = CASE WHEN ? THEN 1 ELSE responded END,
+ on_clock = ?, clock_until = ?, feed_json = ? WHERE id = ?`,
+ [quality, side === 'b' ? 1 : 0, other, clockFor(otherId), feed, beef.id]);
+ // Bounty: anyone with a bounty on their head pays double to whoever collects.
+ const bounty = board().openBountyOn(targetId);
+ board().addXp(speakerId, quality * XP_BEEF_HIT * (bounty ? 2 : 1), 'beef_hit', beef.id, { beefHit: true, line: hit.best_line, lineScore: quality, lineVodId: hit.vod_id, lineSec: hit.sec });
+ if (bounty) board().recordBountyHit(speakerId, bounty, quality);
+ return { beef: db.get('SELECT * FROM arena_beefs WHERE id = ?', [beef.id]), opened, side, first_response: firstResponse, bounty: !!bounty };
+}
+
+function hype(beefId, side, voterKey) {
+ ensureTables();
+ const beef = db.get('SELECT * FROM arena_beefs WHERE id = ? AND status = ?', [beefId, 'open']);
+ if (!beef) throw new Error('No open beef with that id');
+ if (!['a', 'b'].includes(side)) throw new Error('side must be a or b');
+ const sideUser = side === 'a' ? beef.a_user_id : beef.b_user_id;
+ if (voterKey === `user:${sideUser}`) throw new Error("You can't hype yourself");
+ const ins = db.run('INSERT OR IGNORE INTO arena_beef_hype (beef_id, side, voter_key) VALUES (?, ?, ?)', [beefId, side, voterKey]);
+ const n = db.get('SELECT COUNT(*) AS n FROM arena_beef_hype WHERE beef_id = ? AND side = ?', [beefId, side])?.n || 0;
+ db.run(`UPDATE arena_beefs SET crowd_${side} = ? WHERE id = ?`, [Math.min(CROWD_MAX, n), beefId]);
+ if (ins.changes) board().addXp(sideUser, board().XP_HYPE, 'hype', beefId);
+ return { added: !!ins.changes, hypers: n, crowd: Math.min(CROWD_MAX, n) };
+}
+
+function totals(beef) {
+ return { a: Number(((beef.score_a || 0) + (beef.crowd_a || 0)).toFixed(1)), b: Number(((beef.score_b || 0) + (beef.crowd_b || 0)).toFixed(1)) };
+}
+
+function resolve(beef, resolution, winnerId) {
+ const loserId = winnerId == null ? null : (winnerId === beef.a_user_id ? beef.b_user_id : beef.a_user_id);
+ let upset = 0;
+ try { const roster = arena().loadRoster(); if (winnerId && loserId) { const rw = roster.order.indexOf(winnerId), rl = roster.order.indexOf(loserId); upset = rw >= 0 && rl >= 0 && rw - rl >= 4 ? 1 : 0; } } catch { /* */ }
+ const t = totals(beef);
+ const ctx = { a: nameOf(beef.a_user_id), b: nameOf(beef.b_user_id), winner: winnerId ? nameOf(winnerId) : null, loser: loserId ? nameOf(loserId) : null, score_a: t.a, score_b: t.b, upset: !!upset, streak: winnerId ? streakFor(winnerId) + 1 : 0 };
+ const feed = pushFeed(beef, { kind: resolution === 'forfeit' ? 'forfeit' : 'end', side: winnerId == null ? null : (winnerId === beef.a_user_id ? 'a' : 'b'), text: null, upset: !!upset });
+ db.run(`UPDATE arena_beefs SET status = 'resolved', resolution = ?, winner_user_id = ?, resolved_at = CURRENT_TIMESTAMP, on_clock = NULL, clock_until = NULL, feed_json = ?, upset = ?, result_headline = ? WHERE id = ? AND status = 'open'`, [resolution, winnerId, feed, upset, templateHeadline(resolution === 'forfeit' ? 'forfeit' : 'score', ctx), beef.id]);
+ if (winnerId) board().addXp(winnerId, XP_BEEF_WIN + (upset ? 20 : 0), upset ? 'beef_upset_win' : 'beef_win', beef.id);
+ // Viewers who picked a side: settle their clout.
+ const voters = db.all('SELECT voter_key, side FROM arena_beef_sides WHERE beef_id = ?', [beef.id]);
+ const winSide = winnerId == null ? null : (winnerId === beef.a_user_id ? 'a' : 'b');
+ try { board().settleClout && board().settleClout(voters.map(v => v.voter_key), new Set(voters.filter(v => v.side === winSide).map(v => v.voter_key))); } catch { /* */ }
+ console.log(`[Arena] beef #${beef.id} resolved: ${resolution}${winnerId ? ` winner user ${winnerId}` : ' draw'}${upset ? ' UPSET' : ''}`);
+ const id = beef.id;
+ headlineFor(resolution, ctx).then(h => { if (h) db.run('UPDATE arena_beefs SET result_headline = ? WHERE id = ?', [h, id]); }).catch(() => {});
+ try { arena().loadRoster(true); } catch { /* */ }
+}
+
+/** Sides: viewers back a fighter in an open beef (`!side <name>` in chat or the button). */
+function pickSide(beefId, voterKey, side, userId = null) {
+ ensureTables();
+ const beef = db.get('SELECT * FROM arena_beefs WHERE id = ? AND status = ?', [beefId, 'open']);
+ if (!beef) throw new Error('No open beef with that id');
+ if (!['a', 'b'].includes(side)) throw new Error('side must be a or b');
+ db.run('INSERT INTO arena_beef_sides (beef_id, voter_key, side, user_id) VALUES (?, ?, ?, ?) ON CONFLICT(beef_id, voter_key) DO UPDATE SET side = excluded.side, created_at = CURRENT_TIMESTAMP', [beefId, voterKey, side, userId]);
+ return sidesTally(beefId);
+}
+function sidesTally(beefId) {
+ const r = db.get(`SELECT SUM(side = 'a') AS a, SUM(side = 'b') AS b FROM arena_beef_sides WHERE beef_id = ?`, [beefId]) || {};
+ const a = r.a || 0, b = r.b || 0;
+ return { a, b, share_a: a + b ? Math.round((a / (a + b)) * 100) : 50 };
+}
+
+/** Current win streak (consecutive resolved beefs won). */
+function streakFor(userId) {
+ ensureTables();
+ let n = 0;
+ for (const b of db.all(`SELECT winner_user_id FROM arena_beefs WHERE status = 'resolved' AND (a_user_id = ? OR b_user_id = ?) ORDER BY resolved_at DESC LIMIT 20`, [userId, userId])) { if (b.winner_user_id === userId) n++; else break; }
+ return n;
+}
+
+/** Rivalry between two fighters: fights, record, receipts (best lines from earlier beefs). */
+function rivalry(u1, u2) {
+ ensureTables();
+ const rows = db.all(`SELECT * FROM arena_beefs WHERE status = 'resolved' AND ((a_user_id = ? AND b_user_id = ?) OR (a_user_id = ? AND b_user_id = ?)) ORDER BY resolved_at DESC LIMIT 10`, [u1, u2, u2, u1]);
+ const wins1 = rows.filter(r => r.winner_user_id === u1).length, wins2 = rows.filter(r => r.winner_user_id === u2).length;
+ const receipts = [];
+ for (const r of rows.slice(0, 3)) for (const e of parseJson(r.feed_json, [])) if (e.text && e.quality >= 6) receipts.push({ beef_id: r.id, side_user_id: e.side === 'a' ? r.a_user_id : r.b_user_id, text: e.text, quality: e.quality, vod_id: e.vod_id, sec: e.sec, at: e.at });
+ receipts.sort((x, y) => y.quality - x.quality);
+ return { fights: rows.length, wins_1: wins1, wins_2: wins2, last: rows[0] ? { id: rows[0].id, winner_user_id: rows[0].winner_user_id, resolution: rows[0].resolution, at: rows[0].resolved_at } : null, receipts: receipts.slice(0, 4) };
+}
+
+function rivalriesFor(userId, limit = 5) {
+ ensureTables();
+ const opps = db.all(`SELECT CASE WHEN a_user_id = ? THEN b_user_id ELSE a_user_id END AS opp, COUNT(*) AS n FROM arena_beefs WHERE (a_user_id = ? OR b_user_id = ?) GROUP BY opp HAVING n >= 2 ORDER BY n DESC LIMIT ?`, [userId, userId, userId, limit]);
+ const b = board();
+ const roster = arena().loadRoster();
+ return opps.map(o => { const r = rivalry(userId, o.opp); return { opponent: b.fighterBrief(o.opp, roster), fights: r.fights + (openBeefBetween(userId, o.opp) ? 1 : 0), wins: r.wins_1, losses: r.wins_2, open: !!openBeefBetween(userId, o.opp), receipts: r.receipts.slice(0, 2) }; });
+}
+
+/** Ticker: enforce clocks and hard ends; tighten an offline clock when the target goes live. */
+function tick() {
+ ensureTables();
+ const now = Date.now();
+ for (const beef of db.all(`SELECT * FROM arena_beefs WHERE status = 'open'`)) {
+ const t = totals(beef);
+ if (beef.ends_at && Date.parse(beef.ends_at + 'Z') <= now) {
+ resolve(beef, 'score', t.a === t.b ? null : (t.a > t.b ? beef.a_user_id : beef.b_user_id));
+ continue;
+ }
+ if (beef.on_clock && beef.clock_until) {
+ const onId = beef.on_clock === 'a' ? beef.a_user_id : beef.b_user_id;
+ const until = Date.parse(beef.clock_until + 'Z');
+ // Tighten: if the side on the clock is live now, they get the live window at most.
+ const liveCap = now + RESPONSE_LIVE_MIN * 60 * 1000;
+ if (isLive(onId) && until > liveCap) db.run('UPDATE arena_beefs SET clock_until = ? WHERE id = ?', [sqlDate(liveCap), beef.id]);
+ else if (until <= now) {
+ const winner = beef.on_clock === 'a' ? beef.b_user_id : beef.a_user_id;
+ resolve(beef, 'forfeit', winner);
+ }
+ }
+ }
+}
+
+// ── Records + views ──────────────────────────────────────────
+
+function recordFor(userId) {
+ ensureTables();
+ const r = db.get(`SELECT SUM(CASE WHEN winner_user_id = ? THEN 1 ELSE 0 END) AS wins,
+ SUM(CASE WHEN status = 'resolved' AND winner_user_id IS NOT NULL AND winner_user_id != ? THEN 1 ELSE 0 END) AS losses,
+ SUM(CASE WHEN status = 'resolved' AND winner_user_id IS NULL THEN 1 ELSE 0 END) AS draws
+ FROM arena_beefs WHERE a_user_id = ? OR b_user_id = ?`, [userId, userId, userId, userId]) || {};
+ return { wins: r.wins || 0, losses: r.losses || 0, draws: r.draws || 0 };
+}
+
+function recentWins(userId, days = 7) {
+ ensureTables();
+ return db.get(`SELECT COUNT(*) AS n FROM arena_beefs WHERE winner_user_id = ? AND resolved_at >= datetime('now', ?)`, [userId, `-${days} days`])?.n || 0;
+}
+
+function beefView(beef, roster) {
+ const t = totals(beef);
+ const b = board();
+ const clockMs = beef.clock_until ? Date.parse(beef.clock_until + 'Z') - Date.now() : null;
+ return {
+ id: beef.id, status: beef.status, resolution: beef.resolution,
+ a: { ...b.fighterBrief(beef.a_user_id, roster), score: Number((beef.score_a || 0).toFixed(1)), hits: beef.hits_a || 0, crowd: beef.crowd_a || 0, total: t.a, last_at: beef.last_a_at },
+ b: { ...b.fighterBrief(beef.b_user_id, roster), score: Number((beef.score_b || 0).toFixed(1)), hits: beef.hits_b || 0, crowd: beef.crowd_b || 0, total: t.b, last_at: beef.last_b_at },
+ share_a: t.a + t.b > 0 ? Math.round((t.a / (t.a + t.b)) * 100) : 50,
+ on_clock: beef.on_clock, clock_until: beef.clock_until ? new Date(beef.clock_until + 'Z').toISOString() : null, clock_seconds_left: clockMs == null ? null : Math.max(0, Math.round(clockMs / 1000)),
+ clock_is_live_window: beef.on_clock ? isLive(beef.on_clock === 'a' ? beef.a_user_id : beef.b_user_id) : false,
+ responded: !!beef.responded,
+ opener_line: beef.opener_line, headline: beef.headline, result_headline: beef.result_headline,
+ rematch: !!beef.rematch, upset: !!beef.upset, bounty: !!beef.bounty_topic_id,
+ sides: sidesTally(beef.id),
+ history: rivalry(beef.a_user_id, beef.b_user_id),
+ streaks: { a: streakFor(beef.a_user_id), b: streakFor(beef.b_user_id) },
+ feed: parseJson(beef.feed_json, []).slice(-14),
+ winner_user_id: beef.winner_user_id,
+ opened_at: beef.opened_at, ends_at: beef.ends_at ? new Date(beef.ends_at + 'Z').toISOString() : null, resolved_at: beef.resolved_at,
+ rules: { response_live_min: RESPONSE_LIVE_MIN, response_offline_hours: RESPONSE_OFFLINE_HOURS, max_hours: MAX_BEEF_HOURS, crowd_max: CROWD_MAX },
+ };
+}
+
+function list({ limitResolved = 12 } = {}) {
+ ensureTables();
+ const roster = arena().loadRoster();
+ const open = db.all(`SELECT * FROM arena_beefs WHERE status = 'open' ORDER BY COALESCE(last_b_at, last_a_at, opened_at) DESC`).map(b => beefView(b, roster));
+ const resolved = db.all(`SELECT * FROM arena_beefs WHERE status = 'resolved' ORDER BY resolved_at DESC LIMIT ?`, [limitResolved]).map(b => beefView(b, roster));
+ return { open, resolved };
+}
+
+function get(id) {
+ ensureTables();
+ const b = db.get('SELECT * FROM arena_beefs WHERE id = ?', [id]);
+ return b ? beefView(b, arena().loadRoster()) : null;
+}
+
+function forUser(userId, limit = 8) {
+ ensureTables();
+ const roster = arena().loadRoster();
+ return db.all(`SELECT * FROM arena_beefs WHERE a_user_id = ? OR b_user_id = ? ORDER BY CASE WHEN status = 'open' THEN 0 ELSE 1 END, COALESCE(resolved_at, opened_at) DESC LIMIT ?`, [userId, userId, limit]).map(b => beefView(b, roster));
+}
+
+module.exports = {
+ ensureTables, recordHit, hype, tick, recordFor, recentWins, list, get, forUser, openBeefBetween, openBeefsFor, beefView, totals,
+ pickSide, sidesTally, streakFor, rivalry, rivalriesFor, nameOf,
+ RESPONSE_LIVE_MIN, RESPONSE_OFFLINE_HOURS, MAX_BEEF_HOURS, CROWD_MAX, XP_BEEF_WIN, XP_BEEF_OPEN, XP_BEEF_HIT,
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,579 @@
@@ -0,0 +1,579 @@
+/**
+ * OpenVibe.Live — Arena Board: topics, angles, trash levels
+ *
+ * topic → a thing to talk shit about. Made by chat (`!topic …`), by a streamer or a
+ * viewer on the page, or seeded by the AI when the board runs dry. Lives on
+ * the board for TOPIC_TTL_HOURS after its last activity.
+ * angles → when a streamer picks a topic, the AI generates 3–4 angles inside it
+ * ("defend your schedule", "roast the rival's setup"…). The listener judges
+ * their live speech against the angles; each cleared angle gives XP, clearing
+ * all of them conquers the topic (big XP).
+ * level → Trash Level is persistent per streamer (XP_PER_LEVEL per level) and feeds
+ * POWER (see arena-service.talkBonus): beefs, angles and chat hype all pay XP.
+ */
+'use strict';
+
+const db = require('../db/database');
+const llm = require('../ai/llm');
+
+const TOPIC_TTL_HOURS = 36;
+const TOPIC_MAX_LEN = 140;
+const ANGLES_PER_TOPIC = 3;
+const XP_PER_LEVEL = 50;
+const XP_ANGLE_CLEARED = 25;
+const XP_TOPIC_CONQUERED = 60;
+const XP_HYPE = 2;
+const MAX_OPEN_TOPICS = 24;
+
+const SEED_TOPICS = [
+ { text: 'Whose chat is actually the smartest chat on this site?', hint: 'Bring receipts, or confidence. Confidence is cheaper.' },
+ { text: 'Stream schedules are a lifestyle. Defend yours against the calendar.', hint: 'Sleep is for people with fewer viewers.' },
+ { text: 'The last thing that went wrong on your stream was a strategic decision. Explain.', hint: 'The audio cutting out was a bit. Obviously.' },
+ { text: 'Sell your category to someone who has never heard of it.', hint: 'You have the mic. They have their doubts.' },
+ { text: 'The worst possible advice for a new streamer, delivered with total confidence.', hint: 'Every word must sound like wisdom.' },
+ { text: 'Your setup vs everyone else\'s setup.', hint: 'Cables count. Cable management counts double.' },
+ { text: 'Rank the top three fighters and explain why you\'re about to move up.', hint: 'A plan that cannot possibly work scores double.' },
+ { text: 'What your chat says about you when you step away.', hint: 'They say worse. Get ahead of it.' },
+];
+
+let _ready = false;
+function ensureTables() {
+ if (_ready) return;
+ db.run(`CREATE TABLE IF NOT EXISTS arena_topics (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ text TEXT NOT NULL,
+ hint TEXT,
+ created_by TEXT NOT NULL,
+ creator_user_id INTEGER,
+ creator_name TEXT,
+ status TEXT NOT NULL DEFAULT 'open',
+ angles_json TEXT,
+ joins INTEGER DEFAULT 0,
+ hits INTEGER DEFAULT 0,
+ conquered INTEGER DEFAULT 0,
+ last_activity_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ )`);
+ db.run(`CREATE TABLE IF NOT EXISTS arena_topic_progress (
+ topic_id INTEGER NOT NULL,
+ user_id INTEGER NOT NULL,
+ angle_idx INTEGER NOT NULL,
+ progress REAL DEFAULT 0,
+ score REAL DEFAULT 0,
+ hits INTEGER DEFAULT 0,
+ best_line TEXT,
+ best_vod_id INTEGER,
+ best_sec INTEGER,
+ cleared_at DATETIME,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (topic_id, user_id, angle_idx)
+ )`);
+ db.run(`CREATE TABLE IF NOT EXISTS arena_topic_members (
+ topic_id INTEGER NOT NULL,
+ user_id INTEGER NOT NULL,
+ active INTEGER DEFAULT 1,
+ conquered_at DATETIME,
+ joined_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (topic_id, user_id)
+ )`);
+ db.run(`CREATE TABLE IF NOT EXISTS arena_topic_hype (
+ topic_id INTEGER NOT NULL,
+ user_id INTEGER NOT NULL,
+ voter_key TEXT NOT NULL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (topic_id, user_id, voter_key)
+ )`);
+ db.run(`CREATE TABLE IF NOT EXISTS arena_trash_levels (
+ user_id INTEGER PRIMARY KEY,
+ xp INTEGER DEFAULT 0,
+ level INTEGER DEFAULT 1,
+ angles_cleared INTEGER DEFAULT 0,
+ topics_conquered INTEGER DEFAULT 0,
+ beef_hits INTEGER DEFAULT 0,
+ best_line TEXT,
+ best_line_vod_id INTEGER,
+ best_line_sec INTEGER,
+ best_line_score REAL DEFAULT 0,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ )`);
+ db.run(`CREATE TABLE IF NOT EXISTS arena_xp_log (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER NOT NULL,
+ amount INTEGER NOT NULL,
+ reason TEXT NOT NULL,
+ ref_id INTEGER,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ )`);
+ for (const col of ["kind TEXT NOT NULL DEFAULT 'topic'", 'side_a TEXT', 'side_b TEXT', 'phrase TEXT', 'target_user_id INTEGER', 'headline TEXT', 'heat REAL DEFAULT 0', 'source_note TEXT', 'expires_at DATETIME', 'winner_side TEXT', 'resolved_json TEXT']) {
+ try { db.run(`ALTER TABLE arena_topics ADD COLUMN ${col}`); } catch { /* exists */ }
+ }
+ db.run(`CREATE TABLE IF NOT EXISTS arena_topic_sides (
+ topic_id INTEGER NOT NULL,
+ voter_key TEXT NOT NULL,
+ side TEXT NOT NULL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (topic_id, voter_key)
+ )`);
+ db.run(`CREATE TABLE IF NOT EXISTS arena_viewer_clout (
+ voter_key TEXT PRIMARY KEY,
+ user_id INTEGER,
+ picks INTEGER DEFAULT 0,
+ wins INTEGER DEFAULT 0,
+ streak INTEGER DEFAULT 0,
+ best_streak INTEGER DEFAULT 0,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ )`);
+ db.run('CREATE INDEX IF NOT EXISTS idx_arena_xp_log_user ON arena_xp_log (user_id, created_at)');
+ _ready = true;
+}
+
+function aiOn() { try { return llm.isEnabled() && llm.withinBudget(); } catch { return false; } }
+function parseJson(t, f = null) { try { return t ? JSON.parse(t) : f; } catch { return f; } }
+function arena() { return require('./arena-service'); }
+function levelFor(xp) { return 1 + Math.floor((Number(xp) || 0) / XP_PER_LEVEL); }
+
+// ── Levels ───────────────────────────────────────────────────
+
+function levelRow(userId) {
+ ensureTables();
+ return db.get('SELECT * FROM arena_trash_levels WHERE user_id = ?', [userId]) || { user_id: userId, xp: 0, level: 1, angles_cleared: 0, topics_conquered: 0, beef_hits: 0 };
+}
+
+function addXp(userId, amount, reason, refId = null, extra = {}) {
+ ensureTables();
+ amount = Math.round(Number(amount) || 0);
+ if (amount <= 0) return levelRow(userId);
+ const before = levelRow(userId);
+ db.run(`INSERT INTO arena_trash_levels (user_id, xp, level) VALUES (?, ?, ?)
+ ON CONFLICT(user_id) DO UPDATE SET xp = xp + excluded.xp, level = ?, updated_at = CURRENT_TIMESTAMP`,
+ [userId, amount, levelFor(amount), levelFor((before.xp || 0) + amount)]);
+ db.run('INSERT INTO arena_xp_log (user_id, amount, reason, ref_id) VALUES (?, ?, ?, ?)', [userId, amount, reason, refId]);
+ const sets = [];
+ if (extra.angle) sets.push('angles_cleared = angles_cleared + 1');
+ if (extra.topic) sets.push('topics_conquered = topics_conquered + 1');
+ if (extra.beefHit) sets.push('beef_hits = beef_hits + 1');
+ if (sets.length) db.run(`UPDATE arena_trash_levels SET ${sets.join(', ')} WHERE user_id = ?`, [userId]);
+ if (extra.line && (extra.lineScore || 0) > (before.best_line_score || 0)) {
+ db.run('UPDATE arena_trash_levels SET best_line = ?, best_line_vod_id = ?, best_line_sec = ?, best_line_score = ? WHERE user_id = ?', [String(extra.line).slice(0, 220), extra.lineVodId || null, extra.lineSec ?? null, extra.lineScore, userId]);
+ }
+ const after = levelRow(userId);
+ if (after.level > before.level) console.log(`[Arena] user ${userId} → Trash Level ${after.level}`);
+ try { arena().loadRoster(true); } catch { /* */ }
+ return { ...after, leveled_up: after.level > before.level, gained: amount };
+}
+
+/** XP earned in the last N days (POWER bonus input; recent effort matters more than history). */
+function recentXp(userId, days = 7) {
+ ensureTables();
+ return db.get(`SELECT COALESCE(SUM(amount), 0) AS n FROM arena_xp_log WHERE user_id = ? AND created_at >= datetime('now', ?)`, [userId, `-${days} days`])?.n || 0;
+}
+
+function levelView(userId) {
+ const r = levelRow(userId);
+ const xp = r.xp || 0;
+ return {
+ level: levelFor(xp), xp, xp_into_level: xp - (levelFor(xp) - 1) * XP_PER_LEVEL, xp_per_level: XP_PER_LEVEL, next_level_xp: levelFor(xp) * XP_PER_LEVEL,
+ angles_cleared: r.angles_cleared || 0, topics_conquered: r.topics_conquered || 0, beef_hits: r.beef_hits || 0,
+ recent_xp: recentXp(userId),
+ best_line: r.best_line ? { text: r.best_line, vod_id: r.best_line_vod_id, sec: r.best_line_sec, score: r.best_line_score } : null,
+ };
+}
+
+// ── Topics ───────────────────────────────────────────────────
+
+function cleanTopicText(text) {
+ let t = String(text || '').replace(/\s+/g, ' ').trim();
+ if (t.length < 8) throw new Error('Give the topic a few more words');
+ if (arena()._isBannedText(t)) throw new Error('That topic crosses the line (threats, minors, doxxing)');
+ t = t.slice(0, TOPIC_MAX_LEN);
+ if (!/[.?!]$/.test(t)) t += /^(who|what|why|how|when|where|which|is|are|does|do|can|should)\b/i.test(t) ? '?' : '.';
+ return t.charAt(0).toUpperCase() + t.slice(1);
+}
+
+const KIND_TTL_HOURS = { topic: TOPIC_TTL_HOURS, debate: 24, phrase: 6, bounty: 6 };
+
+function createTopic({ text, hint = null, createdBy, creatorUserId = null, creatorName = null, kind = 'topic', sideA = null, sideB = null, phrase = null, targetUserId = null, headline = null, sourceNote = null }) {
+ ensureTables();
+ const clean = cleanTopicText(text);
+ kind = ['topic', 'debate', 'phrase', 'bounty'].includes(kind) ? kind : 'topic';
+ if (kind === 'debate' && !(sideA && sideB)) throw new Error('A debate needs two sides');
+ if (kind === 'phrase' && !phrase) throw new Error('A phrase challenge needs the phrase');
+ if (kind === 'bounty' && !targetUserId) throw new Error('A bounty needs a target');
+ if (kind === 'bounty' && db.get(`SELECT id FROM arena_topics WHERE status = 'open' AND kind = 'bounty' AND target_user_id = ?`, [targetUserId])) throw new Error('There is already a bounty on them');
+ const dup = db.get(`SELECT id FROM arena_topics WHERE status = 'open' AND LOWER(text) = LOWER(?)`, [clean]);
+ if (dup) throw new Error('That topic is already on the board');
+ const open = db.get(`SELECT COUNT(*) AS n FROM arena_topics WHERE status = 'open'`)?.n || 0;
+ if (open >= MAX_OPEN_TOPICS) archiveStale(true);
+ const expires = new Date(Date.now() + (KIND_TTL_HOURS[kind] || TOPIC_TTL_HOURS) * 3600 * 1000).toISOString().replace('T', ' ').slice(0, 19);
+ // Debates carry their sides as the first two angles so the judge can classify a rant.
+ const angles = kind === 'debate' ? JSON.stringify([{ text: `FOR: ${sideA}`, hint: 'Argue this side like your channel depends on it.' }, { text: `AGAINST: ${sideB}`, hint: 'Tear the other side apart.' }, { text: 'Roast everyone who took this seriously', hint: 'The third option is always chaos.' }]) : null;
+ db.run(`INSERT INTO arena_topics (text, hint, created_by, creator_user_id, creator_name, kind, side_a, side_b, phrase, target_user_id, headline, source_note, expires_at, angles_json)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ [clean, hint ? String(hint).slice(0, 120) : null, createdBy, creatorUserId, creatorName ? String(creatorName).slice(0, 40) : null, kind, sideA ? String(sideA).slice(0, 80) : null, sideB ? String(sideB).slice(0, 80) : null, phrase ? String(phrase).slice(0, 60) : null, targetUserId, headline ? String(headline).slice(0, 160) : null, sourceNote ? String(sourceNote).slice(0, 80) : null, expires, angles]);
+ const row = db.get('SELECT * FROM arena_topics ORDER BY id DESC LIMIT 1');
+ console.log(`[Arena] ${kind} #${row.id} by ${createdBy}${creatorName ? ' ' + creatorName : ''}: "${clean}"`);
+ return row;
+}
+
+/** Heat = activity in the last hour (hits, joins, hype, side picks), decays with age. Hot things rise. */
+function computeHeat(topicId) {
+ const hits = db.get(`SELECT COUNT(*) AS n FROM arena_xp_log WHERE ref_id = ? AND reason IN ('topic_hit', 'angle_cleared', 'topic_conquered', 'phrase_hit', 'bounty_hit') AND created_at >= datetime('now', '-60 minutes')`, [topicId])?.n || 0;
+ const hype = db.get(`SELECT COUNT(*) AS n FROM arena_topic_hype WHERE topic_id = ? AND created_at >= datetime('now', '-60 minutes')`, [topicId])?.n || 0;
+ const sides = db.get(`SELECT COUNT(*) AS n FROM arena_topic_sides WHERE topic_id = ? AND created_at >= datetime('now', '-60 minutes')`, [topicId])?.n || 0;
+ const talking = db.get(`SELECT COUNT(*) AS n FROM arena_topic_members WHERE topic_id = ? AND active = 1`, [topicId])?.n || 0;
+ const heat = hits * 3 + hype + sides + talking * 4;
+ db.run('UPDATE arena_topics SET heat = ? WHERE id = ?', [heat, topicId]);
+ return heat;
+}
+const HOT_THRESHOLD = 12;
+
+/** Viewers pick a side on a debate (chat `!side a|b` or the page). Clout is settled at resolution. */
+function pickSide(topicId, voterKey, side, userId = null) {
+ ensureTables();
+ const t = db.get(`SELECT * FROM arena_topics WHERE id = ? AND status = 'open' AND kind = 'debate'`, [topicId]);
+ if (!t) throw new Error('No open debate with that id');
+ if (!['a', 'b'].includes(side)) throw new Error('side must be a or b');
+ db.run('INSERT INTO arena_topic_sides (topic_id, voter_key, side) VALUES (?, ?, ?) ON CONFLICT(topic_id, voter_key) DO UPDATE SET side = excluded.side, created_at = CURRENT_TIMESTAMP', [topicId, voterKey, side]);
+ db.run('INSERT INTO arena_viewer_clout (voter_key, user_id, picks) VALUES (?, ?, 0) ON CONFLICT(voter_key) DO UPDATE SET user_id = COALESCE(excluded.user_id, arena_viewer_clout.user_id)', [voterKey, userId]);
+ db.run('UPDATE arena_topics SET last_activity_at = CURRENT_TIMESTAMP WHERE id = ?', [topicId]);
+ return sideTally(topicId);
+}
+function sideTally(topicId) {
+ const r = db.get(`SELECT SUM(side = 'a') AS a, SUM(side = 'b') AS b FROM arena_topic_sides WHERE topic_id = ?`, [topicId]) || {};
+ const a = r.a || 0, b = r.b || 0;
+ return { a, b, share_a: a + b ? Math.round((a / (a + b)) * 100) : 50 };
+}
+function settleClout(voterKeys, wonKeys) {
+ for (const k of voterKeys) {
+ const won = wonKeys.has(k);
+ db.run(`INSERT INTO arena_viewer_clout (voter_key, picks, wins, streak, best_streak) VALUES (?, 1, ?, ?, ?)
+ ON CONFLICT(voter_key) DO UPDATE SET picks = picks + 1, wins = wins + ?, streak = CASE WHEN ? THEN streak + 1 ELSE 0 END, best_streak = MAX(best_streak, CASE WHEN ? THEN streak + 1 ELSE 0 END), updated_at = CURRENT_TIMESTAMP`,
+ [k, won ? 1 : 0, won ? 1 : 0, won ? 1 : 0, won ? 1 : 0, won ? 1 : 0, won ? 1 : 0]);
+ }
+}
+function cloutLeaderboard(limit = 10) {
+ ensureTables();
+ return db.all('SELECT * FROM arena_viewer_clout WHERE picks >= 2 ORDER BY wins DESC, streak DESC LIMIT ?', [limit]).map(r => {
+ const u = r.user_id ? db.getUserById(r.user_id) : null;
+ return { name: u ? (u.display_name || u.username) : 'anonymous', username: u ? u.username : null, picks: r.picks, wins: r.wins, streak: r.streak, best_streak: r.best_streak, accuracy: r.picks ? Math.round((r.wins / r.picks) * 100) : 0 };
+ });
+}
+
+/** Debates and challenges resolve when they expire: side with more talk + more chat wins; clout settles. */
+function resolveExpired() {
+ ensureTables();
+ const due = db.all(`SELECT * FROM arena_topics WHERE status = 'open' AND expires_at IS NOT NULL AND expires_at <= datetime('now')`);
+ const results = [];
+ for (const t of due) {
+ let resolved = { kind: t.kind };
+ if (t.kind === 'debate') {
+ const talk = db.get(`SELECT SUM(CASE WHEN angle_idx = 0 THEN score ELSE 0 END) AS a, SUM(CASE WHEN angle_idx = 1 THEN score ELSE 0 END) AS b FROM arena_topic_progress WHERE topic_id = ?`, [t.id]) || {};
+ const tally = sideTally(t.id);
+ const scoreA = (talk.a || 0) * 2 + tally.a, scoreB = (talk.b || 0) * 2 + tally.b;
+ const winner = scoreA === scoreB ? null : (scoreA > scoreB ? 'a' : 'b');
+ const mvp = db.get(`SELECT user_id, SUM(score) AS s FROM arena_topic_progress WHERE topic_id = ? AND angle_idx = ? GROUP BY user_id ORDER BY s DESC LIMIT 1`, [t.id, winner === 'b' ? 1 : 0]);
+ resolved = { kind: 'debate', winner, score_a: Number(scoreA.toFixed(1)), score_b: Number(scoreB.toFixed(1)), chat: tally, mvp_user_id: mvp?.user_id || null };
+ const voters = db.all('SELECT voter_key, side FROM arena_topic_sides WHERE topic_id = ?', [t.id]);
+ settleClout(voters.map(v => v.voter_key), new Set(voters.filter(v => v.side === winner).map(v => v.voter_key)));
+ if (mvp?.user_id) addXp(mvp.user_id, 30, 'debate_mvp', t.id);
+ } else if (t.kind === 'bounty') {
+ const top = db.get(`SELECT user_id, SUM(amount) AS s FROM arena_xp_log WHERE reason = 'bounty_hit' AND ref_id = ? GROUP BY user_id ORDER BY s DESC LIMIT 1`, [t.id]);
+ resolved = { kind: 'bounty', claimed_by: top?.user_id || null, total: top?.s || 0 };
+ if (top?.user_id) addXp(top.user_id, 40, 'bounty_claimed', t.id);
+ } else if (t.kind === 'phrase') {
+ const top = db.get(`SELECT user_id, SUM(amount) AS s FROM arena_xp_log WHERE reason = 'phrase_hit' AND ref_id = ? GROUP BY user_id ORDER BY s DESC LIMIT 1`, [t.id]);
+ resolved = { kind: 'phrase', top_user_id: top?.user_id || null };
+ }
+ db.run(`UPDATE arena_topics SET status = 'resolved', winner_side = ?, resolved_json = ? WHERE id = ?`, [resolved.winner || null, JSON.stringify(resolved), t.id]);
+ db.run('UPDATE arena_topic_members SET active = 0 WHERE topic_id = ?', [t.id]);
+ results.push({ id: t.id, ...resolved });
+ console.log(`[Arena] ${t.kind} #${t.id} resolved: ${JSON.stringify(resolved)}`);
+ }
+ return results;
+}
+
+/** Bounty check for a beef hit: doubles XP while a bounty on the target is open. Returns the bounty topic or null. */
+function openBountyOn(targetUserId) {
+ ensureTables();
+ return db.get(`SELECT * FROM arena_topics WHERE status = 'open' AND kind = 'bounty' AND target_user_id = ?`, [targetUserId]) || null;
+}
+function recordBountyHit(hitterId, bounty, quality) {
+ addXp(hitterId, Math.round(quality), 'bounty_hit', bounty.id);
+ db.run('UPDATE arena_topics SET hits = hits + 1, last_activity_at = CURRENT_TIMESTAMP WHERE id = ?', [bounty.id]);
+}
+/** Phrase challenges: no model needed — the phrase either came out of their mouth or it didn't. */
+function checkPhrases(userId, text, lineRef) {
+ ensureTables();
+ const hits = [];
+ for (const t of db.all(`SELECT * FROM arena_topics WHERE status = 'open' AND kind = 'phrase'`)) {
+ if (!t.phrase || !String(text).toLowerCase().includes(String(t.phrase).toLowerCase())) continue;
+ const already = db.get(`SELECT COUNT(*) AS n FROM arena_xp_log WHERE user_id = ? AND reason = 'phrase_hit' AND ref_id = ?`, [userId, t.id])?.n || 0;
+ const xp = already === 0 ? 15 : 5;
+ addXp(userId, xp, 'phrase_hit', t.id, { line: text.slice(0, 200), lineScore: 6, lineVodId: lineRef?.vod_id, lineSec: lineRef?.sec });
+ db.run('UPDATE arena_topics SET hits = hits + 1, last_activity_at = CURRENT_TIMESTAMP WHERE id = ?', [t.id]);
+ db.run('INSERT OR IGNORE INTO arena_topic_members (topic_id, user_id, active) VALUES (?, ?, 0)', [t.id, userId]);
+ hits.push({ topic_id: t.id, phrase: t.phrase, xp });
+ }
+ return hits;
+}
+
+function archiveStale(force = false) {
+ ensureTables();
+ db.run(`UPDATE arena_topics SET status = 'archived' WHERE status = 'open' AND last_activity_at < datetime('now', ?)`, [`-${TOPIC_TTL_HOURS} hours`]);
+ if (force) {
+ const extra = db.all(`SELECT id FROM arena_topics WHERE status = 'open' ORDER BY last_activity_at ASC`).slice(0, Math.max(0, (db.get(`SELECT COUNT(*) AS n FROM arena_topics WHERE status = 'open'`)?.n || 0) - MAX_OPEN_TOPICS + 1));
+ for (const r of extra) db.run(`UPDATE arena_topics SET status = 'archived' WHERE id = ?`, [r.id]);
+ }
+}
+
+const TOPIC_SEED_SCHEMA = { name: 'arena_pulse', schema: { type: 'object', additionalProperties: false, required: ['pulse', 'events'], properties: {
+ pulse: { type: 'string', description: 'One sentence, ≤ 140 chars: what the community is on about right now, hype-caster voice, name names' },
+ events: { type: 'array', minItems: 2, maxItems: 5, items: { type: 'object', additionalProperties: false, required: ['kind', 'text', 'hint', 'source', 'headline', 'side_a', 'side_b', 'phrase', 'target_username'], properties: {
+ kind: { type: 'string', enum: ['topic', 'debate', 'phrase', 'bounty'], description: 'topic = thing to rant on; debate = a take people are split on (fill side_a/side_b); phrase = a trending phrase/meme to work into your trash talk (fill phrase); bounty = someone the community keeps naming (fill target_username with an exact username from the snapshot)' },
+ text: { type: 'string', description: '≤ 120 chars' }, hint: { type: 'string', description: '≤ 90 chars, cheeky' },
+ source: { type: 'string', description: '≤ 6 words: where it came from, e.g. "global chat", "Goosely\'s stream", "the Maticus beef"' },
+ headline: { type: 'string', description: 'Tabloid headline for the board, ≤ 90 chars, inflammatory but funny' },
+ side_a: { type: 'string', description: 'debate only, else empty' }, side_b: { type: 'string', description: 'debate only, else empty' },
+ phrase: { type: 'string', description: 'phrase only, else empty; 2–6 words exactly as people say it' }, target_username: { type: 'string', description: 'bounty only, else empty' },
+ } } } } } };
+
+/**
+ * What the site is talking about right now — the raw material for community topics.
+ * Pulled from every AI/data source we have: the global chat AI summary + memory, the
+ * chat timeline, the last hour of transcripts across live streams, recent stream
+ * memories (what the vision model saw), streamer and VOD overviews, open beefs.
+ */
+function communityPulseInput() {
+ const clip = (t, n) => String(t || '').replace(/\s+/g, ' ').trim().slice(0, n);
+ const out = { at: new Date().toISOString() };
+ try { const g = db.getChatAiSummary('global', 0, 'rolling'); if (g) { const ov = parseJson(g.overview, null); out.global_chat = clip(ov ? (ov.today || ov.alltime) : g.overview, 700); out.global_chat_memory = clip(g.memory_json, 500); } } catch { /* */ }
+ try { out.chat_timeline = db.all(`SELECT label, detail FROM chat_timeline_events WHERE scope = 'global' AND created_at >= datetime('now', '-24 hours') ORDER BY id DESC LIMIT 12`).map(r => clip(`${r.label}: ${r.detail || ''}`, 140)); } catch { out.chat_timeline = []; }
+ try { out.chat_now = db.all(`SELECT username, message FROM chat_messages WHERE COALESCE(is_deleted, 0) = 0 AND timestamp >= datetime('now', '-2 hours') AND LENGTH(message) BETWEEN 12 AND 160 ORDER BY id DESC LIMIT 40`).filter(r => !arena()._isBannedText(r.message)).slice(0, 25).map(r => clip(`${r.username}: ${r.message}`, 160)); } catch { out.chat_now = []; }
+ try { out.on_stream_now = db.all(`SELECT u.username, e.text FROM stream_timeline_events e JOIN users u ON u.id = e.user_id WHERE e.kind = 'speech' AND e.created_at >= datetime('now', '-60 minutes') AND LENGTH(e.text) BETWEEN 30 AND 180 ORDER BY e.id DESC LIMIT 60`).filter(r => !arena()._isBannedText(r.text)).slice(0, 25).map(r => clip(`${r.username}: ${r.text}`, 180)); } catch { out.on_stream_now = []; }
+ try { out.what_streams_looked_like = db.all(`SELECT u.username, m.description FROM stream_memories m JOIN users u ON u.id = m.user_id WHERE m.captured_at >= datetime('now', '-24 hours') ORDER BY m.id DESC LIMIT 10`).map(r => clip(`${r.username}: ${r.description}`, 200)); } catch { out.what_streams_looked_like = []; }
+ try { out.streamer_overviews = db.all(`SELECT u.username, o.overview FROM streamer_overviews o JOIN users u ON u.id = o.user_id WHERE o.generated_at >= datetime('now', '-3 days') ORDER BY o.generated_at DESC LIMIT 6`).map(r => clip(`${r.username}: ${r.overview}`, 220)); } catch { out.streamer_overviews = []; }
+ try { out.recent_vods = db.all(`SELECT v.title, va.ai_overview_short AS o FROM vods v LEFT JOIN vod_ai_state va ON va.vod_id = v.id WHERE v.is_public = 1 AND v.created_at >= datetime('now', '-3 days') ORDER BY v.created_at DESC LIMIT 6`).map(r => clip(`${r.title}: ${r.o || ''}`, 160)); } catch { out.recent_vods = []; }
+ try { out.open_beefs = db.all(`SELECT a.username AS a, b.username AS b, x.opener_line FROM arena_beefs x JOIN users a ON a.id = x.a_user_id JOIN users b ON b.id = x.b_user_id WHERE x.status = 'open' ORDER BY x.opened_at DESC LIMIT 5`).map(r => clip(`${r.a} vs ${r.b}: ${r.opener_line || ''}`, 160)); } catch { out.open_beefs = []; }
+ try { const roster = arena().loadRoster(); out.ladder_top = roster.order.slice(0, 6).map(id => (parseJson(db.get('SELECT persona_json FROM arena_profiles WHERE user_id = ?', [id])?.persona_json) || {}).fighter_name || roster.byId[id].user.display_name); } catch { out.ladder_top = []; }
+ out.existing_topics = db.all(`SELECT text FROM arena_topics ORDER BY id DESC LIMIT 15`).map(r => r.text);
+ return out;
+}
+
+const PULSE_SYSTEM = `You are the community consciousness of a live-streaming site's Arena — part tabloid editor, part fight promoter, part ragebaiter. You get a snapshot of what the community is ACTUALLY talking about right now: global chat, what people said on their streams in the last hour, what the streams looked like, AI overviews, open beefs, the ladder. Turn it into (1) one hype-caster sentence about what everyone is on about (name names), and (2) 2–5 EVENTS for the board, specific to what is really going on — never generic:
+- an argument or split opinion → a DEBATE with two sharp sides;
+- a phrase, meme or running joke people keep saying → a PHRASE challenge (the exact words);
+- someone the community keeps naming, dunking on or defending → a BOUNTY on that username;
+- anything else spicy → a TOPIC to rant on.
+Every event gets a tabloid HEADLINE that is inflammatory but funny and a source tag. Provoke: the point is to bait streamers into responding on stream and chat into picking sides. Profanity, disrespect, provocation and trolling are welcome; this platform does not censor language. Hard line: no direct threats of violence, nothing sexual about minors, no doxxing. Output only the JSON.`;
+
+let _pulse = { text: null, at: null, sources: [] };
+const PULSE_REFRESH_MS = 30 * 60 * 1000;
+const PULSE_MIN_OPEN = 6;
+
+/** Refresh the community pulse + add community topics. Called by the job; safe to call often. */
+async function refreshPulse({ force = false } = {}) {
+ ensureTables();
+ archiveStale();
+ if (!force && _pulse.at && Date.now() - _pulse.at < PULSE_REFRESH_MS) return { skipped: true };
+ const open = db.get(`SELECT COUNT(*) AS n FROM arena_topics WHERE status = 'open'`)?.n || 0;
+ let made = 0;
+ if (aiOn()) {
+ try {
+ const input = communityPulseInput();
+ const r = await llm.complete({ role: 'summary', kind: 'arena_pulse', source: 'arena', system: PULSE_SYSTEM, user: JSON.stringify(input), json: TOPIC_SEED_SCHEMA, maxTokens: 600, temperature: 0.95, timeoutMs: 30000 });
+ if (r && r.json) {
+ _pulse = { text: String(r.json.pulse || '').slice(0, 200), at: Date.now(), sources: Object.entries(input).filter(([k, v]) => Array.isArray(v) ? v.length : (v && k !== 'at' && k !== 'existing_topics')).map(([k]) => k) };
+ const want = Math.max(2, PULSE_MIN_OPEN - open);
+ for (const e of (r.json.events || []).slice(0, want)) {
+ try {
+ let targetUserId = null;
+ if (e.kind === 'bounty') { const u = db.getUserByUsername(String(e.target_username || '').replace(/^@/, '')); if (!u || !arena().loadRoster().byId[u.id]) continue; targetUserId = u.id; }
+ createTopic({ text: e.text, hint: e.hint, createdBy: 'community', creatorName: String(e.source || 'the community').slice(0, 40), kind: e.kind, sideA: e.side_a || null, sideB: e.side_b || null, phrase: e.phrase || null, targetUserId, headline: e.headline || null, sourceNote: e.source || null });
+ made++;
+ } catch { /* dup / invalid */ }
+ }
+ }
+ } catch (e) { console.warn('[Arena] pulse:', e.message); }
+ }
+ if (!_pulse.at) _pulse = { text: null, at: Date.now(), sources: [] };
+ if (open + made < 3) {
+ for (const t of SEED_TOPICS.slice().sort(() => Math.random() - 0.5).slice(0, 3 - open - made)) { try { createTopic({ text: t.text, hint: t.hint, createdBy: 'ai' }); made++; } catch { /* dup */ } }
+ }
+ if (made) console.log(`[Arena] pulse refreshed — ${made} community topic(s) added`);
+ return { made, pulse: _pulse.text };
+}
+function pulse() { return { text: _pulse.text, at: _pulse.at ? new Date(_pulse.at).toISOString() : null, sources: _pulse.sources }; }
+async function seedIfEmpty() { return (await refreshPulse({ force: (db.get(`SELECT COUNT(*) AS n FROM arena_topics WHERE status = 'open'`)?.n || 0) < 3 })).made || 0; }
+
+const ANGLES_SCHEMA = { name: 'arena_topic_angles', schema: { type: 'object', additionalProperties: false, required: ['angles'], properties: { angles: { type: 'array', minItems: ANGLES_PER_TOPIC, maxItems: ANGLES_PER_TOPIC, items: { type: 'object', additionalProperties: false, required: ['text', 'hint'], properties: { text: { type: 'string', description: 'a specific angle inside the topic to talk shit about, ≤ 90 chars' }, hint: { type: 'string', description: '≤ 70 chars' } } } } } } };
+
+async function ensureAngles(topic) {
+ let angles = parseJson(topic.angles_json, null);
+ if (Array.isArray(angles) && angles.length) return angles;
+ if (aiOn()) {
+ try {
+ const r = await llm.complete({
+ role: 'summary', kind: 'arena_topic_angles', source: 'arena',
+ system: 'Break a shit-talk topic into distinct ANGLES a live-streamer can rant on for 30–60 seconds each: one brag angle, one roast angle (name names), one bit/absurd/troll angle. Savage and funny; this platform does not censor language. Hard line only: no direct threats of violence, nothing sexual about minors, no doxxing. Output only the JSON.',
+ user: JSON.stringify({ topic: topic.text, hint: topic.hint, created_by: topic.created_by }),
+ json: ANGLES_SCHEMA, maxTokens: 300, temperature: 0.9, timeoutMs: 20000,
+ });
+ if (r && r.json && Array.isArray(r.json.angles)) angles = r.json.angles.map(a => ({ text: String(a.text).slice(0, 120), hint: String(a.hint || '').slice(0, 100) }));
+ } catch (e) { console.warn('[Arena] angles:', e.message); }
+ }
+ if (!angles) angles = [
+ { text: `Brag: why you win this — "${topic.text}"`, hint: 'Numbers optional, confidence mandatory.' },
+ { text: `Roast: who is worst at this and why`, hint: 'Name a fighter. Lovingly.' },
+ { text: `Bit: the most unhinged take on it`, hint: 'Commit to the bit.' },
+ ];
+ db.run('UPDATE arena_topics SET angles_json = ? WHERE id = ?', [JSON.stringify(angles), topic.id]);
+ return angles;
+}
+
+async function joinTopic(topicId, userId) {
+ ensureTables();
+ const topic = db.get(`SELECT * FROM arena_topics WHERE id = ? AND status = 'open'`, [topicId]);
+ if (!topic) throw new Error('That topic is gone');
+ if (!arena().loadRoster().byId[userId]) throw new Error('Only fighters on the roster can talk on a topic — stream once and come back');
+ // One active topic per streamer: the listener judges their speech against it.
+ db.run('UPDATE arena_topic_members SET active = 0 WHERE user_id = ? AND active = 1', [userId]);
+ const existing = db.get('SELECT * FROM arena_topic_members WHERE topic_id = ? AND user_id = ?', [topicId, userId]);
+ if (existing) db.run('UPDATE arena_topic_members SET active = 1 WHERE topic_id = ? AND user_id = ?', [topicId, userId]);
+ else { db.run('INSERT INTO arena_topic_members (topic_id, user_id) VALUES (?, ?)', [topicId, userId]); db.run('UPDATE arena_topics SET joins = joins + 1 WHERE id = ?', [topicId]); }
+ db.run('UPDATE arena_topics SET last_activity_at = CURRENT_TIMESTAMP WHERE id = ?', [topicId]);
+ const angles = await ensureAngles(topic);
+ return { topic: { ...topic, angles }, angles };
+}
+
+function leaveTopic(userId) {
+ ensureTables();
+ db.run('UPDATE arena_topic_members SET active = 0 WHERE user_id = ? AND active = 1', [userId]);
+}
+
+function activeTopicFor(userId) {
+ ensureTables();
+ const m = db.get(`SELECT t.* FROM arena_topic_members m JOIN arena_topics t ON t.id = m.topic_id WHERE m.user_id = ? AND m.active = 1 AND t.status = 'open' ORDER BY m.joined_at DESC LIMIT 1`, [userId]);
+ return m || null;
+}
+
+function progressFor(topicId, userId) {
+ return db.all('SELECT * FROM arena_topic_progress WHERE topic_id = ? AND user_id = ? ORDER BY angle_idx ASC', [topicId, userId]);
+}
+
+/**
+ * Apply a judged chunk to the streamer's active topic. `judgement` is the listener's
+ * topic judgement: { angle_idx (or -1), quality, progress_gain, best_line, about, flagged }.
+ */
+function applyTopicJudgement(userId, topic, judgement, lineRef) {
+ if (judgement.flagged || judgement.angle_idx == null || judgement.angle_idx < 0) return { applied: false };
+ const angles = parseJson(topic.angles_json, []);
+ const idx = Math.max(0, Math.min(angles.length - 1, judgement.angle_idx));
+ const cur = db.get('SELECT * FROM arena_topic_progress WHERE topic_id = ? AND user_id = ? AND angle_idx = ?', [topic.id, userId, idx]) || { progress: 0, score: 0, hits: 0, best_line: null, cleared_at: null };
+ const wasCleared = !!cur.cleared_at;
+ const progress = Math.min(100, (cur.progress || 0) + (judgement.progress_gain || 0));
+ const better = judgement.best_line && (judgement.quality || 0) > (cur.best_score || 0);
+ db.run(`INSERT INTO arena_topic_progress (topic_id, user_id, angle_idx, progress, score, hits, best_line, best_vod_id, best_sec, updated_at)
+ VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, CURRENT_TIMESTAMP)
+ ON CONFLICT(topic_id, user_id, angle_idx) DO UPDATE SET progress = ?, score = score + ?, hits = hits + 1,
+ best_line = CASE WHEN ? THEN ? ELSE best_line END, best_vod_id = CASE WHEN ? THEN ? ELSE best_vod_id END, best_sec = CASE WHEN ? THEN ? ELSE best_sec END, updated_at = CURRENT_TIMESTAMP`,
+ [topic.id, userId, idx, progress, judgement.quality || 0, judgement.best_line || null, lineRef?.vod_id || null, lineRef?.sec ?? null,
+ progress, judgement.quality || 0, better ? 1 : 0, judgement.best_line || null, better ? 1 : 0, lineRef?.vod_id || null, better ? 1 : 0, lineRef?.sec ?? null]);
+ db.run('UPDATE arena_topics SET hits = hits + 1, last_activity_at = CURRENT_TIMESTAMP WHERE id = ?', [topic.id]);
+ let xp = Math.round((judgement.quality || 0) * 0.8);
+ let clearedAngle = false, conquered = false;
+ if (progress >= 100 && !wasCleared) {
+ db.run('UPDATE arena_topic_progress SET cleared_at = CURRENT_TIMESTAMP WHERE topic_id = ? AND user_id = ? AND angle_idx = ?', [topic.id, userId, idx]);
+ xp += XP_ANGLE_CLEARED; clearedAngle = true;
+ const clearedCount = db.get('SELECT COUNT(*) AS n FROM arena_topic_progress WHERE topic_id = ? AND user_id = ? AND cleared_at IS NOT NULL', [topic.id, userId])?.n || 0;
+ if (clearedCount >= angles.length) {
+ const m = db.get('SELECT conquered_at FROM arena_topic_members WHERE topic_id = ? AND user_id = ?', [topic.id, userId]);
+ if (!m?.conquered_at) {
+ db.run('UPDATE arena_topic_members SET conquered_at = CURRENT_TIMESTAMP, active = 0 WHERE topic_id = ? AND user_id = ?', [topic.id, userId]);
+ db.run('UPDATE arena_topics SET conquered = conquered + 1 WHERE id = ?', [topic.id]);
+ xp += XP_TOPIC_CONQUERED; conquered = true;
+ }
+ }
+ }
+ const lvl = addXp(userId, xp, clearedAngle ? (conquered ? 'topic_conquered' : 'angle_cleared') : 'topic_hit', topic.id, { angle: clearedAngle, topic: conquered, line: judgement.best_line, lineScore: judgement.quality, lineVodId: lineRef?.vod_id, lineSec: lineRef?.sec });
+ return { applied: true, angle_idx: idx, progress, cleared_angle: clearedAngle, conquered, xp, level: lvl };
+}
+
+function hypeTopic(topicId, userId, voterKey) {
+ ensureTables();
+ if (voterKey === `user:${userId}`) throw new Error("You can't hype yourself");
+ const ins = db.run('INSERT OR IGNORE INTO arena_topic_hype (topic_id, user_id, voter_key) VALUES (?, ?, ?)', [topicId, userId, voterKey]);
+ if (ins.changes) addXp(userId, XP_HYPE, 'hype', topicId);
+ const n = db.get('SELECT COUNT(*) AS n FROM arena_topic_hype WHERE topic_id = ? AND user_id = ?', [topicId, userId])?.n || 0;
+ return { added: !!ins.changes, hypers: n };
+}
+
+// ── Views ────────────────────────────────────────────────────
+
+function fighterBrief(userId, roster) {
+ const f = roster.byId[userId];
+ const persona = parseJson(db.get('SELECT persona_json FROM arena_profiles WHERE user_id = ?', [userId])?.persona_json);
+ return {
+ user: f ? f.user : (db.getUserById(userId) ? arena().publicUser(db.getUserById(userId)) : { id: userId, username: `user${userId}`, display_name: `user${userId}` }),
+ fighter_name: persona?.fighter_name || f?.user?.display_name || `user${userId}`,
+ rank: f ? roster.order.indexOf(userId) + 1 : null,
+ image_url: (() => { try { return arena().getFighterImageUrl(userId); } catch { return null; } })(),
+ live: !!db.get('SELECT 1 FROM streams WHERE user_id = ? AND is_live = 1 LIMIT 1', [userId]),
+ level: levelFor(levelRow(userId).xp || 0),
+ };
+}
+
+function topicView(topic, roster, { detail = false } = {}) {
+ const angles = parseJson(topic.angles_json, []);
+ const members = db.all(`SELECT m.*, COALESCE((SELECT COUNT(*) FROM arena_topic_progress p WHERE p.topic_id = m.topic_id AND p.user_id = m.user_id AND p.cleared_at IS NOT NULL), 0) AS cleared, COALESCE((SELECT SUM(score) FROM arena_topic_progress p WHERE p.topic_id = m.topic_id AND p.user_id = m.user_id), 0) AS score FROM arena_topic_members m WHERE m.topic_id = ? ORDER BY score DESC`, [topic.id]);
+ const heat = topic.heat || 0;
+ const out = {
+ id: topic.id, text: topic.text, hint: topic.hint, created_by: topic.created_by, creator_name: topic.creator_name, status: topic.status,
+ kind: topic.kind || 'topic', side_a: topic.side_a, side_b: topic.side_b, phrase: topic.phrase, target: topic.target_user_id ? fighterBrief(topic.target_user_id, roster) : null,
+ headline: topic.headline, source_note: topic.source_note, heat: Math.round(heat), hot: heat >= HOT_THRESHOLD,
+ expires_at: topic.expires_at ? new Date(topic.expires_at + 'Z').toISOString() : null, winner_side: topic.winner_side, resolved: parseJson(topic.resolved_json),
+ sides: topic.kind === 'debate' ? sideTally(topic.id) : null,
+ angles: angles.map((a, i) => ({ idx: i, text: a.text, hint: a.hint })),
+ joins: topic.joins || 0, hits: topic.hits || 0, conquered: topic.conquered || 0,
+ talking_now: members.filter(m => m.active).map(m => fighterBrief(m.user_id, roster)),
+ members: members.slice(0, 12).map(m => ({ ...fighterBrief(m.user_id, roster), active: !!m.active, cleared: m.cleared, score: Number((m.score || 0).toFixed(1)), conquered_at: m.conquered_at })),
+ created_at: topic.created_at, last_activity_at: topic.last_activity_at,
+ };
+ if (detail) {
+ out.progress = {};
+ for (const m of members) out.progress[m.user_id] = progressFor(topic.id, m.user_id).map(p => ({ angle_idx: p.angle_idx, progress: Math.round(p.progress || 0), score: Number((p.score || 0).toFixed(1)), hits: p.hits, best_line: p.best_line, best_vod_id: p.best_vod_id, best_sec: p.best_sec, cleared: !!p.cleared_at }));
+ out.best_lines = db.all(`SELECT p.*, p.user_id FROM arena_topic_progress p WHERE p.topic_id = ? AND p.best_line IS NOT NULL ORDER BY p.score DESC LIMIT 8`, [topic.id]).map(p => ({ ...fighterBrief(p.user_id, roster), text: p.best_line, vod_id: p.best_vod_id, sec: p.best_sec, angle_idx: p.angle_idx }));
+ }
+ return out;
+}
+
+function boardView() {
+ ensureTables();
+ archiveStale();
+ resolveExpired();
+ const roster = arena().loadRoster();
+ const topics = db.all(`SELECT * FROM arena_topics WHERE status = 'open' ORDER BY id DESC LIMIT ?`, [MAX_OPEN_TOPICS]);
+ for (const t of topics) t.heat = computeHeat(t.id);
+ topics.sort((x, y) => (y.heat - x.heat) || (Date.parse(y.last_activity_at) - Date.parse(x.last_activity_at)));
+ const recent = db.all(`SELECT * FROM arena_topics WHERE status = 'resolved' AND kind != 'topic' ORDER BY id DESC LIMIT 6`).map(t => topicView(t, roster));
+ return { open: topics.map(t => topicView(t, roster)), resolved: recent, pulse: pulse(), hot_threshold: HOT_THRESHOLD };
+}
+
+function topicDetail(topicId) {
+ ensureTables();
+ const topic = db.get('SELECT * FROM arena_topics WHERE id = ?', [topicId]);
+ if (!topic) return null;
+ return topicView(topic, arena().loadRoster(), { detail: true });
+}
+
+function levelsLeaderboard(limit = 10) {
+ ensureTables();
+ const roster = arena().loadRoster();
+ return db.all('SELECT * FROM arena_trash_levels ORDER BY xp DESC LIMIT ?', [limit]).map(r => ({ ...fighterBrief(r.user_id, roster), xp: r.xp, level: levelFor(r.xp), angles_cleared: r.angles_cleared, topics_conquered: r.topics_conquered, beef_hits: r.beef_hits, best_line: r.best_line ? { text: r.best_line, vod_id: r.best_line_vod_id, sec: r.best_line_sec } : null }));
+}
+
+module.exports = {
+ ensureTables, createTopic, seedIfEmpty, ensureAngles, joinTopic, leaveTopic, activeTopicFor, progressFor, applyTopicJudgement, hypeTopic,
+ addXp, levelRow, levelView, levelFor, recentXp, boardView, topicDetail, levelsLeaderboard, fighterBrief, archiveStale, refreshPulse, pulse, communityPulseInput,
+ computeHeat, pickSide, sideTally, cloutLeaderboard, resolveExpired, openBountyOn, recordBountyHit, checkPhrases, HOT_THRESHOLD, KIND_TTL_HOURS,
+ XP_PER_LEVEL, XP_ANGLE_CLEARED, XP_TOPIC_CONQUERED, XP_HYPE, TOPIC_TTL_HOURS, SEED_TOPICS,
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,273 @@
@@ -0,0 +1,273 @@
+/**
+ * OpenVibe.Live — Arena Listener (the ears)
+ *
+ * Every TICK_MS, for every LIVE fighter whose stream is being transcribed, read the new
+ * speech lines and route them:
+ *
+ * mentions → a line that names another fighter (username, display name, fighter name,
+ * "@name") starts/extends a MENTION BUFFER for that target; the next few
+ * lines without a name stay in it (people keep ranting after the name drop).
+ * Once the buffer has ≥ JUDGE_MIN_WORDS and ≥ JUDGE_MIN_INTERVAL_MS passed,
+ * the beef judge decides whether it was trash talk AIMED AT that fighter.
+ * Yes → beef.recordHit() (opens the beef, scores it, starts the other side's
+ * clock). See beef.js.
+ * topic → everything else goes to the streamer's ACTIVE BOARD TOPIC (if they picked
+ * one): the topic judge says which angle (if any) the chunk addressed and how
+ * well → board.applyTopicJudgement() (progress, XP, levels).
+ *
+ * Bounded: at most one judge call per stream per JUDGE_MIN_INTERVAL_MS; nothing happens
+ * for streams nobody is talking on; the slur filter voids chunks before any model call.
+ * State lives in memory (offsets are re-seeded from "now" on restart, so a restart never
+ * replays old speech).
+ */
+'use strict';
+
+const db = require('../db/database');
+const llm = require('../ai/llm');
+
+const TICK_MS = 15 * 1000;
+const JUDGE_MIN_WORDS = 20;
+const JUDGE_MIN_INTERVAL_MS = 30 * 1000;
+const MENTION_TAIL_SEC = 45; // lines within this many seconds after a name-drop stay in the buffer
+const BUFFER_MAX_CHARS = 1400;
+const STALE_BUFFER_MS = 3 * 60 * 1000;
+
+const state = new Map(); // streamId → { userId, lastOffset, lastJudgeAt, mention: { [targetId]: { lines, lastAt } }, topic: { lines } }
+
+function aiOn() { try { return llm.isEnabled() && llm.withinBudget(); } catch { return false; } }
+function arena() { return require('./arena-service'); }
+function beef() { return require('./beef'); }
+function board() { return require('./board'); }
+function parseJson(t, f = null) { try { return t ? JSON.parse(t) : f; } catch { return f; } }
+function words(t) { return String(t || '').split(/\s+/).filter(Boolean).length; }
+
+// ── Aliases: who can be called out, by which names ───────────
+
+let _aliasCache = { at: 0, list: [] };
+function aliases(roster) {
+ if (Date.now() - _aliasCache.at < 60 * 1000) return _aliasCache.list;
+ const list = [];
+ for (const id of roster.order) {
+ const f = roster.byId[id];
+ const persona = parseJson(db.get('SELECT persona_json FROM arena_profiles WHERE user_id = ?', [id])?.persona_json);
+ const names = new Set([f.user.username, f.user.display_name, persona?.fighter_name].filter(Boolean).map(s => String(s).toLowerCase().trim()));
+ for (const n of names) {
+ if (n.length < 3) continue;
+ // "@name", "name", and names with underscores/dots spoken as spaces.
+ const spoken = n.replace(/[_.-]+/g, ' ');
+ const variants = new Set([n, spoken]);
+ for (const v of variants) list.push({ userId: id, name: v, re: new RegExp(`(?:^|[^a-z0-9])@?${v.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![a-z0-9])`, 'i') });
+ }
+ }
+ // Longer names first so "goosely" beats "goose" style prefixes.
+ list.sort((a, b) => b.name.length - a.name.length);
+ _aliasCache = { at: Date.now(), list };
+ return list;
+}
+
+function mentionsIn(text, speakerId, roster) {
+ const found = new Set();
+ for (const a of aliases(roster)) {
+ if (a.userId === speakerId || found.has(a.userId)) continue;
+ if (a.re.test(text)) found.add(a.userId);
+ }
+ return [...found];
+}
+
+// ── Judges ───────────────────────────────────────────────────
+
+const BEEF_SCHEMA = {
+ name: 'arena_beef_judgement',
+ schema: {
+ type: 'object', additionalProperties: false,
+ required: ['aimed_at_target', 'quality', 'best_line', 'about', 'announcer', 'flagged'],
+ properties: {
+ aimed_at_target: { type: 'boolean', description: 'true if the speaker is trash-talking, roasting, calling out or bragging AT/ABOUT the named target (in good fun)' },
+ announcer: { type: 'string', description: 'one-line ring-announcer call of this moment, ≤ 110 chars, hype and funny (empty if not aimed at the target)' },
+ quality: { type: 'integer', minimum: 0, maximum: 10, description: 'how good the trash talk is: spice, wit, specificity, quotability' },
+ best_line: { type: 'string', description: 'the single best line, VERBATIM from the speech (empty if none)' },
+ about: { type: 'string', description: '≤ 10 words: what they said about the target' },
+ flagged: { type: 'boolean', description: 'ONLY for direct threats of violence, sexual content about minors, or doxxing — never for offensive language' },
+ },
+ },
+};
+const BEEF_SYSTEM = `You judge live streamer-vs-streamer shit talk. You get a target fighter's name(s) and the last ~60 seconds of a streamer's speech-to-text in which the target was named. Decide if the speaker is talking shit AT or ABOUT the target (roast, callout, disrespect, bragging over them, trolling them) versus just mentioning them neutrally (shout-out, thanks, gameplay chatter). Savage, profane, petty and personal is GOOD and scores high — creativity, specificity and quotability score highest; lazy generic insults score low. Pick the best verbatim line, summarise what was said about the target in ≤ 10 words. Forgive transcription noise. Offensive, provocative and taboo language — slurs included — is allowed on this platform and is NOT a reason to flag or to score low; judge the craft. Flag ONLY behavior: direct threats of violence, sexual content about minors, doxxing (addresses, phone numbers, private info). Output only the JSON.`;
+
+const TOPIC_SCHEMA = {
+ name: 'arena_topic_judgement',
+ schema: {
+ type: 'object', additionalProperties: false,
+ required: ['angle_idx', 'quality', 'progress_gain', 'best_line', 'about', 'flagged'],
+ properties: {
+ angle_idx: { type: 'integer', minimum: -1, maximum: 5, description: 'which angle (0-based) this chunk addressed, -1 if none / not trash talk' },
+ quality: { type: 'integer', minimum: 0, maximum: 10 },
+ progress_gain: { type: 'integer', minimum: 0, maximum: 60, description: 'how much of that angle this chunk covered' },
+ best_line: { type: 'string' },
+ about: { type: 'string', description: '≤ 10 words: what they talked about' },
+ flagged: { type: 'boolean' },
+ },
+ },
+};
+const TOPIC_SYSTEM = `You judge a live streamer talking shit on a BOARD TOPIC that has several angles. You get the topic, its angles (0-based) and the last ~60 seconds of speech-to-text. Say which angle (if any) the speech addressed with actual shit talk / bragging / roasting / trolling / a committed bit, score its quality (savage, profane, petty, specific and quotable = high; generic = low), how much of that angle it covered, the best verbatim line, and a ≤ 10-word summary. Ordinary gameplay chatter or off-topic talk → angle_idx -1, progress 0. Forgive transcription noise. Offensive, provocative and taboo language — slurs included — is allowed on this platform and is NOT a reason to flag or to score low; judge the craft. Flag ONLY behavior: direct threats of violence, sexual content about minors, doxxing (addresses, phone numbers, private info). Output only the JSON.`;
+
+function heuristicBeef(text, targetNames) {
+ const t = text.toLowerCase();
+ const spicy = /\b(clown|weak|scared|duck|ducking|trash|garbage|mid|washed|bum|ratio|cook|cooked|better than|can't|cannot|never|nobody|beat|fraud|ass|bet|catch (these|this)|come see|fight me|square up|run it|talk (that|your))\b/.test(t);
+ const excl = (text.match(/!/g) || []).length;
+ const quality = Math.min(10, (spicy ? 5 : 1) + excl + (words(text) > 30 ? 1 : 0));
+ const sentences = text.split(/(?<=[.!?])\s+/).filter(Boolean);
+ const named = sentences.filter(l => targetNames.some(n => l.toLowerCase().includes(n)));
+ const pick = (named.length ? named : sentences).sort((a, b) => b.length - a.length)[0] || text;
+ return { aimed_at_target: spicy, quality, best_line: pick.trim().slice(0, 200), about: text.split(/\s+/).slice(0, 8).join(' '), flagged: false, _fallback: true };
+}
+function heuristicTopic(text, angles) {
+ const t = text.toLowerCase();
+ let best = -1, bestHits = 0;
+ angles.forEach((a, i) => { const stems = (String(a.text).toLowerCase().match(/[a-z]{4,}/g) || []).map(w => w.slice(0, 5)); const hits = stems.filter(s => t.includes(s)).length; if (hits > bestHits) { bestHits = hits; best = i; } });
+ const excl = (text.match(/!/g) || []).length;
+ const quality = Math.min(10, 3 + excl + Math.min(3, bestHits));
+ return { angle_idx: bestHits ? best : -1, quality, progress_gain: bestHits ? Math.min(60, 12 + bestHits * 10) : 0, best_line: (text.split(/(?<=[.!?])\s+/).sort((a, b) => b.length - a.length)[0] || '').slice(0, 200), about: text.split(/\s+/).slice(0, 8).join(' '), flagged: false, _fallback: true };
+}
+
+async function judgeBeef(speakerId, targetId, text, roster) {
+ if (arena()._isBannedText(text)) return { aimed_at_target: false, quality: 0, best_line: '', about: 'voided', flagged: true };
+ const tf = roster.byId[targetId];
+ const targetNames = [tf.user.username, tf.user.display_name, (parseJson(db.get('SELECT persona_json FROM arena_profiles WHERE user_id = ?', [targetId])?.persona_json) || {}).fighter_name].filter(Boolean);
+ let j = null;
+ if (aiOn()) {
+ try {
+ const r = await llm.complete({ role: 'chat', kind: 'arena_beef_judge', source: 'arena', ownerUserId: speakerId, system: BEEF_SYSTEM, user: JSON.stringify({ target_names: targetNames, speech: text }), json: BEEF_SCHEMA, maxTokens: 220, temperature: 0.4, timeoutMs: 25000 });
+ if (r && r.json && typeof r.json.quality === 'number') j = r.json;
+ } catch (e) { console.warn('[Arena] beef judge:', e.message); }
+ }
+ if (!j) j = heuristicBeef(text, targetNames.map(n => n.toLowerCase()));
+ return { aimed_at_target: !!j.aimed_at_target && !j.flagged, quality: Math.max(0, Math.min(10, Math.round(Number(j.quality) || 0))), best_line: String(j.best_line || '').slice(0, 220), about: String(j.about || '').slice(0, 80), announcer: String(j.announcer || '').slice(0, 140), flagged: !!j.flagged, fallback: !!j._fallback };
+}
+
+async function judgeTopic(speakerId, topic, text) {
+ if (arena()._isBannedText(text)) return { angle_idx: -1, quality: 0, progress_gain: 0, best_line: '', about: 'voided', flagged: true };
+ const angles = parseJson(topic.angles_json, []);
+ let j = null;
+ if (aiOn()) {
+ try {
+ const r = await llm.complete({ role: 'chat', kind: 'arena_topic_judge', source: 'arena', ownerUserId: speakerId, system: TOPIC_SYSTEM, user: JSON.stringify({ topic: topic.text, hint: topic.hint, angles: angles.map((a, i) => `${i}: ${a.text}`), speech: text }), json: TOPIC_SCHEMA, maxTokens: 220, temperature: 0.4, timeoutMs: 25000 });
+ if (r && r.json && typeof r.json.quality === 'number') j = r.json;
+ } catch (e) { console.warn('[Arena] topic judge:', e.message); }
+ }
+ if (!j) j = heuristicTopic(text, angles);
+ const idx = Number.isInteger(j.angle_idx) && j.angle_idx >= 0 && j.angle_idx < angles.length ? j.angle_idx : -1;
+ return { angle_idx: j.flagged ? -1 : idx, quality: Math.max(0, Math.min(10, Math.round(Number(j.quality) || 0))), progress_gain: Math.max(0, Math.min(60, Math.round(Number(j.progress_gain) || 0))), best_line: String(j.best_line || '').slice(0, 220), about: String(j.about || '').slice(0, 80), flagged: !!j.flagged, fallback: !!j._fallback };
+}
+
+// ── Tick ─────────────────────────────────────────────────────
+
+function liveTranscribedStreams(roster) {
+ return db.all(`SELECT s.id, s.user_id, s.started_at FROM streams s WHERE s.is_live = 1 AND EXISTS (SELECT 1 FROM stream_timeline_events e WHERE e.stream_id = s.id AND e.kind = 'speech' AND e.created_at >= datetime('now', '-30 minutes'))`)
+ .filter(s => roster.byId[s.user_id]);
+}
+
+function streamOffsetNow(stream) {
+ const startedMs = stream.started_at ? Date.parse(String(stream.started_at).replace(' ', 'T') + 'Z') : Date.now();
+ return Math.max(0, (Date.now() - startedMs) / 1000);
+}
+
+function bufferText(lines) { return lines.map(l => l.t.replace(/^\s*(?:>>|--?)\s*/, '').trim()).filter(Boolean).join(' ').replace(/\s+/g, ' ').slice(-BUFFER_MAX_CHARS); }
+function lineRefFor(lines, bestLine) {
+ const needle = String(bestLine || '').toLowerCase().slice(0, 30);
+ const hit = (needle && lines.find(l => l.t.toLowerCase().includes(needle))) || lines[0];
+ return hit ? { vod_id: hit.v || null, sec: Math.max(0, hit.s - 2) } : { vod_id: null, sec: null };
+}
+
+async function tickStream(stream, roster, events) {
+ let st = state.get(stream.id);
+ if (!st) { st = { userId: stream.user_id, lastOffset: streamOffsetNow(stream) - 20, lastJudgeAt: 0, mention: {}, topic: { lines: [] } }; state.set(stream.id, st); }
+ const rows = db.all(`SELECT text, start_sec, vod_id FROM stream_timeline_events WHERE stream_id = ? AND kind = 'speech' AND start_sec > ? ORDER BY start_sec ASC LIMIT 100`, [stream.id, st.lastOffset]);
+ if (rows.length) st.lastOffset = rows[rows.length - 1].start_sec;
+ const now = Date.now();
+ for (const r of rows) {
+ const line = { t: String(r.text || ''), s: Math.floor(r.start_sec), v: r.vod_id || null };
+ try { const ph = board().checkPhrases(stream.user_id, line.t, { vod_id: line.v, sec: Math.max(0, line.s - 2) }); if (ph.length) events.push({ kind: 'phrase_hit', streamId: stream.id, speakerId: stream.user_id, hits: ph }); } catch { /* */ }
+ const targets = mentionsIn(line.t, stream.user_id, roster);
+ if (targets.length) {
+ for (const tid of targets) { const m = st.mention[tid] || (st.mention[tid] = { lines: [], lastAt: 0, lastSec: 0 }); m.lines.push(line); m.lastAt = now; m.lastSec = line.s; }
+ } else {
+ // Tail: keep feeding the most recent mention buffer for a bit after the name drop.
+ const recent = Object.entries(st.mention).filter(([, m]) => line.s - m.lastSec <= MENTION_TAIL_SEC && now - m.lastAt < STALE_BUFFER_MS).sort((x, y) => y[1].lastSec - x[1].lastSec)[0];
+ if (recent) recent[1].lines.push(line);
+ else st.topic.lines.push(line);
+ }
+ }
+ // Drop stale mention buffers that never reached the judge.
+ for (const [tid, m] of Object.entries(st.mention)) if (now - m.lastAt > STALE_BUFFER_MS) delete st.mention[tid];
+ if (st.topic.lines.length > 60) st.topic.lines = st.topic.lines.slice(-60);
+
+ if (now - st.lastJudgeAt < JUDGE_MIN_INTERVAL_MS) return;
+
+ // 1) Mention buffers first (a callout is the interesting thing).
+ for (const [tidStr, m] of Object.entries(st.mention)) {
+ const tid = Number(tidStr);
+ if (m.lines.reduce((n, l) => n + words(l.t), 0) < JUDGE_MIN_WORDS) continue;
+ const text = bufferText(m.lines);
+ const lines = m.lines; delete st.mention[tidStr];
+ st.lastJudgeAt = Date.now();
+ const j = await judgeBeef(stream.user_id, tid, text, roster);
+ if (j.aimed_at_target) {
+ const ref = lineRefFor(lines, j.best_line);
+ const res = beef().recordHit(stream.user_id, tid, { quality: j.quality, best_line: j.best_line, about: j.about, announcer: j.announcer, vod_id: ref.vod_id, sec: ref.sec });
+ st.lastBeefJudgement = { at: new Date().toISOString(), target_id: tid, ...j, opened: res?.opened, bounty: res?.bounty };
+ events.push({ kind: 'beef_hit', streamId: stream.id, speakerId: stream.user_id, targetId: tid, opened: res?.opened, quality: j.quality, line: j.best_line });
+ } else {
+ events.push({ kind: 'beef_miss', streamId: stream.id, speakerId: stream.user_id, targetId: tid, about: j.about });
+ }
+ return; // one judge call per stream per tick
+ }
+
+ // 2) Active board topic.
+ const topic = board().activeTopicFor(stream.user_id);
+ if (topic && st.topic.lines.reduce((n, l) => n + words(l.t), 0) >= JUDGE_MIN_WORDS) {
+ const lines = st.topic.lines; st.topic.lines = [];
+ const text = bufferText(lines);
+ st.lastJudgeAt = Date.now();
+ if (!parseJson(topic.angles_json, null)) await board().ensureAngles(topic);
+ const fresh = db.get('SELECT * FROM arena_topics WHERE id = ?', [topic.id]);
+ const j = await judgeTopic(stream.user_id, fresh, text);
+ const ref = lineRefFor(lines, j.best_line);
+ const res = board().applyTopicJudgement(stream.user_id, fresh, j, ref);
+ st.lastTopicJudgement = { at: new Date().toISOString(), ...j, applied: res.applied, angle_idx: res.applied ? res.angle_idx : -1, progress: res.progress, cleared_angle: res.cleared_angle, conquered: res.conquered };
+ events.push({ kind: res.applied ? 'topic_hit' : 'topic_miss', streamId: stream.id, speakerId: stream.user_id, topicId: topic.id, ...res });
+ } else if (!topic && st.topic.lines.length > 80) {
+ st.topic.lines = st.topic.lines.slice(-40);
+ }
+}
+
+let _timer = null, _busy = false;
+async function tick() {
+ if (_busy) return [];
+ _busy = true;
+ const events = [];
+ try {
+ const roster = arena().loadRoster();
+ const streams = liveTranscribedStreams(roster);
+ for (const s of streams) { try { await tickStream(s, roster, events); } catch (e) { console.warn(`[Arena] listener stream ${s.id}:`, e.message); } }
+ for (const id of [...state.keys()]) if (!streams.find(s => s.id === id)) state.delete(id);
+ try { beef().tick(); } catch (e) { console.warn('[Arena] beef tick:', e.message); }
+ } finally { _busy = false; }
+ return events;
+}
+
+function consoleState(userId) {
+ for (const [streamId, st] of state) if (st.userId === userId) {
+ return { stream_id: streamId, listening: true, mention_buffers: Object.keys(st.mention).length, pending_topic_words: st.topic.lines.reduce((n, l) => n + words(l.t), 0), last_topic_judgement: st.lastTopicJudgement || null, last_beef_judgement: st.lastBeefJudgement || null, last_judge_at: st.lastJudgeAt ? new Date(st.lastJudgeAt).toISOString() : null };
+ }
+ return { listening: false };
+}
+
+function start() {
+ if (_timer) return;
+ _timer = setInterval(() => tick().catch(e => console.warn('[Arena] listener:', e.message)), TICK_MS);
+ if (_timer.unref) _timer.unref();
+ console.log('[Arena] listener started (every 15 s)');
+}
+function stop() { if (_timer) { clearInterval(_timer); _timer = null; } }
+
+module.exports = { start, stop, tick, consoleState, TICK_MS, JUDGE_MIN_WORDS, JUDGE_MIN_INTERVAL_MS, _mentionsIn: mentionsIn, _aliases: aliases, _heuristicBeef: heuristicBeef, _heuristicTopic: heuristicTopic, _state: state };
@@ -1,218 +1,163 @@
@@ -1,218 +1,163 @@
/**
/**
* OpenVibe.Live — Arena API (mounted at /api/arena)
* 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)
+ * GET /status · /fighters · /fighters/:user · /fighters/:user/stat/:stat · /live
+ * POST /fighters/:user/refresh admin: regenerate persona (+ portrait)
+ * GET /console/:user what the listener hears for a live fighter
+ * GET /board pulse + open events (topics/debates/phrases/bounties) + leaderboards
+ * POST /board/topics {text} anyone signed in starts a topic
+ * GET /board/topics/:id · POST …/join · …/leave · …/side {side} · …/hype {user_id}
+ * POST /board/bounty {username} put a bounty on a fighter
+ * GET /beefs · /beefs/:id · POST /beefs/:id/hype {side} · POST /beefs/:id/side {side}
+ * GET /levels · /clout Trash Level ladder · viewers with the best picks
*/
*/
'use strict';
'use strict';
const express = require('express');
const express = require('express');
const { requireAuth, optionalAuth } = require('../auth/auth');
const { requireAuth, optionalAuth } = require('../auth/auth');
const permissions = require('../auth/permissions');
const permissions = require('../auth/permissions');
+const db = require('../db/database');
const arena = require('./arena-service');
const arena = require('./arena-service');
+const board = require('./board');
+const beef = require('./beef');
+const listener = require('./listener');
const router = express.Router();
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.use((req, res, next) => { if (!arena.arenaEnabled()) return res.status(404).json({ error: 'Arena is disabled' }); next(); });
+const fail = (res, err, msg) => { console.error('[Arena]', msg, err.message); res.status(500).json({ error: msg }); };
+const userFrom = (param) => (/^\d+$/.test(String(param)) ? db.getUserById(Number(param)) : db.getUserByUsername(String(param).replace(/^@/, '')));
-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('/status', (req, res) => { try { res.json(arena.status()); } catch (err) { fail(res, err, 'Arena unavailable'); } });
router.get('/fighters', (req, res) => {
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' });
- }
-});
-
-// ── Trash Talk (server/arena/trash-talk.js) ──
-const talk = require('./trash-talk');
-
-router.get('/talk', optionalAuth, async (req, res) => {
- try {
- res.set('Cache-Control', 'no-store');
- res.json(await talk.board({ userId: req.user?.id || null, generate: req.query.generate !== '0' }));
- } catch (err) {
- console.error('[Arena] talk board:', err.message);
- res.status(500).json({ error: 'Failed to load Trash Talk' });
- }
-});
-
-router.post('/talk/mic/start', requireAuth, (req, res) => {
- try { res.json(talk.startMic(req.user.id)); }
- catch (err) { res.status(400).json({ error: err.message }); }
-});
-
-router.get('/talk/mic/feed', requireAuth, (req, res) => {
- try { res.set('Cache-Control', 'no-store'); res.json(talk.micFeed(req.user.id)); }
- catch (err) { res.status(400).json({ error: err.message }); }
-});
-
-router.post('/talk/submit', requireAuth, async (req, res) => {
- try {
- const mode = req.body?.mode === 'mic' ? 'mic' : 'text';
- res.json({ ok: true, entry: await talk.submit(req.user.id, { mode, text: req.body?.text }) });
- } catch (err) {
- res.status(400).json({ error: err.message });
- }
-});
-
-// ── Live trash-talk sessions (server/arena/talk-session.js) ──
-const sessions = require('./talk-session');
-
-router.get('/talk/sessions', (req, res) => {
- try { res.set('Cache-Control', 'no-store'); res.json({ sessions: sessions.liveSessionSummaries() }); }
- catch (err) { res.status(500).json({ error: err.message }); }
-});
-router.get('/talk/session/:user', (req, res) => {
- try {
- const v = sessions.viewFor(req.params.user);
- if (!v) return res.status(404).json({ error: 'No such user' });
- res.set('Cache-Control', 'no-store');
- res.json(v);
- } catch (err) { res.status(500).json({ error: err.message }); }
-});
-router.post('/talk/session/start', requireAuth, async (req, res) => {
- try { const s = await sessions.startSession(req.user.id); res.json({ ok: true, session: sessions.sessionView(s) }); }
- catch (err) { res.status(400).json({ error: err.message }); }
-});
-router.post('/talk/session/stop', requireAuth, (req, res) => {
- try { const s = sessions.stopSession(req.user.id); res.json({ ok: true, session: sessions.sessionView(s) }); }
- catch (err) { res.status(400).json({ error: err.message }); }
-});
-router.post('/talk/session/skip', requireAuth, async (req, res) => {
- try { const t = await sessions.skipTopic(req.user.id); res.json({ ok: true, topic: t ? { topic: t.topic, hint: t.hint, idx: t.idx } : null }); }
- catch (err) { res.status(400).json({ error: err.message }); }
-});
-router.post('/talk/session/:user/hype', optionalAuth, (req, res) => {
- try {
- const db = require('../db/database');
- const user = /^\d+$/.test(req.params.user) ? db.getUserById(Number(req.params.user)) : db.getUserByUsername(req.params.user);
- if (!user) return res.status(404).json({ error: 'No such user' });
- const r = sessions.hypeSession(user.id, arena.voterKeyFor(req));
- if (!r) return res.status(404).json({ error: 'No live session' });
- res.json(r);
- } catch (err) { res.status(400).json({ error: err.message }); }
-});
-
-router.post('/talk/:id/hype', optionalAuth, (req, res) => {
- try { res.json(talk.hype(Number(req.params.id), arena.voterKeyFor(req))); }
- catch (err) { res.status(400).json({ error: err.message }); }
-});
-
-router.get('/main-event', optionalAuth, async (req, res) => {
- try {
- const battle = await arena.getMainEvent({ generate: req.query.generate !== '0' });
- if (!battle) return res.status(404).json({ error: 'No main event yet — need at least two fighters' });
- let yourVote = null;
- try {
- const row = require('../db/database').get('SELECT side FROM arena_votes WHERE battle_id = ? AND voter_key = ?', [battle.id, arena.voterKeyFor(req)]);
- yourVote = row ? row.side : null;
- } catch { /* */ }
- res.set('Cache-Control', 'no-store');
- res.json({ ...battle, your_vote: yourVote });
- } catch (err) {
- console.error('[Arena] main event:', err.message);
- res.status(500).json({ error: 'Failed to load the main event' });
- }
+ try { res.set('Cache-Control', 'public, max-age=30'); res.json({ fighters: arena.listFighters(), ai: arena.aiOn(), image_generation: arena.imageGenAvailable() }); }
+ catch (err) { fail(res, err, 'Failed to load the roster'); }
});
});
+router.get('/live', (req, res) => { try { res.set('Cache-Control', 'no-store'); res.json({ live: arena.liveFighters() }); } catch (err) { fail(res, err, 'Failed to load live fighters'); } });
router.get('/fighters/:user/stat/:stat', (req, res) => {
router.get('/fighters/:user/stat/:stat', (req, res) => {
try {
try {
- const card = arena.loadRoster();
- const db = require('../db/database');
- const user = /^\d+$/.test(req.params.user) ? db.getUserById(Number(req.params.user)) : db.getUserByUsername(req.params.user);
- if (!user || !card.byId[user.id]) return res.status(404).json({ error: 'No such fighter on the roster' });
+ const user = userFrom(req.params.user);
+ if (!user || !arena.loadRoster().byId[user.id]) return res.status(404).json({ error: 'No such fighter on the roster' });
const detail = arena.getStatDetail(user.id, String(req.params.stat));
const detail = arena.getStatDetail(user.id, String(req.params.stat));
if (!detail) return res.status(404).json({ error: 'Unknown stat' });
if (!detail) return res.status(404).json({ error: 'Unknown stat' });
res.set('Cache-Control', 'public, max-age=120');
res.set('Cache-Control', 'public, max-age=120');
res.json(detail);
res.json(detail);
- } catch (err) {
- console.error('[Arena] stat detail:', err.message);
- res.status(500).json({ error: 'Failed to load stat detail' });
- }
+ } catch (err) { fail(res, err, 'Failed to load stat detail'); }
});
});
-
router.get('/fighters/:user', async (req, res) => {
router.get('/fighters/:user', async (req, res) => {
try {
try {
- const generate = req.query.generate !== '0';
- const card = await arena.getFighter(req.params.user, { generate });
+ const card = await arena.getFighter(req.params.user, { generate: req.query.generate !== '0' });
if (!card) return res.status(404).json({ error: 'No such fighter' });
if (!card) return res.status(404).json({ error: 'No such fighter' });
+ if (!card.not_on_roster) { try { card.rivalries = beef.rivalriesFor(card.user.id); } catch { card.rivalries = []; } }
res.set('Cache-Control', 'no-store');
res.set('Cache-Control', 'no-store');
res.json(card);
res.json(card);
- } catch (err) {
- console.error('[Arena] fighter:', err.message);
- res.status(500).json({ error: 'Failed to load fighter' });
- }
+ } catch (err) { fail(res, err, 'Failed to load fighter'); }
});
});
-
router.post('/fighters/:user/refresh', requireAuth, permissions.requireAdmin, async (req, res) => {
router.post('/fighters/:user/refresh', requireAuth, permissions.requireAdmin, async (req, res) => {
try {
try {
const card = await arena.getFighter(req.params.user, { generate: false });
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' });
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 });
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 });
+ const image = req.body?.image !== false && arena.imageGenAvailable() ? await arena.generateImage(card.user.id, { force: true }) : null;
res.json({ ok: true, persona, image_url: image });
res.json({ ok: true, persona, image_url: image });
- } catch (err) {
- console.error('[Arena] refresh:', err.message);
- res.status(500).json({ error: 'Refresh failed' });
- }
+ } catch (err) { fail(res, err, 'Refresh failed'); }
});
});
-router.get('/battle/:a/:b', optionalAuth, async (req, res) => {
+// ── Live console: what the ears hear for one fighter ──
+router.get('/console/:user', (req, res) => {
+ try {
+ const user = userFrom(req.params.user);
+ if (!user) return res.status(404).json({ error: 'No such user' });
+ const roster = arena.loadRoster();
+ const brief = board.fighterBrief(user.id, roster);
+ const liveStream = db.get('SELECT id, title, started_at, viewer_count FROM streams WHERE user_id = ? AND is_live = 1 ORDER BY started_at DESC LIMIT 1', [user.id]);
+ const active = board.activeTopicFor(user.id);
+ let lines = [];
+ if (liveStream) {
+ lines = db.all(`SELECT text, start_sec, vod_id FROM stream_timeline_events WHERE stream_id = ? AND kind = 'speech' ORDER BY start_sec DESC LIMIT 14`, [liveStream.id]).reverse()
+ .filter(l => !arena._isBannedText(l.text)).map(l => ({ text: l.text, sec: Math.floor(l.start_sec), vod_id: l.vod_id }));
+ }
+ res.set('Cache-Control', 'no-store');
+ res.json({
+ fighter: brief, on_roster: !!roster.byId[user.id], live: !!liveStream, stream: liveStream || null,
+ transcribed: liveStream ? !!db.get(`SELECT 1 FROM stream_timeline_events WHERE stream_id = ? AND kind = 'speech' AND created_at >= datetime('now', '-30 minutes') LIMIT 1`, [liveStream.id]) : false,
+ listener: listener.consoleState(user.id),
+ level: board.levelView(user.id),
+ active_topic: active ? { ...board.topicDetail(active.id), my_progress: board.progressFor(active.id, user.id) } : null,
+ open_beefs: beef.openBeefsFor(user.id).map(b => beef.beefView(b, roster)),
+ hot_mic: lines,
+ bounty_on_me: board.openBountyOn(user.id) ? board.topicDetail(board.openBountyOn(user.id).id) : null,
+ });
+ } catch (err) { fail(res, err, 'Failed to load console'); }
+});
+
+// ── Board ──
+router.get('/board', (req, res) => {
try {
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 { /* */ }
+ const v = board.boardView();
res.set('Cache-Control', 'no-store');
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.' });
- }
+ res.json({ ...v, levels: board.levelsLeaderboard(8), clout: board.cloutLeaderboard(8), ai: arena.aiOn() });
+ } catch (err) { fail(res, err, 'Failed to load the board'); }
});
});
-
-router.post('/battle/:a/:b/vote', optionalAuth, async (req, res) => {
+router.post('/board/topics', requireAuth, async (req, res) => {
+ try {
+ const onRoster = !!arena.loadRoster().byId[req.user.id];
+ const t = board.createTopic({ text: req.body?.text, hint: req.body?.hint || null, createdBy: onRoster ? 'streamer' : 'viewer', creatorUserId: req.user.id, creatorName: req.user.display_name || req.user.username });
+ board.ensureAngles(t).catch(() => {});
+ res.json({ ok: true, topic: board.topicDetail(t.id) });
+ } catch (err) { res.status(400).json({ error: err.message }); }
+});
+router.post('/board/bounty', requireAuth, (req, res) => {
try {
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 });
- }
+ const target = userFrom(String(req.body?.username || ''));
+ if (!target || !arena.loadRoster().byId[target.id]) return res.status(404).json({ error: 'No such fighter on the roster' });
+ if (target.id === req.user.id) return res.status(400).json({ error: "You can't put a bounty on yourself" });
+ const name = board.fighterBrief(target.id, arena.loadRoster()).fighter_name;
+ const t = board.createTopic({ text: `Bounty: ${name}. Anyone who talks shit about them on stream gets double XP.`, hint: `Put up by ${req.user.display_name || req.user.username}. Say the name, collect the bag.`, createdBy: 'viewer', creatorUserId: req.user.id, creatorName: req.user.display_name || req.user.username, kind: 'bounty', targetUserId: target.id, headline: `WANTED: ${name} — chat wants smoke` });
+ res.json({ ok: true, topic: board.topicDetail(t.id) });
+ } catch (err) { res.status(400).json({ error: err.message }); }
+});
+router.get('/board/topics/:id', (req, res) => {
+ try {
+ const t = board.topicDetail(Number(req.params.id));
+ if (!t) return res.status(404).json({ error: 'No such topic' });
+ res.set('Cache-Control', 'no-store');
+ res.json(t);
+ } catch (err) { fail(res, err, 'Failed to load topic'); }
+});
+router.post('/board/topics/:id/join', requireAuth, async (req, res) => {
+ try { const r = await board.joinTopic(Number(req.params.id), req.user.id); res.json({ ok: true, topic: board.topicDetail(r.topic.id) }); }
+ catch (err) { res.status(400).json({ error: err.message }); }
+});
+router.post('/board/topics/:id/leave', requireAuth, (req, res) => { try { board.leaveTopic(req.user.id); res.json({ ok: true }); } catch (err) { res.status(400).json({ error: err.message }); } });
+router.post('/board/topics/:id/side', optionalAuth, (req, res) => {
+ try { res.json(board.pickSide(Number(req.params.id), arena.voterKeyFor(req), String(req.body?.side || ''), req.user?.id || null)); }
+ catch (err) { res.status(400).json({ error: err.message }); }
});
});
+router.post('/board/topics/:id/hype', optionalAuth, (req, res) => {
+ try { res.json(board.hypeTopic(Number(req.params.id), Number(req.body?.user_id), arena.voterKeyFor(req))); }
+ catch (err) { res.status(400).json({ error: err.message }); }
+});
+router.post('/pulse/refresh', requireAuth, permissions.requireAdmin, async (req, res) => {
+ try { res.json(await board.refreshPulse({ force: true })); } catch (err) { fail(res, err, 'Pulse refresh failed'); }
+});
+
+// ── Beefs ──
+router.get('/beefs', (req, res) => { try { res.set('Cache-Control', 'no-store'); res.json(beef.list()); } catch (err) { fail(res, err, 'Failed to load beefs'); } });
+router.get('/beefs/:id', (req, res) => {
+ try { const b = beef.get(Number(req.params.id)); if (!b) return res.status(404).json({ error: 'No such beef' }); res.set('Cache-Control', 'no-store'); res.json(b); }
+ catch (err) { fail(res, err, 'Failed to load beef'); }
+});
+router.post('/beefs/:id/hype', optionalAuth, (req, res) => {
+ try { res.json(beef.hype(Number(req.params.id), String(req.body?.side || ''), arena.voterKeyFor(req))); } catch (err) { res.status(400).json({ error: err.message }); }
+});
+router.post('/beefs/:id/side', optionalAuth, (req, res) => {
+ try { res.json(beef.pickSide(Number(req.params.id), arena.voterKeyFor(req), String(req.body?.side || ''), req.user?.id || null)); } catch (err) { res.status(400).json({ error: err.message }); }
+});
+
+router.get('/levels', (req, res) => { try { res.json({ levels: board.levelsLeaderboard(20) }); } catch (err) { fail(res, err, 'Failed'); } });
+router.get('/clout', (req, res) => { try { res.json({ clout: board.cloutLeaderboard(20) }); } catch (err) { fail(res, err, 'Failed'); } });
module.exports = router;
module.exports = router;
deleted file mode 100644
deleted file mode 100644
@@ -1,434 +0,0 @@
@@ -1,434 +0,0 @@
-/**
- * OpenVibe.Live — Arena Trash Talk SESSIONS (live, continuous, transcription-driven)
- *
- * A streamer who is live with the timeline transcription running starts a session. From
- * then on, every TICK_MS the session reads the new speech lines off the transcript,
- * buffers them against the CURRENT TOPIC, and — once enough new words have arrived —
- * asks the judge whether that chunk was trash talk on the topic and how good it was.
- *
- * progress → each judged chunk adds `progress_gain` (0–60) to the topic; at 100 the
- * topic is CLEARED: it becomes a regular Trash Talk entry (so it counts for
- * the POWER bonus and the Hall of Trash) and the NEXT topic is generated,
- * shaped by what they have already cleared. Talk enough → the topic changes.
- * xp/level → every judged chunk adds XP (quality × trash-talkiness); levels every
- * XP_PER_LEVEL. Viewers add XP + progress with `!hype` (one per person per
- * topic).
- * about → each chunk also yields a ≤ 10-word "what they talked about" tag, so the
- * page shows what kind of trash was talked during the session.
- *
- * Everything is visible live at /arena/talk/<username> (public) and polled every few
- * seconds; the streamer sees the same console with start / skip / stop controls.
- * Sessions end when the stream ends, after IDLE_END_MIN minutes without speech, or at
- * MAX_SESSION_HOURS. AI use is bounded: one judge call per ≥ JUDGE_MIN_INTERVAL_MS and
- * ≥ JUDGE_MIN_WORDS new words, a few hundred per session at most.
- */
-'use strict';
-
-const db = require('../db/database');
-const llm = require('../ai/llm');
-
-const TICK_MS = 15 * 1000;
-const JUDGE_MIN_INTERVAL_MS = 30 * 1000;
-const JUDGE_MIN_WORDS = 25;
-const JUDGE_MAX_CALLS = 300;
-const IDLE_END_MIN = 20;
-const MAX_SESSION_HOURS = 3;
-const XP_PER_LEVEL = 40;
-const HYPE_XP = 2;
-const HYPE_PROGRESS = 4;
-const CHUNK_MAX_CHARS = 1400;
-
-let _ready = false;
-function ensureTables() {
- if (_ready) return;
- db.run(`CREATE TABLE IF NOT EXISTS arena_talk_sessions (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- user_id INTEGER NOT NULL,
- stream_id INTEGER NOT NULL,
- status TEXT NOT NULL DEFAULT 'live',
- level INTEGER DEFAULT 1,
- xp INTEGER DEFAULT 0,
- topics_cleared INTEGER DEFAULT 0,
- lines_seen INTEGER DEFAULT 0,
- words_seen INTEGER DEFAULT 0,
- judge_calls INTEGER DEFAULT 0,
- last_offset_sec REAL DEFAULT 0,
- offset_start_sec REAL DEFAULT 0,
- last_judge_at DATETIME,
- last_speech_at DATETIME,
- about_json TEXT,
- started_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- ended_at DATETIME,
- end_reason TEXT
- )`);
- db.run(`CREATE TABLE IF NOT EXISTS arena_talk_session_topics (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- session_id INTEGER NOT NULL,
- idx INTEGER NOT NULL,
- topic TEXT NOT NULL,
- hint TEXT,
- tone TEXT,
- status TEXT NOT NULL DEFAULT 'active',
- progress REAL DEFAULT 0,
- score REAL DEFAULT 0,
- chunks INTEGER DEFAULT 0,
- best_line TEXT,
- best_line_score REAL DEFAULT 0,
- best_line_sec INTEGER,
- best_vod_id INTEGER,
- pending_json TEXT,
- judged_json TEXT,
- entry_id INTEGER,
- started_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- cleared_at DATETIME
- )`);
- db.run(`CREATE TABLE IF NOT EXISTS arena_talk_session_hype (
- topic_id INTEGER NOT NULL,
- voter_key TEXT NOT NULL,
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- UNIQUE(topic_id, voter_key)
- )`);
- db.run('CREATE INDEX IF NOT EXISTS idx_arena_talk_sessions_live ON arena_talk_sessions (status, user_id)');
- _ready = true;
-}
-
-function aiOn() { try { return llm.isEnabled() && llm.withinBudget(); } catch { return false; } }
-function parseJson(t, f = null) { try { return t ? JSON.parse(t) : f; } catch { return f; } }
-function arena() { return require('./arena-service'); }
-function talk() { return require('./trash-talk'); }
-function levelFor(xp) { return 1 + Math.floor((Number(xp) || 0) / XP_PER_LEVEL); }
-function words(t) { return String(t || '').split(/\s+/).filter(Boolean).length; }
-
-// ── Lookups ──────────────────────────────────────────────────
-
-function liveSessionFor(userId) {
- ensureTables();
- return db.get(`SELECT * FROM arena_talk_sessions WHERE user_id = ? AND status = 'live' ORDER BY id DESC LIMIT 1`, [userId]) || null;
-}
-function activeTopic(sessionId) {
- return db.get(`SELECT * FROM arena_talk_session_topics WHERE session_id = ? AND status = 'active' ORDER BY idx DESC LIMIT 1`, [sessionId]) || null;
-}
-function liveSessions() {
- ensureTables();
- return db.all(`SELECT * FROM arena_talk_sessions WHERE status = 'live'`);
-}
-
-// ── Topics for a session ─────────────────────────────────────
-
-const TOPIC_SCHEMA = {
- name: 'arena_session_topic',
- schema: { type: 'object', additionalProperties: false, required: ['topic', 'hint', 'tone'], properties: { topic: { type: 'string' }, hint: { type: 'string' }, tone: { type: 'string', enum: ['brag', 'roast', 'spin', 'bit'] } } },
-};
-const TOPIC_SYSTEM = `You feed topics to a live-streamer who is trash-talking on stream in real time, one topic at a time. Each topic must be answerable in 30–60 seconds of talking, funny, PG-13, and about streaming life, their chat, the Arena ladder, their category, or the day's Main Event — never appearance, identity, health or money. Vary tone and angle from the topics they already cleared. Output only the JSON.`;
-
-async function nextTopic(session, idx) {
- const cleared = db.all('SELECT topic FROM arena_talk_session_topics WHERE session_id = ? ORDER BY idx ASC', [session.id]).map(r => r.topic);
- let t = null;
- if (aiOn()) {
- try {
- const roster = arena().loadRoster();
- const me = roster.byId[session.user_id];
- const persona = parseJson(db.get('SELECT persona_json FROM arena_profiles WHERE user_id = ?', [session.user_id])?.persona_json);
- const rivals = roster.order.filter(id => id !== session.user_id).slice(0, 4).map(id => (parseJson(db.get('SELECT persona_json FROM arena_profiles WHERE user_id = ?', [id])?.persona_json) || {}).fighter_name || roster.byId[id].user.display_name);
- const r = await llm.complete({
- role: 'summary', kind: 'arena_session_topic', source: 'arena', ownerUserId: session.user_id,
- system: TOPIC_SYSTEM,
- user: JSON.stringify({ fighter: persona?.fighter_name || me?.user?.display_name, category: me?.raw?.category, rank: roster.order.indexOf(session.user_id) + 1, rivals, already_cleared: cleared, topic_number: idx + 1 }),
- json: TOPIC_SCHEMA, maxTokens: 200, temperature: 1.05, timeoutMs: 20000,
- });
- if (r && r.json && r.json.topic) t = r.json;
- } catch (e) { console.warn('[Arena] session topic:', e.message); }
- }
- if (!t) {
- const pool = talk().FALLBACK_TOPICS || [];
- const seed = (session.id * 7 + idx * 13) % Math.max(1, pool.length);
- t = pool[seed] || { topic: 'Talk your talk. Anything goes, PG-13.', hint: 'Chat is listening.', tone: 'bit' };
- }
- db.run('INSERT INTO arena_talk_session_topics (session_id, idx, topic, hint, tone) VALUES (?, ?, ?, ?, ?)', [session.id, idx, String(t.topic).slice(0, 200), String(t.hint || '').slice(0, 140), t.tone || 'bit']);
- return activeTopic(session.id);
-}
-
-// ── Lifecycle ────────────────────────────────────────────────
-
-async function startSession(userId) {
- ensureTables();
- const existing = liveSessionFor(userId);
- if (existing) return existing;
- const roster = arena().loadRoster();
- if (!roster.byId[userId]) throw new Error('Only fighters on the roster can run a session — stream once and come back');
- const mic = talk().micAvailable(userId);
- if (!mic.available) throw new Error(mic.reason === 'not_live' ? 'Go live first — sessions run off your live transcription' : 'You are live, but no transcription has come through yet. Talk for a few seconds and try again.');
- const stream = db.get('SELECT id, started_at FROM streams WHERE id = ?', [mic.stream_id]);
- const startedMs = stream?.started_at ? Date.parse(String(stream.started_at).replace(' ', 'T') + 'Z') : Date.now();
- const offset = Math.max(0, (Date.now() - startedMs) / 1000);
- db.run(`INSERT INTO arena_talk_sessions (user_id, stream_id, last_offset_sec, offset_start_sec, last_speech_at, about_json) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, '[]')`, [userId, mic.stream_id, offset, offset]);
- const session = liveSessionFor(userId);
- await nextTopic(session, 0);
- console.log(`[Arena] trash-talk session ${session.id} started for user ${userId} on stream ${mic.stream_id}`);
- return session;
-}
-
-function endSession(session, reason = 'stopped') {
- db.run(`UPDATE arena_talk_sessions SET status = 'ended', ended_at = CURRENT_TIMESTAMP, end_reason = ? WHERE id = ? AND status = 'live'`, [reason, session.id]);
- db.run(`UPDATE arena_talk_session_topics SET status = 'skipped' WHERE session_id = ? AND status = 'active'`, [session.id]);
- console.log(`[Arena] trash-talk session ${session.id} ended (${reason})`);
-}
-
-function stopSession(userId) {
- const s = liveSessionFor(userId);
- if (!s) throw new Error('No live session');
- endSession(s, 'stopped');
- return db.get('SELECT * FROM arena_talk_sessions WHERE id = ?', [s.id]);
-}
-
-async function skipTopic(userId) {
- const s = liveSessionFor(userId);
- if (!s) throw new Error('No live session');
- const t = activeTopic(s.id);
- if (t) db.run(`UPDATE arena_talk_session_topics SET status = 'skipped' WHERE id = ?`, [t.id]);
- return nextTopic(s, (t ? t.idx : -1) + 1);
-}
-
-// ── Judge (chunk mode) ───────────────────────────────────────
-
-const CHUNK_SCHEMA = {
- name: 'arena_session_chunk',
- schema: {
- type: 'object', additionalProperties: false,
- required: ['is_trash_talk', 'spice', 'wit', 'on_topic', 'delivery', 'progress_gain', 'best_line', 'about', 'flagged'],
- properties: {
- is_trash_talk: { type: 'boolean', description: 'true if a meaningful part of this chunk is trash talk / bragging / roasting aimed at the topic' },
- spice: { type: 'integer', minimum: 0, maximum: 10 },
- wit: { type: 'integer', minimum: 0, maximum: 10 },
- on_topic: { type: 'integer', minimum: 0, maximum: 10 },
- delivery: { type: 'integer', minimum: 0, maximum: 10 },
- progress_gain: { type: 'integer', minimum: 0, maximum: 60, description: 'how much of the topic this chunk covered: 0 = not about it, 60 = nailed it completely' },
- best_line: { type: 'string', description: 'the single best line, copied VERBATIM from the chunk (empty if none)' },
- about: { type: 'string', description: '≤ 10 words: what they were actually talking about in this chunk' },
- flagged: { type: 'boolean', description: 'slurs / hate / threats / attacks on appearance, identity, health or money' },
- },
- },
-};
-const CHUNK_SYSTEM = `You judge a LIVE trash-talk session. You get the current topic and the newest ~60 seconds of a streamer's speech-to-text. Decide whether they were trash-talking (bragging, roasting, hyping themselves, calling people out — in good fun) ON that topic, score it, say how much of the topic they covered, pick the best verbatim line, and summarise what they talked about in ≤ 10 words. Ordinary gameplay chatter, silence or off-topic talk → is_trash_talk false, low progress. Forgive transcription noise. Flag cruelty (appearance, identity, health, money, threats, hate). Output only the JSON.`;
-
-function heuristicChunk(text, topic) {
- const h = talk()._heuristicJudge(text, topic.topic);
- const trash = h.spice >= 5 || /\b(chat|champ|bet|weak|scared|better|best|nobody|clown|cook|ratio)\b/i.test(text);
- const lines = String(text).split(/(?<=[.!?])\s+/).filter(l => l.length > 12);
- const best = lines.sort((a, b) => (b.match(/[!?]/g) || []).length - (a.match(/[!?]/g) || []).length)[0] || '';
- return { is_trash_talk: trash, spice: h.spice, wit: h.wit, on_topic: h.on_topic, delivery: h.delivery, progress_gain: trash ? Math.min(60, 10 + h.on_topic * 4) : Math.min(15, h.on_topic * 2), best_line: best.slice(0, 200), about: text.split(/\s+/).slice(0, 8).join(' '), flagged: false, _fallback: true };
-}
-
-async function judgeChunk(session, topic, text) {
- if (arena()._isBannedText(text)) return { is_trash_talk: false, spice: 0, wit: 0, on_topic: 0, delivery: 0, progress_gain: 0, best_line: '', about: 'crossed the line — voided', flagged: true };
- let s = null;
- if (aiOn()) {
- try {
- const r = await llm.complete({
- role: 'chat', kind: 'arena_session_judge', source: 'arena', ownerUserId: session.user_id,
- system: CHUNK_SYSTEM,
- user: JSON.stringify({ topic: topic.topic, hint: topic.hint, tone: topic.tone, progress_so_far: Math.round(topic.progress), speech: text }),
- json: CHUNK_SCHEMA, maxTokens: 260, temperature: 0.5, timeoutMs: 25000,
- });
- if (r && r.json && typeof r.json.spice === 'number') s = r.json;
- } catch (e) { console.warn('[Arena] session judge:', e.message); }
- }
- if (!s) s = heuristicChunk(text, topic);
- const c = (v, max = 10) => Math.max(0, Math.min(max, Math.round(Number(v) || 0)));
- return { is_trash_talk: !!s.is_trash_talk && !s.flagged, spice: c(s.spice), wit: c(s.wit), on_topic: c(s.on_topic), delivery: c(s.delivery), progress_gain: s.flagged ? 0 : c(s.progress_gain, 60), best_line: String(s.best_line || '').slice(0, 220), about: String(s.about || '').slice(0, 80), flagged: !!s.flagged, fallback: !!s._fallback };
-}
-
-// ── Tick ─────────────────────────────────────────────────────
-
-async function clearTopic(session, topic) {
- const judged = parseJson(topic.judged_json, []);
- const hits = judged.filter(j => j.is_trash_talk);
- const avg = (k) => hits.length ? hits.reduce((a, j) => a + j[k], 0) / hits.length : 0;
- const scores = { spice: Math.round(avg('spice')), wit: Math.round(avg('wit')), on_topic: Math.round(avg('on_topic')), delivery: Math.round(avg('delivery')), verdict: `Cleared live on stream — ${hits.length} chunk${hits.length === 1 ? '' : 's'} of trash talk`, note: `Best line: “${topic.best_line || '—'}”`, flagged: false };
- const text = (hits.map(j => j.best_line).filter(Boolean).join(' … ') || topic.best_line || '').slice(0, 400) || 'Cleared on the live mic.';
- let entryId = null;
- try {
- const t = talk();
- const topicRow = await t.getTopic({ generate: false });
- // Session clears live outside the 6-hour slot topic, so they are stored against their
- // own topic text (in the note) but under the current slot id for the board.
- const total = t._totalFor(scores, 0);
- db.run(`INSERT INTO arena_talk (topic_id, user_id, stream_id, source, text, vod_id, start_sec, scores_json, total, flagged, verdict, note, stamp, judge_model, judged_at)
- VALUES (?, ?, ?, 'session', ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, CURRENT_TIMESTAMP)
- ON CONFLICT(topic_id, user_id) DO UPDATE SET text = CASE WHEN excluded.total > arena_talk.total THEN excluded.text ELSE arena_talk.text END,
- scores_json = CASE WHEN excluded.total > arena_talk.total THEN excluded.scores_json ELSE arena_talk.scores_json END,
- total = MAX(arena_talk.total, excluded.total), stamp = CASE WHEN excluded.total > arena_talk.total THEN excluded.stamp ELSE arena_talk.stamp END,
- verdict = excluded.verdict, note = excluded.note, source = 'session', vod_id = COALESCE(excluded.vod_id, arena_talk.vod_id), start_sec = COALESCE(excluded.start_sec, arena_talk.start_sec)`,
- [topicRow.id, session.user_id, session.stream_id, text, topic.best_vod_id, topic.best_line_sec, JSON.stringify(scores), total, scores.verdict, `${topic.topic} — ${scores.note}`, t._stampFor(total), hits[0]?.fallback ? null : 'session']);
- entryId = db.get('SELECT id FROM arena_talk WHERE topic_id = ? AND user_id = ?', [topicRow.id, session.user_id])?.id || null;
- } catch (e) { console.warn('[Arena] session clear → entry:', e.message); }
- db.run(`UPDATE arena_talk_session_topics SET status = 'cleared', progress = 100, cleared_at = CURRENT_TIMESTAMP, entry_id = ? WHERE id = ?`, [entryId, topic.id]);
- db.run(`UPDATE arena_talk_sessions SET topics_cleared = topics_cleared + 1 WHERE id = ?`, [session.id]);
- console.log(`[Arena] session ${session.id}: topic ${topic.idx + 1} cleared (“${topic.topic.slice(0, 50)}…”)`);
- try { arena().loadRoster(true); } catch { /* */ }
- return nextTopic(session, topic.idx + 1);
-}
-
-async function tickSession(session) {
- const stream = db.get('SELECT id, is_live, started_at FROM streams WHERE id = ?', [session.stream_id]);
- if (!stream || !stream.is_live) return endSession(session, 'stream_ended');
- if (Date.now() - Date.parse(session.started_at + 'Z') > MAX_SESSION_HOURS * 3600 * 1000) return endSession(session, 'max_duration');
- const lastSpeech = session.last_speech_at ? Date.parse(session.last_speech_at + 'Z') : Date.parse(session.started_at + 'Z');
- if (Date.now() - lastSpeech > IDLE_END_MIN * 60 * 1000) return endSession(session, 'idle');
- if (session.judge_calls >= JUDGE_MAX_CALLS) return endSession(session, 'judge_cap');
-
- let topic = activeTopic(session.id);
- if (!topic) topic = await nextTopic(session, 0);
-
- const rows = db.all(`SELECT text, start_sec, end_sec, vod_id FROM stream_timeline_events WHERE stream_id = ? AND kind = 'speech' AND start_sec > ? ORDER BY start_sec ASC LIMIT 120`, [session.stream_id, session.last_offset_sec]);
- if (rows.length) {
- const pending = parseJson(topic.pending_json, []);
- for (const r of rows) pending.push({ t: r.text, s: Math.floor(r.start_sec), v: r.vod_id || null });
- const newWords = rows.reduce((n, r) => n + words(r.text), 0);
- db.run(`UPDATE arena_talk_sessions SET last_offset_sec = ?, lines_seen = lines_seen + ?, words_seen = words_seen + ?, last_speech_at = CURRENT_TIMESTAMP WHERE id = ?`, [rows[rows.length - 1].start_sec, rows.length, newWords, session.id]);
- db.run('UPDATE arena_talk_session_topics SET pending_json = ? WHERE id = ?', [JSON.stringify(pending.slice(-60)), topic.id]);
- topic.pending_json = JSON.stringify(pending.slice(-60));
- }
-
- const pending = parseJson(topic.pending_json, []);
- const pendingWords = pending.reduce((n, l) => n + words(l.t), 0);
- const sinceJudge = session.last_judge_at ? Date.now() - Date.parse(session.last_judge_at + 'Z') : Infinity;
- if (pendingWords < JUDGE_MIN_WORDS || sinceJudge < JUDGE_MIN_INTERVAL_MS) return;
-
- const text = pending.map(l => l.t.replace(/^\s*(?:>>|--?)\s*/, '').trim()).filter(Boolean).join(' ').replace(/\s+/g, ' ').slice(-CHUNK_MAX_CHARS);
- const j = await judgeChunk(session, topic, text);
- const quality = (j.spice + j.wit + j.on_topic + j.delivery) / 4;
- const xpGain = j.flagged ? 0 : Math.round(quality * (j.is_trash_talk ? 1 : 0.15));
- const judged = parseJson(topic.judged_json, []);
- judged.push({ at: new Date().toISOString(), ...j, words: pendingWords, quality: Number(quality.toFixed(1)), xp: xpGain });
- // Locate the best line in the pending buffer for a VOD link.
- let bestSec = topic.best_line_sec, bestVod = topic.best_vod_id, bestLine = topic.best_line, bestScore = topic.best_line_score || 0;
- if (j.is_trash_talk && j.best_line && quality > bestScore) {
- const needle = j.best_line.toLowerCase().slice(0, 30);
- const hit = pending.find(l => l.t.toLowerCase().includes(needle)) || pending[0];
- bestLine = j.best_line; bestScore = quality; bestSec = hit ? Math.max(0, hit.s - 2) : null; bestVod = hit ? hit.v : null;
- }
- const about = parseJson(session.about_json, []);
- if (j.about && !j.flagged) { about.push({ text: j.about, topic_idx: topic.idx, hit: j.is_trash_talk, at: new Date().toISOString() }); }
- const newProgress = Math.min(100, (topic.progress || 0) + (j.is_trash_talk ? j.progress_gain : Math.min(5, j.progress_gain)));
- const newScore = (topic.score || 0) + (j.is_trash_talk ? quality : 0);
- const newXp = (session.xp || 0) + xpGain;
- const newLevel = levelFor(newXp);
- if (newLevel > (session.level || 1)) console.log(`[Arena] session ${session.id}: level ${newLevel}`);
- db.run(`UPDATE arena_talk_session_topics SET progress = ?, score = ?, chunks = chunks + 1, judged_json = ?, pending_json = '[]', best_line = ?, best_line_score = ?, best_line_sec = ?, best_vod_id = ? WHERE id = ?`,
- [newProgress, newScore, JSON.stringify(judged.slice(-40)), bestLine, bestScore, bestSec, bestVod, topic.id]);
- db.run(`UPDATE arena_talk_sessions SET xp = ?, level = ?, judge_calls = judge_calls + 1, last_judge_at = CURRENT_TIMESTAMP, about_json = ? WHERE id = ?`, [newXp, newLevel, JSON.stringify(about.slice(-60)), session.id]);
- if (newProgress >= 100) {
- const fresh = db.get('SELECT * FROM arena_talk_session_topics WHERE id = ?', [topic.id]);
- await clearTopic({ ...session, xp: newXp }, fresh);
- }
-}
-
-let _timer = null, _busy = false;
-async function tick() {
- if (_busy) return;
- _busy = true;
- try {
- for (const s of liveSessions()) {
- try { await tickSession(s); } catch (e) { console.warn(`[Arena] session ${s.id} tick:`, e.message); }
- }
- } finally { _busy = false; }
-}
-function start() {
- if (_timer) return;
- ensureTables();
- _timer = setInterval(() => tick().catch(() => {}), TICK_MS);
- if (_timer.unref) _timer.unref();
-}
-function stop() { if (_timer) { clearInterval(_timer); _timer = null; } }
-
-// ── Hype (chat / button) ─────────────────────────────────────
-
-function hypeSession(userId, voterKey) {
- const s = liveSessionFor(userId);
- if (!s) return null;
- const t = activeTopic(s.id);
- if (!t) return null;
- if (voterKey === `user:${userId}`) throw new Error("You can't hype yourself");
- const ins = db.run('INSERT OR IGNORE INTO arena_talk_session_hype (topic_id, voter_key) VALUES (?, ?)', [t.id, voterKey]);
- const uniques = db.get('SELECT COUNT(*) AS n FROM arena_talk_session_hype WHERE topic_id = ?', [t.id])?.n || 0;
- if (ins.changes) {
- const xp = (s.xp || 0) + HYPE_XP;
- db.run('UPDATE arena_talk_sessions SET xp = ?, level = ? WHERE id = ?', [xp, levelFor(xp), s.id]);
- db.run('UPDATE arena_talk_session_topics SET progress = MIN(100, progress + ?) WHERE id = ?', [HYPE_PROGRESS, t.id]);
- }
- return { added: !!ins.changes, hypers: uniques, session_id: s.id, level: levelFor((s.xp || 0) + (ins.changes ? HYPE_XP : 0)), xp: (s.xp || 0) + (ins.changes ? HYPE_XP : 0), progress: Math.min(100, (t.progress || 0) + (ins.changes ? HYPE_PROGRESS : 0)) };
-}
-
-// ── Views ────────────────────────────────────────────────────
-
-function formatTopic(t) {
- if (!t) return null;
- const judged = parseJson(t.judged_json, []);
- const hypers = db.get('SELECT COUNT(*) AS n FROM arena_talk_session_hype WHERE topic_id = ?', [t.id])?.n || 0;
- return {
- id: t.id, idx: t.idx, topic: t.topic, hint: t.hint, tone: t.tone, status: t.status,
- progress: Math.round(t.progress || 0), score: Number((t.score || 0).toFixed(1)), chunks: t.chunks || 0,
- best_line: t.best_line || null, best_line_sec: t.best_line_sec, best_vod_id: t.best_vod_id,
- hits: judged.filter(j => j.is_trash_talk).length, hypers,
- last_judgement: judged.length ? { at: judged[judged.length - 1].at, is_trash_talk: judged[judged.length - 1].is_trash_talk, quality: judged[judged.length - 1].quality, about: judged[judged.length - 1].about, fallback: !!judged[judged.length - 1].fallback } : null,
- entry_id: t.entry_id || null, started_at: t.started_at, cleared_at: t.cleared_at,
- };
-}
-
-function sessionView(session, { includeLines = true } = {}) {
- if (!session) return null;
- const topics = db.all('SELECT * FROM arena_talk_session_topics WHERE session_id = ? ORDER BY idx ASC', [session.id]);
- const active = topics.find(t => t.status === 'active') || null;
- const lines = includeLines ? db.all(`SELECT text, start_sec, vod_id FROM stream_timeline_events WHERE stream_id = ? AND kind = 'speech' AND start_sec >= ? ORDER BY start_sec DESC LIMIT 14`, [session.stream_id, session.offset_start_sec])
- .reverse().filter(l => !arena()._isBannedText(l.text)).map(l => ({ text: l.text, at: Math.max(0, Math.round(l.start_sec - session.offset_start_sec)), sec: Math.floor(l.start_sec), vod_id: l.vod_id })) : [];
- const about = parseJson(session.about_json, []);
- return {
- id: session.id, status: session.status, started_at: session.started_at, ended_at: session.ended_at, end_reason: session.end_reason,
- level: session.level || 1, xp: session.xp || 0, next_level_xp: levelFor(session.xp || 0) * XP_PER_LEVEL, xp_per_level: XP_PER_LEVEL,
- topics_cleared: session.topics_cleared || 0, lines_seen: session.lines_seen || 0, words_seen: session.words_seen || 0,
- active_topic: formatTopic(active),
- cleared_topics: topics.filter(t => t.status !== 'active').map(formatTopic),
- talked_about: about.slice(-20).reverse(),
- recent_lines: lines,
- stream_id: session.stream_id,
- judge_is_ai: aiOn(),
- };
-}
-
-function viewFor(usernameOrId) {
- ensureTables();
- const user = /^\d+$/.test(String(usernameOrId)) ? db.getUserById(Number(usernameOrId)) : db.getUserByUsername(String(usernameOrId));
- if (!user) return null;
- const session = liveSessionFor(user.id) || db.get(`SELECT * FROM arena_talk_sessions WHERE user_id = ? ORDER BY id DESC LIMIT 1`, [user.id]);
- const roster = arena().loadRoster();
- const f = roster.byId[user.id];
- const persona = parseJson(db.get('SELECT persona_json FROM arena_profiles WHERE user_id = ?', [user.id])?.persona_json);
- return {
- user: f ? f.user : { id: user.id, username: user.username, display_name: user.display_name || user.username, avatar_url: user.avatar_url, profile_color: user.profile_color },
- fighter_name: persona?.fighter_name || user.display_name || user.username,
- rank: f ? roster.order.indexOf(user.id) + 1 : null,
- power: f ? f.ratings.power : null,
- talk_bonus: f ? f.ratings.talk_bonus : 0,
- image_url: (() => { try { return arena().getFighterImageUrl ? arena().getFighterImageUrl(user.id) : null; } catch { return null; } })(),
- session: sessionView(session),
- };
-}
-
-function liveSessionSummaries() {
- ensureTables();
- const roster = arena().loadRoster();
- return liveSessions().map(s => {
- const f = roster.byId[s.user_id];
- const t = activeTopic(s.id);
- const persona = parseJson(db.get('SELECT persona_json FROM arena_profiles WHERE user_id = ?', [s.user_id])?.persona_json);
- return { user: f ? f.user : { id: s.user_id, username: `user${s.user_id}`, display_name: `user${s.user_id}` }, fighter_name: persona?.fighter_name || f?.user?.display_name, level: s.level, xp: s.xp, topics_cleared: s.topics_cleared, topic: t ? t.topic : null, progress: t ? Math.round(t.progress || 0) : 0, started_at: s.started_at };
- });
-}
-
-module.exports = {
- ensureTables, start, stop, tick, tickSession,
- startSession, stopSession, skipTopic, hypeSession, liveSessionFor, viewFor, sessionView, liveSessionSummaries,
- TICK_MS, XP_PER_LEVEL, JUDGE_MIN_WORDS, JUDGE_MIN_INTERVAL_MS, IDLE_END_MIN,
- _judgeChunk: judgeChunk, _heuristicChunk: heuristicChunk, _levelFor: levelFor, _activeTopic: activeTopic, _nextTopic: nextTopic,
-};
deleted file mode 100644
deleted file mode 100644
@@ -1,398 +0,0 @@
@@ -1,398 +0,0 @@
-/**
- * OpenVibe.Live — Arena Trash Talk
- *
- * The part of the Arena streamers and viewers play themselves:
- *
- * topic → every TOPIC_SLOT_HOURS the Arena posts a topic ("tonight's beef"), AI-written
- * from what is going on (main event, top of the ladder), with a templated
- * fallback. Seeded per slot so every server/restart agrees on it.
- * entry → a streamer on the roster enters once per topic, either from the LIVE MIC
- * (we read what they said on stream from the transcription timeline between
- * "start" and "drop the mic") or by typing it.
- * judge → the AI scores Spice / Wit / On-topic / Delivery (0–10 each) with a verdict,
- * a note and a stamp; slurs / harassment are zeroed and hidden (hard filter
- * before the model, plus the model's own `flagged`). Heuristic when AI is off.
- * crowd → viewers add the fifth score: `!hype` in that streamer's chat (or the Hype
- * button) — one per person, Crowd = min(10, uniques / 3).
- * power → an entry's total (/50) becomes a Trash Talk bonus on POWER (up to
- * TALK_BONUS_MAX), decaying linearly over TALK_BONUS_DAYS.
- */
-'use strict';
-
-const crypto = require('crypto');
-const db = require('../db/database');
-const llm = require('../ai/llm');
-
-const TOPIC_SLOT_HOURS = 6;
-const MIC_WINDOW_SEC = 60; // how long a live-mic session may run
-const MIC_GRACE_SEC = 8; // transcript lag — lines a few seconds after "drop" still count
-const TEXT_MAX = 280;
-const TALK_BONUS_MAX = 12;
-const TALK_BONUS_DAYS = 7;
-const HYPE_WINDOW_MS = 24 * 60 * 60 * 1000; // an entry can be hyped for a day
-const STAMPS = [[42, 'COOKED'], [30, 'SOLID'], [18, 'MID'], [0, 'FLOP']];
-
-const FALLBACK_TOPICS = [
- { topic: 'Explain why your chat is the smartest chat on this site. Lie if you must.', hint: 'Bring receipts, or confidence. Confidence is cheaper.', tone: 'brag' },
- { topic: 'Your stream schedule is a lifestyle. Defend it against the calendar.', hint: 'Sleep is for people with fewer viewers.', tone: 'brag' },
- { topic: 'The Main Event has two fighters in it. Tell us why neither is the real champion.', hint: 'Name names. Lovingly.', tone: 'roast' },
- { topic: 'Describe the last thing that went wrong on your stream as if it was a strategic decision.', hint: 'The audio cutting out was a bit. Obviously.', tone: 'spin' },
- { topic: 'Your signature move vs. the #1 fighter\'s. Who taps first?', hint: 'Fighting-game energy. Frame data optional.', tone: 'roast' },
- { topic: 'Sell your stream category to someone who has never heard of it, in one breath.', hint: 'You have the mic. They have their doubts.', tone: 'brag' },
- { topic: 'What does your chat say about you when you step away? Set the record straight.', hint: 'They say worse things. Get ahead of it.', tone: 'spin' },
- { topic: 'Rank the top three fighters and explain why you\'re about to move up.', hint: 'Bonus points for a plan that could not possibly work.', tone: 'roast' },
- { topic: 'Your setup cost more than their setup. Or it cost less and it\'s better. Pick one.', hint: 'Cables count. Cable management counts double.', tone: 'brag' },
- { topic: 'A new streamer just joined the site. Give them the worst possible advice with total confidence.', hint: 'Every word must sound like wisdom.', tone: 'bit' },
- { topic: 'Your entrance music just played. Narrate your walk to the ring.', hint: 'Slow-motion. Pyro. A confused sound guy.', tone: 'bit' },
- { topic: 'Defend the most controversial opinion you have ever said on stream.', hint: 'Keep it PG-13, keep it petty.', tone: 'spin' },
-];
-
-// ── Tables ───────────────────────────────────────────────────
-
-let _ready = false;
-function ensureTables() {
- if (_ready) return;
- db.run(`CREATE TABLE IF NOT EXISTS arena_talk_topics (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- slot TEXT NOT NULL UNIQUE,
- topic TEXT NOT NULL,
- hint TEXT,
- tone TEXT,
- source TEXT,
- generated_at DATETIME DEFAULT CURRENT_TIMESTAMP
- )`);
- db.run(`CREATE TABLE IF NOT EXISTS arena_talk (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- topic_id INTEGER NOT NULL,
- user_id INTEGER NOT NULL,
- stream_id INTEGER,
- source TEXT NOT NULL,
- text TEXT NOT NULL,
- vod_id INTEGER,
- start_sec INTEGER,
- scores_json TEXT,
- total REAL DEFAULT 0,
- crowd_uniques INTEGER DEFAULT 0,
- flagged INTEGER DEFAULT 0,
- verdict TEXT,
- note TEXT,
- stamp TEXT,
- judge_model TEXT,
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- judged_at DATETIME,
- UNIQUE(topic_id, user_id)
- )`);
- db.run(`CREATE TABLE IF NOT EXISTS arena_talk_hype (
- talk_id INTEGER NOT NULL,
- voter_key TEXT NOT NULL,
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- UNIQUE(talk_id, voter_key)
- )`);
- db.run('CREATE INDEX IF NOT EXISTS idx_arena_talk_user ON arena_talk (user_id, created_at)');
- _ready = true;
-}
-
-function aiOn() { try { return llm.isEnabled() && llm.withinBudget(); } catch { return false; } }
-function parseJson(t, f = null) { try { return t ? JSON.parse(t) : f; } catch { return f; } }
-function arena() { return require('./arena-service'); }
-
-// ── Topic ────────────────────────────────────────────────────
-
-function currentSlot(now = Date.now()) {
- const d = new Date(now);
- const slotIndex = Math.floor(d.getUTCHours() / TOPIC_SLOT_HOURS);
- return `${d.toISOString().slice(0, 10)}/${slotIndex}`;
-}
-function slotEndsAt(slot) {
- const [day, idx] = slot.split('/');
- return new Date(`${day}T00:00:00Z`).getTime() + (Number(idx) + 1) * TOPIC_SLOT_HOURS * 3600 * 1000;
-}
-
-function seededIndex(seedText, n) {
- const h = parseInt(crypto.createHash('sha256').update(seedText).digest('hex').slice(0, 8), 16);
- return h % n;
-}
-
-const TOPIC_SCHEMA = {
- name: 'arena_talk_topic',
- schema: {
- type: 'object', additionalProperties: false, required: ['topic', 'hint', 'tone'],
- properties: {
- topic: { type: 'string', description: 'One-sentence prompt a streamer answers on the mic, ≤ 140 chars' },
- hint: { type: 'string', description: 'A cheeky one-line tip, ≤ 90 chars' },
- tone: { type: 'string', enum: ['brag', 'roast', 'spin', 'bit'] },
- },
- },
-};
-const TOPIC_SYSTEM = `You write prompts for a live-streaming site's Trash Talk contest: a streamer answers the prompt out loud on stream, an AI judge scores spice, wit, relevance and delivery, and chat hypes them. Prompts must be answerable in 30 seconds, funny, PG-13, and about streaming life, chat, the fighters on the Arena ladder, or the day's Main Event — never about appearance, identity, health or money. Output only the JSON.`;
-
-async function getTopic({ generate = true } = {}) {
- ensureTables();
- const slot = currentSlot();
- let row = db.get('SELECT * FROM arena_talk_topics WHERE slot = ?', [slot]);
- if (row) return { ...row, ends_at: new Date(slotEndsAt(slot)).toISOString() };
- let topic = null;
- if (generate && aiOn()) {
- try {
- const roster = arena().loadRoster();
- const names = roster.order.slice(0, 5).map(id => {
- const p = parseJson(db.get('SELECT persona_json FROM arena_profiles WHERE user_id = ?', [id])?.persona_json);
- return p?.fighter_name || roster.byId[id].user.display_name;
- });
- let mainEvent = null;
- try { const me = await arena().getMainEvent({ generate: false }); if (me) mainEvent = `${me.a.persona.fighter_name} vs ${me.b.persona.fighter_name}`; } catch { /* */ }
- const r = await llm.complete({
- role: 'summary', kind: 'arena_talk_topic', source: 'arena',
- system: TOPIC_SYSTEM,
- user: JSON.stringify({ slot, top_fighters: names, main_event: mainEvent, previous_topics: db.all('SELECT topic FROM arena_talk_topics ORDER BY id DESC LIMIT 8').map(r => r.topic) }),
- json: TOPIC_SCHEMA, maxTokens: 200, temperature: 1.0, timeoutMs: 20000,
- });
- if (r && r.json && r.json.topic) topic = { ...r.json, source: 'ai' };
- } catch (e) { console.warn('[Arena] talk topic:', e.message); }
- }
- if (!topic) topic = { ...FALLBACK_TOPICS[seededIndex(slot, FALLBACK_TOPICS.length)], source: 'template' };
- try {
- db.run('INSERT OR IGNORE INTO arena_talk_topics (slot, topic, hint, tone, source) VALUES (?, ?, ?, ?, ?)', [slot, topic.topic.slice(0, 200), (topic.hint || '').slice(0, 140), topic.tone || 'brag', topic.source]);
- } catch { /* */ }
- row = db.get('SELECT * FROM arena_talk_topics WHERE slot = ?', [slot]);
- return { ...row, ends_at: new Date(slotEndsAt(slot)).toISOString() };
-}
-
-// ── Live mic sessions (in memory — a session is a minute long) ──
-
-const sessions = new Map(); // userId → { streamId, startedAt, streamStartedMs }
-
-function liveStreamFor(userId) {
- return db.get('SELECT id, started_at FROM streams WHERE user_id = ? AND is_live = 1 ORDER BY started_at DESC LIMIT 1', [userId]) || null;
-}
-function streamOffsetNow(stream) {
- const startedMs = stream?.started_at ? Date.parse(String(stream.started_at).replace(' ', 'T') + 'Z') : Date.now();
- return Math.max(0, (Date.now() - startedMs) / 1000);
-}
-function micAvailable(userId) {
- const live = liveStreamFor(userId);
- if (!live) return { available: false, reason: 'not_live' };
- const recent = db.get(`SELECT COUNT(*) AS n FROM stream_timeline_events WHERE stream_id = ? AND kind = 'speech' AND created_at >= datetime('now', '-15 minutes')`, [live.id])?.n || 0;
- if (!recent) return { available: false, reason: 'no_transcription', stream_id: live.id };
- return { available: true, stream_id: live.id };
-}
-
-function startMic(userId) {
- const live = liveStreamFor(userId);
- if (!live) throw new Error('You need to be live for the live mic — type your entry instead');
- const s = { streamId: live.id, startedAt: Date.now(), offsetStart: streamOffsetNow(live) };
- sessions.set(userId, s);
- return { stream_id: live.id, started_at: new Date(s.startedAt).toISOString(), window_sec: MIC_WINDOW_SEC };
-}
-
-function micFeed(userId) {
- const s = sessions.get(userId);
- if (!s) return { active: false, lines: [] };
- const elapsed = (Date.now() - s.startedAt) / 1000;
- const rows = db.all(`SELECT text, start_sec FROM stream_timeline_events WHERE stream_id = ? AND kind = 'speech' AND start_sec >= ? ORDER BY start_sec ASC LIMIT 60`, [s.streamId, Math.floor(s.offsetStart) - 2]);
- return {
- active: true,
- elapsed_sec: Math.round(elapsed),
- remaining_sec: Math.max(0, Math.round(MIC_WINDOW_SEC - elapsed)),
- lines: rows.map(r => ({ text: r.text, at: Math.max(0, Math.round(r.start_sec - s.offsetStart)) })),
- };
-}
-
-function collectMic(userId) {
- const s = sessions.get(userId);
- if (!s) throw new Error('No live-mic session — press "I\'m about to cook" first');
- sessions.delete(userId);
- const elapsed = Math.min(MIC_WINDOW_SEC, (Date.now() - s.startedAt) / 1000) + MIC_GRACE_SEC;
- const rows = db.all(`SELECT text, start_sec, vod_id FROM stream_timeline_events WHERE stream_id = ? AND kind = 'speech' AND start_sec >= ? AND start_sec <= ? ORDER BY start_sec ASC LIMIT 80`,
- [s.streamId, Math.floor(s.offsetStart) - 2, Math.ceil(s.offsetStart + elapsed)]);
- const text = rows.map(r => String(r.text || '').replace(/^\s*(?:>>|--?)\s*/, '').trim()).filter(Boolean).join(' ').replace(/\s+/g, ' ').trim();
- return { text: text.slice(0, 1200), stream_id: s.streamId, vod_id: rows[0]?.vod_id || null, start_sec: rows.length ? Math.max(0, Math.floor(rows[0].start_sec) - 2) : Math.floor(s.offsetStart), lines: rows.length };
-}
-
-// ── Judge ────────────────────────────────────────────────────
-
-const JUDGE_SCHEMA = {
- name: 'arena_talk_judgement',
- schema: {
- type: 'object', additionalProperties: false,
- required: ['spice', 'wit', 'on_topic', 'delivery', 'verdict', 'note', 'flagged'],
- properties: {
- spice: { type: 'integer', minimum: 0, maximum: 10, description: 'attitude and bite' },
- wit: { type: 'integer', minimum: 0, maximum: 10, description: 'cleverness, jokes, wordplay' },
- on_topic: { type: 'integer', minimum: 0, maximum: 10, description: 'actually answers the topic' },
- delivery: { type: 'integer', minimum: 0, maximum: 10, description: 'rhythm, quotability, confidence (read as a transcript — forgive filler words)' },
- verdict: { type: 'string', description: 'One-line ring-announcer verdict, ≤ 120 chars' },
- note: { type: 'string', description: "Judge's note: what landed and what to do better, ≤ 160 chars" },
- flagged: { type: 'boolean', description: 'true if it attacks appearance/identity/health/money, threatens, or is hateful — then the entry is void' },
- },
- },
-};
-const JUDGE_SYSTEM = `You judge a wholesome trash-talk contest between live streamers. Score the entry against the topic. Reward attitude, jokes, specificity about streaming life and the Arena, and lines chat would clip. Punish generic filler and anything cruel. Entries arrive as speech-to-text: forgive "um", repeats and odd punctuation — judge the content. Flag (and it becomes void) anything targeting appearance, body, disability, ethnicity, gender, religion, health or money, or containing threats or hate. Output only the JSON.`;
-
-function heuristicJudge(text, topic) {
- const t = String(text || '');
- const words = t.split(/\s+/).filter(Boolean).length;
- const excl = (t.match(/!/g) || []).length;
- // Stem-ish match so "smartest" in the topic counts "smarter" in the entry.
- const stems = [...new Set((String(topic || '').toLowerCase().match(/[a-z]{4,}/g) || []).map(w => w.slice(0, 5)))].filter(w => !['your', 'this', 'that', 'with', 'what', 'from', 'they', 'them', 'have', 'must', 'about', 'which', 'their'].includes(w));
- const hits = stems.filter(w => t.toLowerCase().includes(w)).length;
- const spice = Math.min(10, 3 + excl + (/\b(you|your|chat|arena|champ|weak|scared|bet)\b/i.test(t) ? 2 : 0));
- const wit = Math.min(10, Math.round(Math.min(words, 60) / 8) + (/\b(like|than|because|but)\b/i.test(t) ? 2 : 0));
- const on_topic = Math.min(10, 2 + hits * 2);
- const delivery = Math.min(10, words < 8 ? 2 : words > 120 ? 5 : 7);
- return { spice, wit, on_topic, delivery, verdict: 'The judge is out — scored on instinct.', note: 'AI judging is off; this is a rough heuristic score.', flagged: false, _fallback: true };
-}
-
-function stampFor(total) { return STAMPS.find(([min]) => total >= min)[1]; }
-function crowdScore(uniques) { return Math.min(10, Math.round((Number(uniques) || 0) / 3 * 10) / 10); }
-
-async function judge(text, topic, context) {
- const banned = arena()._isBannedText(text);
- let s = null;
- if (!banned && aiOn()) {
- try {
- const r = await llm.complete({
- role: 'chat', kind: 'arena_talk_judge', source: 'arena', ownerUserId: context.userId,
- system: JUDGE_SYSTEM,
- user: JSON.stringify({ topic: topic.topic, tone: topic.tone, fighter: context.fighter, entry: text, source: context.source }),
- json: JUDGE_SCHEMA, maxTokens: 300, temperature: 0.6, timeoutMs: 25000,
- });
- if (r && r.json && typeof r.json.spice === 'number') s = { ...r.json, _model: r.model };
- } catch (e) { console.warn('[Arena] talk judge:', e.message); }
- }
- if (!s) s = heuristicJudge(text, topic.topic);
- if (banned) s = { ...s, spice: 0, wit: 0, on_topic: 0, delivery: 0, flagged: true, verdict: 'Void — that crossed the line.', note: 'Slurs, hate or threats void an entry. Keep it petty, not cruel.' };
- const clamp = (v) => Math.max(0, Math.min(10, Math.round(Number(v) || 0)));
- return { spice: clamp(s.spice), wit: clamp(s.wit), on_topic: clamp(s.on_topic), delivery: clamp(s.delivery), verdict: String(s.verdict || '').slice(0, 200), note: String(s.note || '').slice(0, 240), flagged: !!s.flagged, model: s._model || null, fallback: !!s._fallback };
-}
-
-// ── Entries ──────────────────────────────────────────────────
-
-function totalFor(scores, crowdUniques) {
- if (!scores || scores.flagged) return 0;
- return Number((scores.spice + scores.wit + scores.on_topic + scores.delivery + crowdScore(crowdUniques)).toFixed(1));
-}
-
-async function submit(userId, { mode, text }) {
- ensureTables();
- const roster = arena().loadRoster();
- const entry = roster.byId[userId];
- if (!entry) throw new Error('Only fighters on the roster can enter — stream once and come back');
- const topic = await getTopic();
- if (db.get('SELECT id FROM arena_talk WHERE topic_id = ? AND user_id = ?', [topic.id, userId])) throw new Error('You already entered this topic — wait for the next one');
- let source = 'text', streamId = null, vodId = null, startSec = null, body = String(text || '').trim();
- if (mode === 'mic') {
- const c = collectMic(userId);
- if (!c.text || c.lines < 1) throw new Error("The mic didn't catch anything — the transcription needs a few seconds after you speak. Try again, or type it.");
- source = 'mic'; body = c.text; streamId = c.stream_id; vodId = c.vod_id; startSec = c.start_sec;
- } else {
- if (body.length < 12) throw new Error('Say a little more than that');
- body = body.slice(0, TEXT_MAX);
- const live = liveStreamFor(userId); if (live) streamId = live.id;
- }
- const fighter = parseJson(db.get('SELECT persona_json FROM arena_profiles WHERE user_id = ?', [userId])?.persona_json)?.fighter_name || entry.user.display_name;
- const scores = await judge(body, topic, { userId, fighter, source });
- const total = totalFor(scores, 0);
- db.run(`INSERT INTO arena_talk (topic_id, user_id, stream_id, source, text, vod_id, start_sec, scores_json, total, flagged, verdict, note, stamp, judge_model, judged_at)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)`,
- [topic.id, userId, streamId, source, body, vodId, startSec, JSON.stringify(scores), total, scores.flagged ? 1 : 0, scores.verdict, scores.note, stampFor(total), scores.model]);
- try { arena().loadRoster(true); } catch { /* */ }
- const row = db.get('SELECT * FROM arena_talk WHERE topic_id = ? AND user_id = ?', [topic.id, userId]);
- console.log(`[Arena] trash talk by user ${userId} (${source}): ${total}/50 ${stampFor(total)}${scores.flagged ? ' FLAGGED' : ''}`);
- return formatEntry(row, roster, { own: true });
-}
-
-function hype(talkId, voterKey) {
- ensureTables();
- const row = db.get('SELECT * FROM arena_talk WHERE id = ?', [talkId]);
- if (!row) throw new Error('No such entry');
- if (row.flagged) throw new Error('That entry is void');
- if (Date.now() - Date.parse(row.created_at + 'Z') > HYPE_WINDOW_MS) throw new Error('Hype window closed');
- if (voterKey === `user:${row.user_id}`) throw new Error("You can't hype yourself");
- const ins = db.run('INSERT OR IGNORE INTO arena_talk_hype (talk_id, voter_key) VALUES (?, ?)', [talkId, voterKey]);
- const uniques = db.get('SELECT COUNT(*) AS n FROM arena_talk_hype WHERE talk_id = ?', [talkId])?.n || 0;
- const scores = parseJson(row.scores_json);
- const total = totalFor(scores, uniques);
- db.run('UPDATE arena_talk SET crowd_uniques = ?, total = ?, stamp = ? WHERE id = ?', [uniques, total, stampFor(total), talkId]);
- if (ins.changes) { try { arena().loadRoster(true); } catch { /* */ } }
- return { added: !!ins.changes, crowd_uniques: uniques, crowd: crowdScore(uniques), total, stamp: stampFor(total) };
-}
-
-/** Latest hypeable entry by a streamer (for `!hype` in their chat). */
-function latestEntryFor(userId) {
- ensureTables();
- return db.get(`SELECT * FROM arena_talk WHERE user_id = ? AND flagged = 0 AND created_at >= datetime('now', '-1 day') ORDER BY id DESC LIMIT 1`, [userId]) || null;
-}
-
-function formatEntry(row, roster, { own = false } = {}) {
- const f = roster.byId[row.user_id];
- const persona = parseJson(db.get('SELECT persona_json FROM arena_profiles WHERE user_id = ?', [row.user_id])?.persona_json);
- const scores = parseJson(row.scores_json) || {};
- const flagged = !!row.flagged;
- return {
- id: row.id,
- user: f ? f.user : { id: row.user_id, username: `user${row.user_id}`, display_name: `user${row.user_id}` },
- fighter_name: persona?.fighter_name || f?.user?.display_name || `user${row.user_id}`,
- rank: f ? roster.order.indexOf(row.user_id) + 1 : null,
- source: row.source,
- text: flagged && !own ? null : row.text,
- vod_id: row.vod_id, start_sec: row.start_sec,
- scores: flagged ? null : { spice: scores.spice, wit: scores.wit, on_topic: scores.on_topic, delivery: scores.delivery, crowd: crowdScore(row.crowd_uniques) },
- crowd_uniques: row.crowd_uniques || 0,
- total: row.total || 0,
- stamp: row.stamp,
- verdict: row.verdict, note: own || !flagged ? row.note : null,
- flagged,
- judged_by_ai: !!row.judge_model,
- created_at: row.created_at,
- hype_open: Date.now() - Date.parse(row.created_at + 'Z') <= HYPE_WINDOW_MS,
- };
-}
-
-/** POWER bonus per user from entries in the last TALK_BONUS_DAYS, decaying linearly. */
-function talkBonuses() {
- ensureTables();
- const rows = db.all(`SELECT user_id, total, created_at FROM arena_talk WHERE flagged = 0 AND created_at >= datetime('now', ?)`, [`-${TALK_BONUS_DAYS} days`]);
- const out = {};
- for (const r of rows) {
- const ageDays = (Date.now() - Date.parse(r.created_at + 'Z')) / 86400000;
- const decay = Math.max(0, 1 - ageDays / TALK_BONUS_DAYS);
- out[r.user_id] = (out[r.user_id] || 0) + (r.total / 50) * TALK_BONUS_MAX * decay;
- }
- for (const id of Object.keys(out)) out[id] = Math.min(TALK_BONUS_MAX, Math.round(out[id]));
- return out;
-}
-
-async function board({ userId = null, generate = true } = {}) {
- ensureTables();
- const roster = arena().loadRoster();
- const topic = await getTopic({ generate });
- const entries = db.all('SELECT * FROM arena_talk WHERE topic_id = ? ORDER BY total DESC, id ASC', [topic.id]).map(r => formatEntry(r, roster, { own: r.user_id === userId }));
- const hall = db.all(`SELECT t.*, tp.topic AS topic_text FROM arena_talk t JOIN arena_talk_topics tp ON tp.id = t.topic_id WHERE t.flagged = 0 AND t.created_at >= datetime('now', '-30 days') ORDER BY t.total DESC, t.id ASC LIMIT 10`)
- .map(r => ({ ...formatEntry(r, roster), topic: r.topic_text }));
- const my = userId ? entries.find(e => e.user.id === userId) || null : null;
- const onRoster = !!(userId && roster.byId[userId]);
- return {
- topic, entries, hall_of_trash: hall,
- my_entry: my,
- can_enter: onRoster && !my,
- on_roster: onRoster,
- mic: userId ? micAvailable(userId) : { available: false, reason: 'anon' },
- mic_session: userId && sessions.has(userId) ? micFeed(userId) : null,
- rules: { text_max: TEXT_MAX, mic_window_sec: MIC_WINDOW_SEC, bonus_max: TALK_BONUS_MAX, bonus_days: TALK_BONUS_DAYS, topic_slot_hours: TOPIC_SLOT_HOURS, crowd_per_point: 3 },
- ai: aiOn(),
- };
-}
-
-function entriesFor(userId, limit = 6) {
- ensureTables();
- const roster = arena().loadRoster();
- return db.all(`SELECT t.*, tp.topic AS topic_text FROM arena_talk t JOIN arena_talk_topics tp ON tp.id = t.topic_id WHERE t.user_id = ? ORDER BY t.id DESC LIMIT ?`, [userId, limit])
- .map(r => ({ ...formatEntry(r, roster, { own: true }), topic: r.topic_text }));
-}
-
-module.exports = {
- ensureTables, getTopic, currentSlot, startMic, micFeed, micAvailable, submit, hype, latestEntryFor, board, entriesFor, talkBonuses,
- TALK_BONUS_MAX, TALK_BONUS_DAYS, TEXT_MAX, MIC_WINDOW_SEC, FALLBACK_TOPICS,
- _judge: judge, _heuristicJudge: heuristicJudge, _totalFor: totalFor, _crowdScore: crowdScore, _stampFor: stampFor, _sessions: sessions,
-};
new file mode 100644
new file mode 100644
@@ -0,0 +1,274 @@
@@ -0,0 +1,274 @@
+'use strict';
+
+// Arena v2 — beefs + the board + the listener + chat commands, end-to-end on a temp DB with no AI
+// configured (template headlines, fallback angles, heuristic judges — no LLM calls).
+
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+
+const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-arena2-'));
+process.env.DB_PATH = path.join(tmp, 'live.db');
+process.env.ARENA_IMAGE_PATH = path.join(tmp, 'arena');
+
+const db = require('../server/db/database');
+db.initDb();
+const arena = require('../server/arena/arena-service');
+const board = require('../server/arena/board');
+const beef = require('../server/arena/beef');
+const listener = require('../server/arena/listener');
+const chat = require('../server/arena/arena-chat');
+
+const mk = (username) => Number(db.createUser({ username, email: `${username}@x`, password_hash: 'x', display_name: username[0].toUpperCase() + username.slice(1), stream_key: username.padEnd(32, '0') }).lastInsertRowid);
+const u1 = mk('nova'), u2 = mk('grizzly_bear'), u3 = mk('pixelqueen'), viewer = mk('viewer');
+for (const uid of [u1, u2, u3, viewer]) 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(u2, 60, 5, 1); stream(u3, 20, 2, 3);
+const roster = arena.loadRoster(true);
+assert.deepStrictEqual(roster.order, [u1, u2, u3], 'roster by power');
+board.ensureTables(); beef.ensureTables();
+
+(async () => {
+ // ── Board: topics from chat, fallback angles, one active topic per streamer ──
+ const t1 = board.createTopic({ text: 'Streamers who read donations in a baby voice', createdBy: 'chat', creatorUserId: viewer, creatorName: 'Viewer' });
+ assert.strictEqual(t1.kind, 'topic');
+ assert.throws(() => board.createTopic({ text: 'streamers who read donations in a baby voice', createdBy: 'chat' }), /already on the board/);
+ assert.throws(() => board.createTopic({ text: 'ok', createdBy: 'chat' }), /short|characters|topic/i);
+ const angles = await board.ensureAngles(t1);
+ assert.strictEqual(angles.length, 3, 'fallback angles without AI');
+ await assert.rejects(board.joinTopic(t1.id, viewer), /roster/, 'viewers cannot talk on a topic');
+ const j = await board.joinTopic(t1.id, u1);
+ assert.strictEqual(j.topic.id, t1.id);
+ assert.strictEqual(board.activeTopicFor(u1).id, t1.id);
+ const t2 = board.createTopic({ text: 'Whose chat is the dumbest chat on the site', createdBy: 'streamer', creatorUserId: u2, creatorName: 'Grizzly' });
+ await board.joinTopic(t2.id, u1);
+ assert.strictEqual(board.activeTopicFor(u1).id, t2.id, 'joining another topic switches the active one');
+ await board.joinTopic(t1.id, u1);
+
+ // Judged chunks: progress → angle cleared (+25) → all angles → conquered (+60).
+ const fresh = () => db.get('SELECT * FROM arena_topics WHERE id = ?', [t1.id]);
+ let r = board.applyTopicJudgement(u1, fresh(), { angle_idx: 0, quality: 6, progress_gain: 60, best_line: 'Your baby voice sounds like a fax machine', about: 'baby voice' }, { vod_id: null, sec: 10 });
+ assert.deepStrictEqual([r.applied, r.progress, r.cleared_angle], [true, 60, false]);
+ assert.strictEqual(board.applyTopicJudgement(u1, fresh(), { angle_idx: -1, quality: 0, progress_gain: 0 }).applied, false, 'off-topic chunk does nothing');
+ assert.strictEqual(board.applyTopicJudgement(u1, fresh(), { angle_idx: 0, quality: 9, progress_gain: 50, flagged: true }).applied, false, 'flagged chunk (threat/minor/dox) is ignored');
+ r = board.applyTopicJudgement(u1, fresh(), { angle_idx: 0, quality: 8, progress_gain: 50, best_line: 'Read my dono in your real voice, coward', about: 'dono voice' }, { vod_id: null, sec: 40 });
+ assert.ok(r.cleared_angle && r.progress === 100 && !r.conquered);
+ assert.ok(r.xp >= board.XP_ANGLE_CLEARED, `angle clear pays: ${r.xp}`);
+ for (const idx of [1, 2]) r = board.applyTopicJudgement(u1, fresh(), { angle_idx: idx, quality: 7, progress_gain: 60, best_line: `line ${idx}`, about: 'x' }, {});
+ for (const idx of [1, 2]) r = board.applyTopicJudgement(u1, fresh(), { angle_idx: idx, quality: 7, progress_gain: 60, best_line: `line ${idx}b`, about: 'x' }, {});
+ assert.strictEqual(r.conquered, true, 'all angles cleared → topic conquered');
+ assert.strictEqual(board.activeTopicFor(u1), null, 'conquering ends the active topic');
+ const lv = board.levelView(u1);
+ assert.ok(lv.xp >= board.XP_ANGLE_CLEARED * 3 + board.XP_TOPIC_CONQUERED, `xp accrued: ${lv.xp}`);
+ assert.strictEqual(lv.level, board.levelFor(lv.xp));
+ assert.ok(lv.level >= 3, `Trash Level climbed: ${lv.level}`);
+ assert.strictEqual(lv.angles_cleared, 3); assert.strictEqual(lv.topics_conquered, 1);
+ assert.strictEqual(lv.best_line.text, 'Read my dono in your real voice, coward', 'best line is the highest-quality one');
+ const detail = board.topicDetail(t1.id);
+ assert.strictEqual(detail.conquered, 1); assert.strictEqual(detail.members[0].cleared, 3);
+ assert.ok(detail.best_lines.length >= 1 && detail.progress[u1].every(p => p.cleared));
+ console.log('✅ board topics: angles, progress, clears, conquest, Trash Level');
+
+ // Hype + heat.
+ assert.throws(() => board.hypeTopic(t2.id, u2, `user:${u2}`), /yourself/);
+ await board.joinTopic(t2.id, u2);
+ assert.deepStrictEqual(board.hypeTopic(t2.id, u2, 'anon:abc'), { added: true, hypers: 1 });
+ assert.deepStrictEqual(board.hypeTopic(t2.id, u2, 'anon:abc'), { added: false, hypers: 1 }, 'one hype per person');
+ assert.ok(board.computeHeat(t2.id) >= 5, 'talking + hype makes heat');
+ const view = board.boardView();
+ assert.strictEqual(view.open[0].id, t1.id, 'hottest topic first (7 judged hits in the last hour beat 1 talker + 1 hype)');
+ assert.ok(view.open[0].heat > view.open[1].heat && view.open[0].hot, `heat sorted: ${view.open.map(t => t.heat)}`);
+ assert.ok(view.open.find(t => t.id === t2.id).talking_now.some(f => f.user.id === u2));
+ console.log('✅ hype + heat ordering');
+
+ // Debate: sides from chat + talk, resolves at expiry, clout for right picks.
+ const d = board.createTopic({ text: 'Face cam or no face cam', createdBy: 'ai', kind: 'debate', sideA: 'Face cam', sideB: 'No cam', headline: 'THE CAM WAR' });
+ assert.strictEqual(JSON.parse(d.angles_json).length, 3, 'debate carries its sides as angles');
+ assert.deepStrictEqual(board.pickSide(d.id, 'anon:v1', 'a', null), { a: 1, b: 0, share_a: 100 });
+ board.pickSide(d.id, 'anon:v2', 'b'); board.pickSide(d.id, 'anon:v1', 'b');
+ assert.deepStrictEqual(board.sideTally(d.id), { a: 0, b: 2, share_a: 0 }, 'changing sides never double-counts');
+ await board.joinTopic(d.id, u3);
+ board.applyTopicJudgement(u3, db.get('SELECT * FROM arena_topics WHERE id = ?', [d.id]), { angle_idx: 0, quality: 9, progress_gain: 30, best_line: 'cams are for cowards', about: 'x' }, {});
+ db.run(`UPDATE arena_topics SET expires_at = datetime('now', '-1 minute') WHERE id = ?`, [d.id]);
+ const res = board.resolveExpired();
+ assert.strictEqual(res.length, 1);
+ assert.strictEqual(res[0].winner, 'a', 'talk (9×2=18) beats chat (2 for b)');
+ assert.strictEqual(res[0].mvp_user_id, u3);
+ assert.strictEqual(board.topicDetail(d.id).status, 'resolved');
+ // second debate so the voter has ≥ 2 picks and shows on the clout board
+ const d2 = board.createTopic({ text: 'Keyboard sounds on stream', createdBy: 'ai', kind: 'debate', sideA: 'ASMR', sideB: 'Mute it' });
+ board.pickSide(d2.id, 'anon:v1', 'a'); board.pickSide(d2.id, 'anon:v2', 'a');
+ db.run(`UPDATE arena_topics SET expires_at = datetime('now', '-1 minute') WHERE id = ?`, [d2.id]);
+ board.resolveExpired();
+ const clout = board.cloutLeaderboard();
+ assert.ok(clout.length >= 1 && clout[0].picks === 2, `clout board: ${JSON.stringify(clout)}`);
+ console.log('✅ debates: sides, resolution, clout');
+
+ // Phrase challenge: no model needed.
+ const ph = board.createTopic({ text: 'Say "certified yapper" on stream', createdBy: 'ai', kind: 'phrase', phrase: 'certified yapper' });
+ assert.deepStrictEqual(board.checkPhrases(u2, 'we are just a certified yapper household', { vod_id: null, sec: 1 }).map(h => h.xp), [15]);
+ assert.deepStrictEqual(board.checkPhrases(u2, 'CERTIFIED YAPPER again', {}).map(h => h.xp), [5], 'repeats pay less');
+ assert.deepStrictEqual(board.checkPhrases(u2, 'nothing here', {}), []);
+ assert.strictEqual(board.topicDetail(ph.id).hits, 2);
+ console.log('✅ phrase challenges');
+
+ // ── Beefs: a name on mic opens it, the target goes on the clock, silence forfeits ──
+ const h1 = beef.recordHit(u1, u2, { quality: 7, best_line: 'Grizzly streams to 12 people and 9 are his alts', about: 'his viewers are alts', announcer: 'Nova swings first!' });
+ assert.strictEqual(h1.opened, true); assert.strictEqual(h1.side, 'a');
+ assert.strictEqual(h1.beef.on_clock, 'b', 'the target is on the clock');
+ let v = beef.get(h1.beef.id);
+ assert.ok(v.clock_seconds_left > 23 * 3600 && v.clock_seconds_left <= 24 * 3600, `offline clock is 24 h: ${v.clock_seconds_left}`);
+ assert.ok(v.headline && v.headline.includes('Nova'), `template headline: ${v.headline}`);
+ assert.strictEqual(v.share_a, 100);
+ assert.strictEqual(beef.recordHit(u1, u1, { quality: 5 }), null, 'no beef with yourself');
+ const h2 = beef.recordHit(u2, u1, { quality: 9, best_line: 'Nova needed a bit to get views, I just exist', about: 'clout chasing' });
+ assert.strictEqual(h2.opened, false); assert.strictEqual(h2.beef.id, h1.beef.id, 'same pair → same beef');
+ assert.strictEqual(h2.first_response, true);
+ assert.strictEqual(h2.beef.on_clock, 'a', 'answering flips the clock');
+ v = beef.get(h1.beef.id);
+ assert.strictEqual(v.feed.length, 2);
+ assert.strictEqual(v.feed[1].kind, 'respond');
+ assert.ok(v.share_a < 50, 'quality 9 beats quality 7');
+ assert.throws(() => beef.hype(v.id, 'a', `user:${u1}`), /yourself/);
+ assert.deepStrictEqual(beef.hype(v.id, 'a', 'anon:x'), { added: true, hypers: 1, crowd: 1 });
+ assert.deepStrictEqual(beef.hype(v.id, 'a', 'anon:x').added, false);
+ assert.deepStrictEqual(beef.pickSide(v.id, 'anon:x', 'b'), { a: 0, b: 1, share_a: 0 });
+ assert.throws(() => beef.hype(999, 'a', 'anon:x'), /No open beef/);
+ beef.tick();
+ assert.strictEqual(beef.get(v.id).status, 'open', 'clock still running → stays open');
+ db.run(`UPDATE arena_beefs SET clock_until = datetime('now', '-1 minute') WHERE id = ?`, [v.id]);
+ beef.tick();
+ v = beef.get(v.id);
+ assert.strictEqual(v.status, 'resolved'); assert.strictEqual(v.resolution, 'forfeit');
+ assert.strictEqual(v.winner_user_id, u2, 'the side on the clock (a) went silent → b wins');
+ assert.ok(v.result_headline, 'result headline set');
+ assert.deepStrictEqual(beef.recordFor(u2), { wins: 1, losses: 0, draws: 0 });
+ assert.deepStrictEqual(beef.recordFor(u1), { wins: 0, losses: 1, draws: 0 });
+ assert.strictEqual(beef.streakFor(u2), 1);
+ assert.ok(board.levelView(u2).xp >= beef.XP_BEEF_WIN, 'winner gets beef XP');
+ assert.ok(arena.listFighters().find(f => f.user.id === u2).ratings.talk_bonus > 0, 'recent win → mouth bonus on POWER');
+ console.log('✅ beef: open on a name-drop, clock flips on answer, forfeit on silence');
+
+ // Rematch + hard end on points + upset + rivalry receipts.
+ const h3 = beef.recordHit(u1, u2, { quality: 8, best_line: 'Round two, and this time bring your real viewers', about: 'rematch' });
+ assert.strictEqual(h3.opened, true);
+ let v2 = beef.get(h3.beef.id);
+ assert.strictEqual(v2.rematch, true, 'second beef between the pair is a rematch');
+ assert.strictEqual(v2.history.fights, 1);
+ beef.recordHit(u2, u1, { quality: 3, best_line: 'ok', about: 'weak' });
+ db.run(`UPDATE arena_beefs SET ends_at = datetime('now', '-1 minute') WHERE id = ?`, [v2.id]);
+ beef.tick();
+ v2 = beef.get(v2.id);
+ assert.strictEqual(v2.resolution, 'score'); assert.strictEqual(v2.winner_user_id, u1, 'higher total on the hard end wins');
+ assert.strictEqual(v2.upset, false, 'the #1 beating #2 is no upset');
+ const riv = beef.rivalry(u1, u2);
+ assert.deepStrictEqual([riv.fights, riv.wins_1, riv.wins_2], [2, 1, 1]);
+ assert.ok(riv.receipts.length >= 1 && riv.receipts[0].quality >= 6, 'receipts are the quotable lines from earlier beefs');
+ assert.strictEqual(beef.rivalriesFor(u1)[0].opponent.user.id, u2);
+ // Upset: #3 beats #1 on a forfeit.
+ const h4 = beef.recordHit(u3, u1, { quality: 6, best_line: 'Nova is a lobby simulator with a face cam', about: 'x' });
+ db.run(`UPDATE arena_beefs SET clock_until = datetime('now', '-1 minute') WHERE id = ?`, [h4.beef.id]);
+ beef.tick();
+ const up = beef.get(h4.beef.id);
+ assert.strictEqual(up.winner_user_id, u3);
+ assert.strictEqual(up.upset, false, 'rank gap of 2 is not an upset (needs ≥ 4)');
+ const L = beef.list();
+ assert.strictEqual(L.open.length, 0); assert.strictEqual(L.resolved.length, 3);
+ console.log('✅ rematches, hard end on points, rivalry receipts');
+
+ // Bounty doubles beef XP for whoever collects.
+ const bty = board.createTopic({ text: 'Bounty: Pixelqueen', createdBy: 'chat', creatorUserId: viewer, kind: 'bounty', targetUserId: u3 });
+ assert.strictEqual(board.openBountyOn(u3).id, bty.id);
+ assert.throws(() => board.createTopic({ text: 'Bounty again', createdBy: 'chat', kind: 'bounty', targetUserId: u3 }), /already a bounty/);
+ const before = board.levelView(u2).xp;
+ const hb = beef.recordHit(u2, u3, { quality: 8, best_line: 'Pixelqueen plays on a calculator', about: 'setup' });
+ assert.strictEqual(hb.bounty, true);
+ const gained = board.levelView(u2).xp - before;
+ assert.ok(gained >= 8 * 2 + beef.XP_BEEF_OPEN, `bounty doubles the hit XP (+open bonus): ${gained}`);
+ assert.strictEqual(board.topicDetail(bty.id).hits, 1);
+ console.log('✅ bounties');
+
+ // ── Listener: aliases + heuristics + a real tick over a live transcribed stream ──
+ const fresh2 = arena.loadRoster(true);
+ assert.deepStrictEqual(listener._mentionsIn('honestly grizzly bear is washed', u1, fresh2), [u2], 'underscored username spoken with a space');
+ assert.deepStrictEqual(listener._mentionsIn('shoutout @pixelqueen for the raid', u1, fresh2), [u3]);
+ assert.deepStrictEqual(listener._mentionsIn('nova is the best, nova nova', u1, fresh2), [], 'a speaker never mentions themselves');
+ assert.deepStrictEqual(listener._mentionsIn('the supernova exploded', u2, fresh2), [], 'no match inside other words');
+ const hb1 = listener._heuristicBeef('Grizzly is trash and washed, he could never beat me!', ['grizzly']);
+ assert.strictEqual(hb1.aimed_at_target, true); assert.ok(hb1.quality >= 6);
+ assert.strictEqual(listener._heuristicBeef('thanks grizzly for the raid, love you', ['grizzly']).aimed_at_target, false);
+ const ht = listener._heuristicTopic('the roast angle: worst at this is grizzly because reasons', [{ text: 'Brag: why you win' }, { text: 'Roast: who is worst at this and why' }, { text: 'Bit: unhinged take' }]);
+ assert.strictEqual(ht.angle_idx, 1);
+
+ // Nova goes live with transcription; says pixelqueen's name while talking shit → beef opens without anyone clicking.
+ const liveId = Number(db.createStream({ user_id: u1, title: 'live', category: 'irl', protocol: 'rtmp' }).lastInsertRowid);
+ db.run(`UPDATE streams SET is_live = 1, started_at = datetime('now', '-600 seconds') WHERE id = ?`, [liveId]);
+ db.addTimelineEvents([
+ { stream_id: liveId, user_id: u1, vod_id: null, kind: 'speech', start_sec: 586, end_sec: 590, text: 'Chat, pixelqueen is trash, she is washed and could never beat me in anything, she is scared of the smoke.', label: null, confidence: 0.9 },
+ { stream_id: liveId, user_id: u1, vod_id: null, kind: 'speech', start_sec: 591, end_sec: 596, text: 'Nobody watches that stream, it is mid, it is garbage, I would cook her in a second, bet!', label: null, confidence: 0.9 },
+ ]);
+ assert.strictEqual(arena.liveFighters()[0].user.id, u1);
+ const ev = await listener.tick();
+ assert.ok(ev.some(e => e.kind === 'beef_hit' && e.targetId === u3 && e.opened), `listener opened the beef: ${JSON.stringify(ev)}`);
+ assert.strictEqual(listener.consoleState(u1).listening, true);
+ assert.ok(listener.consoleState(u1).last_beef_judgement.aimed_at_target);
+ const opened = beef.openBeefsFor(u1);
+ assert.strictEqual(opened.length, 1); assert.strictEqual(opened[0].b_user_id, u3);
+ assert.strictEqual(opened[0].on_clock, 'b');
+ assert.strictEqual(arena.liveFighters()[0].open_beefs, 1);
+ console.log('✅ listener: name-drop on a live stream opens the beef');
+
+ // ── Chat commands ──
+ const sent = [], room = [];
+ const fakeChat = { sendTo: (ws, m) => sent.push(m.message), broadcastToStream: (sid, m) => room.push(m.message) };
+ const run = async (client, line) => { const parts = line.split(' '); const handled = chat.handle(fakeChat, {}, client, parts[0], parts); await new Promise(r => setTimeout(r, 30)); return handled; };
+ const vc = { user: { id: viewer, username: 'viewer', display_name: 'Viewer' }, streamId: liveId, ip: '1.1.1.1' };
+ assert.strictEqual(await run(vc, '!nope'), false);
+ assert.strictEqual(await run(vc, '!topic Streamers who blame lag for everything'), true);
+ assert.ok(sent.pop().includes('/arena/topic/'), 'topic posted with a link');
+ assert.ok(room.pop().includes('put a topic on the Arena board'));
+ assert.ok(db.get(`SELECT id FROM arena_topics WHERE text LIKE 'Streamers who blame lag%' AND created_by = 'chat'`));
+ await new Promise(r => setTimeout(r, 4100)); // per-person command rate limit
+ await run(vc, '!hype');
+ assert.ok(sent.pop().includes('Hyped Nova in their beef'), 'hype goes to the streamer\'s open beef');
+ assert.strictEqual(beef.get(opened[0].id).a.crowd, 1);
+ await new Promise(r => setTimeout(r, 4100));
+ await run(vc, '!side pixelqueen');
+ assert.ok(sent.pop().includes("You're with"));
+ assert.deepStrictEqual(beef.sidesTally(opened[0].id), { a: 0, b: 1, share_a: 0 });
+ await new Promise(r => setTimeout(r, 4100));
+ await run({ anonId: 'zz', streamId: liveId }, '!beef');
+ assert.ok(sent.pop().includes('on the clock'), 'beef summary shows the clock');
+ await run({ anonId: 'zz2', streamId: liveId }, '!board');
+ assert.ok(sent.pop().includes('Hottest'), 'board summary');
+ await run({ anonId: 'zz3', streamId: liveId }, '!topic no sign in');
+ assert.ok(sent.pop().includes('Sign in'));
+ console.log('✅ chat: !topic !hype !side !beef !board');
+
+ // ── Public API smoke ──
+ const express = require('express');
+ const app = express(); app.use(express.json()); app.use('/api/arena', require('../server/arena/routes'));
+ const srv = await new Promise(r => { const s = app.listen(0, () => r(s)); });
+ const get = async (p) => { const res = await fetch(`http://127.0.0.1:${srv.address().port}/api/arena${p}`); return { status: res.status, body: await res.json() }; };
+ const bd = await get('/board');
+ assert.strictEqual(bd.status, 200); assert.ok(Array.isArray(bd.body.open) && bd.body.levels.length >= 1 && bd.body.clout.length >= 1);
+ const bf = await get('/beefs'); assert.strictEqual(bf.body.open.length, 2, 'nova→pixelqueen (listener) + grizzly→pixelqueen (bounty)'); assert.strictEqual(bf.body.resolved.length, 3);
+ const one = await get(`/beefs/${opened[0].id}`); assert.strictEqual(one.body.a.user.username, 'nova'); assert.ok(one.body.rules.response_live_min === 15);
+ const con = await get('/console/nova'); assert.strictEqual(con.body.listener.listening, true); assert.strictEqual(con.body.open_beefs.length, 1); assert.ok(con.body.hot_mic.length === 2);
+ const lv2 = await get('/live'); assert.strictEqual(lv2.body.live[0].open_beefs, 1);
+ const tp = await get(`/board/topics/${t1.id}`); assert.strictEqual(tp.body.conquered, 1);
+ assert.strictEqual((await get('/beefs/999')).status, 404);
+ assert.strictEqual((await get('/console/nobody')).status, 404);
+ srv.close();
+ console.log('✅ public API');
+
+ console.log('\n✅ All Arena beef/board tests passed');
+ process.exit(0);
+})().catch(err => { console.error(err); process.exit(1); });
deleted file mode 100644
deleted file mode 100644
@@ -1,120 +0,0 @@
@@ -1,120 +0,0 @@
-'use strict';
-
-// Live trash-talk sessions: start needs a live stream with transcription, the ticker
-// judges new transcript chunks against the current topic, progress clears topics and
-// generates the next one, XP levels up, hype adds XP/progress, and the session ends when
-// the stream ends. No AI configured → heuristic chunk judge, templated topics.
-
-const assert = require('assert');
-const fs = require('fs');
-const os = require('os');
-const path = require('path');
-
-const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-session-'));
-process.env.DB_PATH = path.join(tmp, 'live.db');
-process.env.ARENA_IMAGE_PATH = path.join(tmp, 'arena');
-const db = require('../server/db/database');
-db.initDb();
-const arena = require('../server/arena/arena-service');
-const sessions = require('../server/arena/talk-session');
-
-const mk = (u, key) => Number(db.createUser({ username: u, email: `${u}@x`, password_hash: 'x', display_name: u.toUpperCase(), stream_key: key }).lastInsertRowid);
-const u1 = mk('alpha', 'a'.repeat(32));
-db.ensureChannel(u1);
-const old = Number(db.createStream({ user_id: u1, title: 'old', category: 'irl', protocol: 'rtmp' }).lastInsertRowid);
-db.run(`UPDATE streams SET is_live = 0, started_at = datetime('now', '-2 days'), ended_at = datetime('now', '-2 days'), duration_seconds = 3600, peak_viewers = 20 WHERE id = ?`, [old]);
-
-assert.strictEqual(sessions._levelFor(0), 1);
-assert.strictEqual(sessions._levelFor(39), 1);
-assert.strictEqual(sessions._levelFor(40), 2);
-assert.strictEqual(sessions._levelFor(125), 4);
-const hc = sessions._heuristicChunk("Chat, your chat couldn't read a menu. I'm the smartest streamer on this whole site, bet!", { topic: 'Explain why your chat is the smartest chat on this site.', hint: '', tone: 'brag' });
-assert.strictEqual(hc.is_trash_talk, true);
-assert.ok(hc.progress_gain > 10 && hc.best_line.length > 5, JSON.stringify(hc));
-const quiet = sessions._heuristicChunk('okay let me open the inventory and craft the pickaxe then we go mining', { topic: 'Explain why your chat is the smartest chat on this site.', hint: '', tone: 'brag' });
-assert.ok(quiet.progress_gain <= 15, 'gameplay chatter barely moves the topic');
-console.log('✅ levels + heuristic chunk judge');
-
-(async () => {
- await assert.rejects(() => sessions.startSession(u1), /Go live/, 'needs a live stream');
- const live = Number(db.createStream({ user_id: u1, title: 'live', category: 'irl', protocol: 'rtmp' }).lastInsertRowid);
- db.run(`UPDATE streams SET is_live = 1, started_at = datetime('now', '-10 minutes') WHERE id = ?`, [live]);
- await assert.rejects(() => sessions.startSession(u1), /transcription/, 'needs transcript rows');
- db.addTimelineEvents([{ stream_id: live, user_id: u1, vod_id: 77, kind: 'speech', start_sec: 500, end_sec: 505, text: 'welcome in chat, we are live', label: null, confidence: 0.9 }]);
-
- const s = await sessions.startSession(u1);
- assert.strictEqual(s.status, 'live');
- const again = await sessions.startSession(u1);
- assert.strictEqual(again.id, s.id, 'one live session per user');
- let v = sessions.viewFor('alpha');
- assert.ok(v.session.active_topic && v.session.active_topic.topic, 'a first topic is set');
- assert.strictEqual(v.session.level, 1);
- assert.strictEqual(v.session.active_topic.progress, 0);
- console.log('✅ session start + first topic');
-
- // Feed trash talk in the transcript AFTER the session offset and tick.
- const off = 600 + 5; // session started ~600s into the stream
- const trash = [
- "Chat, your chat couldn't read a menu. I'm the smartest streamer on this whole site, bet!",
- 'Nobody on this ladder is scared of nothing except me, champ. Come see who is better.',
- "You call that a stream? Best believe my chat is smarter, faster, and prettier than yours!",
- ];
- db.addTimelineEvents(trash.map((t, i) => ({ stream_id: live, user_id: u1, vod_id: 77, kind: 'speech', start_sec: off + i * 10, end_sec: off + i * 10 + 8, text: t, label: null, confidence: 0.9 })));
- await sessions.tick();
- v = sessions.viewFor('alpha');
- const t1 = v.session.active_topic;
- assert.ok(t1.chunks >= 1, 'a chunk was judged');
- assert.ok(t1.progress > 0 && t1.progress <= 100, `progress ${t1.progress}`);
- assert.ok(v.session.xp > 0, 'xp earned');
- assert.ok(v.session.recent_lines.length >= 3, 'live lines visible');
- assert.ok(v.session.talked_about.length >= 1, 'talked-about tags');
- assert.ok(t1.best_line && t1.best_vod_id === 77 && typeof t1.best_line_sec === 'number', 'best line links to the VOD');
- console.log('✅ tick judges new speech against the topic');
-
- // Hype from two viewers, self-hype blocked.
- const h1 = sessions.hypeSession(u1, 'user:50');
- assert.strictEqual(h1.added, true);
- assert.strictEqual(sessions.hypeSession(u1, 'user:50').added, false);
- sessions.hypeSession(u1, 'anon:abc');
- assert.throws(() => sessions.hypeSession(u1, `user:${u1}`), /yourself/);
- v = sessions.viewFor('alpha');
- assert.strictEqual(v.session.active_topic.hypers, 2);
- console.log('✅ hype adds XP + progress');
-
- // Keep talking (bypass the 30 s judge interval by rewinding last_judge_at) until the topic clears.
- let cleared = 0;
- for (let round = 0; round < 8 && !cleared; round++) {
- db.run(`UPDATE arena_talk_sessions SET last_judge_at = datetime('now', '-2 minutes') WHERE id = ?`, [s.id]);
- db.addTimelineEvents(trash.map((t, i) => ({ stream_id: live, user_id: u1, vod_id: 77, kind: 'speech', start_sec: off + 100 + round * 40 + i * 10, end_sec: off + 100 + round * 40 + i * 10 + 8, text: t + ` (${round})`, label: null, confidence: 0.9 })));
- await sessions.tick();
- cleared = sessions.viewFor('alpha').session.topics_cleared;
- }
- v = sessions.viewFor('alpha');
- assert.strictEqual(v.session.topics_cleared, 1, 'talking enough clears the topic');
- assert.strictEqual(v.session.cleared_topics.length, 1);
- assert.strictEqual(v.session.cleared_topics[0].status, 'cleared');
- assert.ok(v.session.active_topic && v.session.active_topic.idx === 1, 'a new topic replaces it');
- assert.notStrictEqual(v.session.active_topic.topic, v.session.cleared_topics[0].topic, 'the new topic differs');
- const entry = db.get(`SELECT * FROM arena_talk WHERE user_id = ? AND source = 'session'`, [u1]);
- assert.ok(entry && entry.total > 0, 'a cleared topic becomes a Trash Talk entry');
- assert.ok(arena.loadRoster(true).byId[u1].ratings.talk_bonus >= 1, 'and feeds the POWER bonus');
- console.log('✅ topic clears → next topic + entry + bonus');
-
- const skipped = await sessions.skipTopic(u1);
- assert.strictEqual(skipped.idx, 2);
- assert.strictEqual(sessions.viewFor('alpha').session.cleared_topics.filter(t => t.status === 'skipped').length, 1);
- assert.ok(sessions.liveSessionSummaries().length === 1 && sessions.liveSessionSummaries()[0].user.username === 'alpha');
- console.log('✅ skip + live list');
-
- db.run('UPDATE streams SET is_live = 0 WHERE id = ?', [live]);
- await sessions.tick();
- v = sessions.viewFor('alpha');
- assert.strictEqual(v.session.status, 'ended');
- assert.strictEqual(v.session.end_reason, 'stream_ended');
- assert.strictEqual(sessions.liveSessionFor(u1), null);
- assert.throws(() => sessions.stopSession(u1), /No live session/);
- console.log('✅ session ends with the stream');
-
- console.log('\n✅ All session tests passed');
- process.exit(0);
-})().catch(err => { console.error(err); process.exit(1); });
deleted file mode 100644
deleted file mode 100644
@@ -1,135 +0,0 @@
@@ -1,135 +0,0 @@
-'use strict';
-
-// Trash Talk: topics are per-slot and deterministic without AI, entries are judged
-// (heuristic without AI, slurs void), the crowd score comes from unique hypes, the
-// POWER bonus decays, and chat commands reply through the ChatServer API.
-
-const assert = require('assert');
-const fs = require('fs');
-const os = require('os');
-const path = require('path');
-
-const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-talk-'));
-process.env.DB_PATH = path.join(tmp, 'live.db');
-process.env.ARENA_IMAGE_PATH = path.join(tmp, 'arena');
-const db = require('../server/db/database');
-db.initDb();
-const arena = require('../server/arena/arena-service');
-const talk = require('../server/arena/trash-talk');
-const chat = require('../server/arena/arena-chat');
-
-// ── pure bits ──
-assert.strictEqual(talk.currentSlot(Date.UTC(2026, 7, 29, 13, 5)), '2026-08-29/2', '13:05 UTC is slot 2 of 6-hour slots');
-assert.strictEqual(talk.currentSlot(Date.UTC(2026, 7, 29, 23, 59)), '2026-08-29/3');
-assert.strictEqual(talk._crowdScore(0), 0);
-assert.strictEqual(talk._crowdScore(3), 1);
-assert.strictEqual(talk._crowdScore(90), 10, 'crowd caps at 10');
-assert.strictEqual(talk._stampFor(45), 'COOKED');
-assert.strictEqual(talk._stampFor(31), 'SOLID');
-assert.strictEqual(talk._stampFor(5), 'FLOP');
-assert.strictEqual(talk._totalFor({ spice: 8, wit: 7, on_topic: 9, delivery: 6 }, 9), 33, 'total = 4 judge scores + crowd');
-assert.strictEqual(talk._totalFor({ spice: 8, wit: 7, on_topic: 9, delivery: 6, flagged: true }, 30), 0, 'flagged entries score 0');
-const h = talk._heuristicJudge('Chat, your chat is smarter than this whole arena, bet!', 'Explain why your chat is the smartest chat on this site.');
-assert.ok(h.spice >= 3 && h.on_topic >= 4 && !h.flagged, JSON.stringify(h));
-console.log('✅ slots, crowd score, stamps, totals, heuristic judge');
-
-// ── roster ──
-const mk = (u, key) => Number(db.createUser({ username: u, email: `${u}@x`, password_hash: 'x', display_name: u.toUpperCase(), stream_key: key }).lastInsertRowid);
-const u1 = mk('alpha', 'a'.repeat(32)), u2 = mk('bravo', 'b'.repeat(32)), viewer = mk('fan', 'c'.repeat(32));
-for (const uid of [u1, u2, viewer]) db.ensureChannel(uid);
-const stream = (uid, peak, hours, daysAgo) => {
- const id = Number(db.createStream({ user_id: uid, title: 't', category: 'irl', protocol: 'rtmp' }).lastInsertRowid);
- db.run(`UPDATE streams SET is_live = 0, started_at = datetime('now', ?), ended_at = datetime('now', ?), duration_seconds = ?, peak_viewers = ? WHERE id = ?`, [`-${daysAgo} days`, `-${daysAgo} days`, Math.round(hours * 3600), peak, id]);
- return id;
-};
-stream(u1, 50, 4, 1); stream(u2, 40, 4, 2);
-
-(async () => {
- const t1 = await talk.getTopic({ generate: false });
- const t2 = await talk.getTopic({ generate: false });
- assert.strictEqual(t1.id, t2.id, 'one topic per slot');
- assert.ok(t1.topic.length > 10 && t1.ends_at, 'templated topic without AI');
- console.log('✅ topic per slot');
-
- const b0 = await talk.board({ userId: viewer, generate: false });
- assert.strictEqual(b0.on_roster, false, 'a viewer is not on the roster');
- assert.strictEqual(b0.can_enter, false);
- await assert.rejects(() => talk.submit(viewer, { mode: 'text', text: 'I am the best and everyone knows it, chat.' }), /roster/);
- await assert.rejects(() => talk.submit(u1, { mode: 'text', text: 'short' }), /more than that/);
- await assert.rejects(() => talk.submit(u1, { mode: 'mic', text: '' }), /live-mic session/);
- console.log('✅ entry gating');
-
- const e1 = await talk.submit(u1, { mode: 'text', text: 'My chat solves my bugs before I finish typing them. Your chat is still loading, champ!' });
- assert.strictEqual(e1.source, 'text');
- assert.ok(e1.total > 0 && e1.scores && e1.stamp, JSON.stringify(e1));
- assert.strictEqual(e1.flagged, false);
- await assert.rejects(() => talk.submit(u1, { mode: 'text', text: 'Trying to enter the same topic twice should fail.' }), /already entered/);
- const bad = await talk.submit(u2, { mode: 'text', text: 'Your chat is full of retards and you know it, loser.' });
- assert.strictEqual(bad.flagged, true, 'slur → void');
- assert.strictEqual(bad.total, 0);
- console.log('✅ judged entries; slurs void');
-
- const before = talk.hype(e1.id, 'user:999');
- assert.strictEqual(before.added, true);
- assert.strictEqual(talk.hype(e1.id, 'user:999').added, false, 'one hype per person');
- for (let i = 0; i < 5; i++) talk.hype(e1.id, `anon:${i}`);
- const after = talk.hype(e1.id, 'user:1000');
- assert.strictEqual(after.crowd_uniques, 7);
- assert.ok(Math.abs(after.crowd - 2.3) < 0.01, `crowd 7/3 → ${after.crowd}`);
- assert.strictEqual(after.total, e1.total + after.crowd);
- assert.throws(() => talk.hype(e1.id, `user:${u1}`), /yourself/);
- assert.throws(() => talk.hype(bad.id, 'user:5'), /void/);
- console.log('✅ crowd hype');
-
- const board = await talk.board({ userId: u1, generate: false });
- assert.strictEqual(board.entries.length, 2);
- assert.strictEqual(board.entries[0].user.username, 'alpha', 'ranked by total');
- assert.strictEqual(board.entries[1].text, null, 'a flagged entry hides its text from others');
- assert.strictEqual(board.my_entry.id, e1.id);
- assert.strictEqual(board.can_enter, false, 'already entered');
- assert.ok(board.hall_of_trash.length === 1 && board.hall_of_trash[0].topic, 'hall lists non-flagged entries with their topic');
- const ownBad = await talk.board({ userId: u2, generate: false });
- assert.ok(ownBad.my_entry.text && ownBad.my_entry.note, 'the author still sees their voided text + note');
- console.log('✅ board');
-
- const bonuses = talk.talkBonuses();
- assert.ok(bonuses[u1] >= 1 && bonuses[u1] <= talk.TALK_BONUS_MAX, `bonus ${bonuses[u1]}`);
- assert.strictEqual(bonuses[u2], undefined, 'flagged entry gives no bonus');
- const roster = arena.loadRoster(true);
- assert.strictEqual(roster.byId[u1].ratings.talk_bonus, bonuses[u1]);
- assert.strictEqual(roster.byId[u1].ratings.power, roster.byId[u1].ratings.base_power + bonuses[u1], 'bonus is added to POWER');
- assert.strictEqual(roster.byId[u2].ratings.talk_bonus, 0);
- const card = await arena.getFighter('alpha', { generate: false });
- assert.strictEqual(card.trash_talk.length, 1);
- console.log('✅ POWER bonus');
-
- // ── chat commands (fake ChatServer) ──
- const sent = [], broadcast = [];
- const fakeChat = { sendTo: (ws, m) => sent.push(m.message), broadcastToStream: (sid, m) => broadcast.push(m.message) };
- const live = Number(db.createStream({ user_id: u1, title: 'live', category: 'irl', protocol: 'rtmp' }).lastInsertRowid);
- db.run('UPDATE streams SET is_live = 1 WHERE id = ?', [live]);
- const client = (user, anonId = null) => ({ user, anonId, streamId: live, ip: '1.2.3.4' });
- assert.strictEqual(chat.handle(fakeChat, {}, client(null, 'anonA'), '!sr', ['!sr']), false, 'unrelated commands fall through');
- assert.strictEqual(chat.handle(fakeChat, {}, client(null, 'anonA'), '!hype', ['!hype']), true);
- await new Promise(r => setTimeout(r, 50));
- assert.ok(/Hyped!|already hyped/.test(sent[sent.length - 1]), sent[sent.length - 1]);
- chat.handle(fakeChat, {}, client(null, 'anonB'), '!talk', ['!talk']);
- await new Promise(r => setTimeout(r, 50));
- assert.ok(/Trash Talk topic/.test(sent[sent.length - 1]));
- chat.handle(fakeChat, {}, client(db.getUserById(viewer)), '!arena', ['!arena']);
- await new Promise(r => setTimeout(r, 100));
- assert.ok(/PWR/.test(sent[sent.length - 1]), sent[sent.length - 1]);
- chat.handle(fakeChat, {}, client(null, 'anonC'), '!vote', ['!vote', 'a']);
- await new Promise(r => setTimeout(r, 50));
- assert.ok(/Sign in/.test(sent[sent.length - 1]));
- chat.handle(fakeChat, {}, client(null, 'anonD'), '!fight', ['!fight', 'bravo']);
- await new Promise(r => setTimeout(r, 50));
- assert.ok(/called out/.test(broadcast[broadcast.length - 1]), 'fight is announced to the room');
- chat.handle(fakeChat, {}, client(null, 'anonD'), '!talk', ['!talk']);
- await new Promise(r => setTimeout(r, 50));
- assert.ok(/Easy/.test(sent[sent.length - 1]), 'rate limited per person');
- console.log('✅ chat commands');
-
- console.log('\n✅ All Trash Talk tests passed');
- process.exit(0);
-})().catch(err => { console.error(err); process.exit(1); });
@@ -1,9 +1,9 @@
@@ -1,9 +1,9 @@
'use strict';
'use strict';
-// Arena regression tests: ratings are fair percentiles (incl. MIC from the transcripts),
-// battles are deterministic per day and order-independent, the crowd vote is the last
-// round, quotes come from real transcript lines with VOD links, and the roster/API works
-// end-to-end on a temp DB without any AI configured (personas fall back, no LLM calls).
+// Arena roster regression tests: ratings are fair percentiles (incl. MIC from the transcripts),
+// the behaviour-only filter blocks threats/minors/doxxing but never vocabulary, quotes come from
+// real transcript lines with VOD links, and the roster/card/stat API works end-to-end on a temp DB
+// without any AI configured (personas fall back, no LLM calls). Beefs + the board: arena-beef-board.test.js.
const assert = require('assert');
const assert = require('assert');
const fs = require('fs');
const fs = require('fs');
@@ -36,26 +36,12 @@ const solo = arena._computeRatings({ 7: { ...base } });
@@ -36,26 +36,12 @@ const solo = arena._computeRatings({ 7: { ...base } });
assert.strictEqual(solo[7].hype, 70, 'a roster of one is a flat 70 — no population to compare to');
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 (7 stats incl. MIC)');
console.log('✅ ratings are percentile-based with a floor (7 stats incl. MIC)');
-const A = { hype: 90, grind: 60, chat: 70, loyalty: 80, clutch: 50, vibe: 85, mic: 75, power: 80 };
-const B = { hype: 60, grind: 90, chat: 65, loyalty: 70, clutch: 95, vibe: 60, mic: 50, power: 70 };
-const r1 = arena._simulateRounds(A, B, '1:2:2026-08-29');
-const r2 = arena._simulateRounds(A, B, '1:2:2026-08-29');
-const r3 = arena._simulateRounds(A, B, '1:2:2026-08-30');
-assert.deepStrictEqual(r1, r2, 'same pair + same day → identical rounds');
-assert.notDeepStrictEqual(r1.map(r => r.a), r3.map(r => r.a), 'a new day rolls new rounds');
-assert.strictEqual(r1.length, 5, 'five rounds incl. Mic Drop');
-assert.strictEqual(r1[4].stat, 'mic');
-for (const r of r1) assert.ok(['a', 'b'].includes(r.winner) && r.a >= 40 && r.b >= 40);
-console.log('✅ battles are seeded per day');
-
-const w = (s) => ({ winner: s });
-assert.deepStrictEqual(arena._scoreBattle([w('a'), w('a'), w('b'), w('a'), w('b')], { a: 0, b: 0 }, 80, 70), { a: 3, b: 2, crowd: null, winner: 'a', tiebreak: null });
-const even = [w('a'), w('a'), w('b'), w('b'), w('a')];
-assert.strictEqual(arena._scoreBattle(even, { a: 0, b: 5 }, 80, 70).tiebreak, 'power');
-assert.strictEqual(arena._scoreBattle(even, { a: 0, b: 5 }, 80, 70).winner, 'a', 'power tiebreak goes to the higher Power');
-assert.strictEqual(arena._scoreBattle([w('a'), w('a'), w('b'), w('b'), w('b')], { a: 9, b: 1 }, 70, 80).winner, 'b', 'crowd adds one round: 2+1 vs 3 → 3–3 → Power (b) wins');
-assert.strictEqual(arena._scoreBattle(even, { a: 0, b: 5 }, 80, 80).winner, null, '3–3 after the crowd round with equal Power stays a draw');
-console.log('✅ crowd vote is the last round');
+// ── Filter: behaviour, not vocabulary (the platform does not censor language) ──
+for (const bad of ['kys', 'kill yourself', 'go drink bleach', 'I will kill you', "i'm gonna stab him", 'his home address is 12 elm st', 'her phone number is 555 0199', 'child porn link', 'cp links in bio', 'doxxed him already'])
+ assert.ok(arena._isBannedText(bad), `banned (behaviour): ${bad}`);
+for (const ok of ['that beaner coon nonsense', 'stop being a faggot', 'what up my niggas', "he's a retard", 'such a whore', 'pussy move', 'we got raped in that match', 'this shell script is hell', 'kill the lights', 'his address is wrong on the form? no, the URL address'])
+ assert.ok(!arena._isBannedText(ok), `allowed (speech): ${ok}`);
+console.log('✅ filter blocks threats / minors / doxxing only — slurs and profanity are speech');
// ── End-to-end on a temp DB (no AI configured → fallback personas/quotes, no LLM calls) ──
// ── End-to-end on a temp DB (no AI configured → fallback personas/quotes, no LLM calls) ──
const mk = (username, key) => Number(db.createUser({ username, email: `${username}@x`, password_hash: 'x', display_name: username.toUpperCase(), stream_key: key }).lastInsertRowid);
const mk = (username, key) => Number(db.createUser({ username, email: `${username}@x`, password_hash: 'x', display_name: username.toUpperCase(), stream_key: key }).lastInsertRowid);
@@ -85,19 +71,17 @@ for (let i = 0; i < 40; i++) {
@@ -85,19 +71,17 @@ for (let i = 0; i < 40; i++) {
db.addTimelineEvents(lines);
db.addTimelineEvents(lines);
db.addTimelineEvents([{ stream_id: s1, user_id: u1, vod_id: 901, kind: 'sound', start_sec: 30, end_sec: 33, text: null, label: 'Laughter', confidence: 0.8 }, { stream_id: s1, user_id: u1, vod_id: 901, kind: 'sound', start_sec: 90, end_sec: 93, text: null, label: 'Rock music', confidence: 0.8 }]);
db.addTimelineEvents([{ stream_id: s1, user_id: u1, vod_id: 901, kind: 'sound', start_sec: 30, end_sec: 33, text: null, label: 'Laughter', confidence: 0.8 }, { stream_id: s1, user_id: u1, vod_id: 901, kind: 'sound', start_sec: 90, end_sec: 93, text: null, label: 'Rock music', confidence: 0.8 }]);
-// Lines with slurs / hate / hard profanity must never become quotes — not even as candidates.
+// Offensive lines are fair game for quotes; threats / doxxing never are.
db.addTimelineEvents([
db.addTimelineEvents([
- { stream_id: s1, user_id: u1, vod_id: 901, kind: 'speech', start_sec: 5000, end_sec: 5010, text: "Let's go chat, you absolute f4ggots, that was insane!", label: null, confidence: 0.9 },
- { stream_id: s1, user_id: u1, vod_id: 901, kind: 'speech', start_sec: 5100, end_sec: 5110, text: 'No way, chat, that guy is such a retard!', label: null, confidence: 0.9 },
- { stream_id: s1, user_id: u1, vod_id: 901, kind: 'speech', start_sec: 5200, end_sec: 5210, text: 'Holy, that was insane, kill yourself if you disagree!', label: null, confidence: 0.9 },
+ { stream_id: s1, user_id: u1, vod_id: 901, kind: 'speech', start_sec: 5000, end_sec: 5010, text: "Let's go chat, you absolute clowns, that was insane you retards!", label: null, confidence: 0.9 },
+ { stream_id: s1, user_id: u1, vod_id: 901, kind: 'speech', start_sec: 5100, end_sec: 5110, text: 'No way, chat, that guy should kill himself, kys buddy, insane!', label: null, confidence: 0.9 },
+ { stream_id: s1, user_id: u1, vod_id: 901, kind: 'speech', start_sec: 5200, end_sec: 5210, text: 'Holy, that was insane, his home address is 12 elm street lol!', label: null, confidence: 0.9 },
]);
]);
-for (const bad of ["what up my n1ggas", "stop being a faggot", "keep it real on those faggots", "niggers", "r3tards everywhere", "he's a retard", "kys", "kill yourself", "such a whore", "pussy move", "f4gg0tz", "niggaaaa"]) assert.ok(arena._isBannedText(bad), `banned: ${bad}`);
-for (const ok of ["the shell script is hell to debug", "we dug a dyke in the sand? no — a dike, the engineering kind"]) { /* documented: broad filter may catch "dyke" — acceptable */ }
-assert.ok(!arena._isBannedText("the shell script is hell to debug and I hate it"), 'ordinary sentences pass');
const cands = arena._quoteCandidates(u1);
const cands = arena._quoteCandidates(u1);
assert.ok(cands.length >= 20, 'has candidates');
assert.ok(cands.length >= 20, 'has candidates');
-assert.ok(cands.every(c => !arena._isBannedText(c.text)), 'no banned line is ever a candidate');
-console.log('✅ slurs/hate never surface as quotes');
+assert.ok(cands.some(c => c.text.includes('retards')), 'offensive vocabulary is not filtered out of quotes');
+assert.ok(cands.every(c => !/kys|home address/.test(c.text)), 'threats / doxxing never become quotes');
+console.log('✅ quote candidates: speech stays, behaviour goes');
const voice = arena._voiceStatsFor(u1, '-90 days');
const voice = arena._voiceStatsFor(u1, '-90 days');
assert.strictEqual(voice.has_data, true);
assert.strictEqual(voice.has_data, true);
@@ -115,10 +99,12 @@ assert.deepStrictEqual(fighters.map(f => f.user.username), ['alpha', 'bravo', 'c
@@ -115,10 +99,12 @@ assert.deepStrictEqual(fighters.map(f => f.user.username), ['alpha', 'bravo', 'c
assert.strictEqual(fighters[0].rank, 1);
assert.strictEqual(fighters[0].rank, 1);
assert.strictEqual(fighters[0].ratings.mic, 99, 'the only one with transcripts tops MIC');
assert.strictEqual(fighters[0].ratings.mic, 99, 'the only one with transcripts tops MIC');
assert.strictEqual(fighters[1].ratings.mic, fighters[2].ratings.mic, 'no-transcript fighters tie on MIC (shared rank)');
assert.strictEqual(fighters[1].ratings.mic, fighters[2].ratings.mic, 'no-transcript fighters tie on MIC (shared rank)');
-assert.ok(fighters[1].ratings.mic < fighters[0].ratings.mic);
assert.ok(fighters[0].persona.fighter_name, 'fallback persona has a name even with AI off');
assert.ok(fighters[0].persona.fighter_name, 'fallback persona has a name even with AI off');
assert.strictEqual(fighters[0].persona_is_fallback, true);
assert.strictEqual(fighters[0].persona_is_fallback, true);
assert.strictEqual(fighters[0].voice.has_data, true);
assert.strictEqual(fighters[0].voice.has_data, true);
+assert.deepStrictEqual(fighters[0].record, { wins: 0, losses: 0, draws: 0 }, 'beef record starts empty');
+assert.deepStrictEqual(fighters[0].level, { level: 1, xp: 0 }, 'Trash Level starts at 1');
+assert.strictEqual(fighters[0].ratings.talk_bonus, 0, 'no mouth bonus without XP');
console.log('✅ roster from real stream/analytics/transcript rows, inactive streamers excluded');
console.log('✅ roster from real stream/analytics/transcript rows, inactive streamers excluded');
(async () => {
(async () => {
@@ -131,10 +117,10 @@ console.log('✅ roster from real stream/analytics/transcript rows, inactive str
@@ -131,10 +117,10 @@ console.log('✅ roster from real stream/analytics/transcript rows, inactive str
assert.strictEqual(card.image_generation, 'off', 'no AI → no image generation');
assert.strictEqual(card.image_generation, 'off', 'no AI → no image generation');
assert.ok(card.quotes && card.quotes.picks.length >= 3, 'heuristic quotes without AI');
assert.ok(card.quotes && card.quotes.picks.length >= 3, 'heuristic quotes without AI');
assert.ok(card.quotes.picks[0].vod_id === 901 && typeof card.quotes.picks[0].start_sec === 'number', 'quotes link to the VOD second');
assert.ok(card.quotes.picks[0].vod_id === 901 && typeof card.quotes.picks[0].start_sec === 'number', 'quotes link to the VOD second');
- assert.ok(card.quotes.picks[0].text.includes("Let's go") || card.quotes.picks[0].text.includes('insane'), 'the spiciest line ranks first without AI');
assert.strictEqual(card.quotes._fallback, true);
assert.strictEqual(card.quotes._fallback, true);
- assert.deepStrictEqual(card.recent_battles, []);
- assert.strictEqual(card.rivalry, null);
+ assert.deepStrictEqual(card.beefs, []);
+ assert.strictEqual(card.active_topic, null);
+ assert.strictEqual(card.level.level, 1);
const off = await arena.getFighter('idle');
const off = await arena.getFighter('idle');
assert.strictEqual(off.not_on_roster, true);
assert.strictEqual(off.not_on_roster, true);
console.log('✅ fighter card + transcript quotes');
console.log('✅ fighter card + transcript quotes');
@@ -149,48 +135,14 @@ console.log('✅ roster from real stream/analytics/transcript rows, inactive str
@@ -149,48 +135,14 @@ console.log('✅ roster from real stream/analytics/transcript rows, inactive str
assert.strictEqual(arena.getStatDetail(u1, 'nope'), null);
assert.strictEqual(arena.getStatDetail(u1, 'nope'), null);
console.log('✅ stat drill-down');
console.log('✅ stat drill-down');
- const b1 = await arena.getBattle('alpha', 'bravo');
- const b2 = await arena.getBattle('bravo', 'alpha');
- assert.strictEqual(b1.id, b2.id, 'a-vs-b and b-vs-a are the same battle');
- assert.strictEqual(b1.rounds.length, 5);
- assert.ok(b1.commentary.intro && b1.commentary.rounds.length === 5, 'templated commentary without AI');
- assert.strictEqual(b1.commentary_is_fallback, true);
- assert.strictEqual(b1.votes.a + b1.votes.b, 0);
- assert.ok(b1.a.walkout && b1.a.walkout.vod_id === 901, 'walkout line comes from the transcript');
- assert.strictEqual(b1.a.quotes, undefined, 'full quote list is not shipped with the battle');
- assert.deepStrictEqual(b1.history, { fights: 1, a_wins: b1.outcome.winner === 'a' ? 1 : 0, b_wins: b1.outcome.winner === 'b' ? 1 : 0 });
-
- const v1 = arena.castVote(b1.id, 'user:99', 'b');
- assert.deepStrictEqual(v1.votes, { a: 0, b: 1 });
- 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 + head-to-head history');
-
- const rec = arena.listFighters().find(f => f.user.username === after.winner.username);
- assert.strictEqual(rec.record.wins, 1, 'winner gets the W on the leaderboard');
- const profile = await arena.getFighter('alpha', { generate: false });
- assert.strictEqual(profile.recent_battles.length, 1);
- assert.strictEqual(profile.recent_battles[0].opponent.username, 'bravo');
- const me = await arena.getMainEvent({ generate: false });
- assert.ok(me && me.a && me.b && me.a.user.id !== me.b.user.id, 'main event pairs two different top fighters');
- const me2 = await arena.getMainEvent({ generate: false });
- assert.strictEqual(me.id, me2.id, 'main event is the same all day');
- const live = arena.getLiveMatchups();
- assert.strictEqual(live.live_count, 0);
+ assert.deepStrictEqual(arena.liveFighters(), [], 'nobody live');
const st = arena.status();
const st = arena.status();
assert.strictEqual(st.roster, 3);
assert.strictEqual(st.roster, 3);
assert.strictEqual(st.with_voice_data, 1);
assert.strictEqual(st.with_voice_data, 1);
- assert.ok(st.battles >= 1);
- assert.strictEqual(st.votes, 2);
- assert.strictEqual(st.rounds.length, 5);
- console.log('✅ records, recent fights, main event, live matchups, status');
+ assert.strictEqual(st.beefs_open, 0);
+ assert.strictEqual(st.listener, 15000, 'listener tick advertised');
+ console.log('✅ live fighters + status');
- console.log('\n✅ All Arena tests passed');
+ console.log('\n✅ All Arena roster tests passed');
process.exit(0);
process.exit(0);
})().catch(err => { console.error(err); process.exit(1); });
})().catch(err => { console.error(err); process.exit(1); });