openvibers/openvibe.live Commits

Arena v4: ARENA ALERT — a drama desk fed by what streamers do (rage quits, viewer crashes and spikes, chat explosions, meltdowns and tears heard on the timeline, marathons, ghost towns, comebacks, title drama, records, beefs), reactions, the Lolcow Index, Clout Rankings with weekly moves, rap sheets and an opt-out (#145)

OP OpenVibers committed 28 days ago · 3234e5ab35 · 12 files, +1024 −99

Changes

ddiff --git a/docs/arena.md b/docs/arena.md
iindex 73158833..c420c931 100644
---- a/docs/arena.md
++++ b/docs/arena.md
@@@ -1,16 +1,18 @@
--# Arena — Battle Cam (pure mic)
++# Arena — ARENA ALERT (v4, 2026-10-16)
--The **Arena** tab (`/arena`) is streamer-vs-streamer shit talk with exactly one input: **what fighters say into the microphone**. The continuous audio transcription of every live cam (`stream_timeline_events`, see the AI timeline) is read every 15 seconds and judged. Nobody clicks "fight", nobody votes, nothing typed in chat counts. There is no board, no topics, no chat levels, no bounties, no check-ins — those were removed in v3 (2026-09-10) because they were not mic.
++The **Arena** tab (`/arena`) is the site's drama desk and competitive board, in the owner's words "drama alert type competitive shit … based on streamer activity". It replaced the mic-only Battle Cam home (v3, 2026-09-10): the mic still feeds it (beefs, the judged lines), and now so does everything else streamers measurably do.
--- **Callouts → beefs** — a fighter says another fighter's name while talking shit → the ears lock on, the judge scores it, the beef opens by itself, the target goes on the clock; silence is a forfeit. The mic judge can also open a beef without the name being in the transcript, when it decides the shit talk is clearly *aimed at* a roster fighter.
--- **The feed** — everything else that is actually shit talk (at chat, the mods, other platforms, the game, the world) is judged, scored 0–10, VOD-linked and lands in the live feed. It pays Trash Level XP. Gameplay narration and small talk score nothing.
--- **The ladder** — every fighter is rated on seven mic-only stats, as percentiles across the roster, plus an AI persona and portrait written from their transcripts.
++- **ARENA ALERT** (`server/arena/drama.js`, `public/js/arena-alert.js`) — every 5 min the desk reads the last 3 hours of activity and writes one story per event (`arena_drama`, deduped): **rage quits** (a stream over in < 15 min from someone who usually goes 45+), **viewer crashes** (halved within 20 min, from 3+) and **spikes** (doubled, to 4+), **chat explosions** (3× the stream's usual rate, 15+ in 5 min), **meltdowns** and **tears** (3+ yells/screams/shouts, or sobs, within 2 min on the sound timeline, quoted with what was said around them), **marathons** (8 h+), **ghost towns** (an hour+ to 0-1 viewers and no chat), **comebacks** (14+ days away), **title drama**, **new personal peaks**; once a day **fell off** (4+ streams the month before, none for 7 days), **no days off** (every 7 days in a row) and the **quote of the day**; beefs report their opening, win and forfeit. A story gets a template headline at once (facts and numbers only) and an AI rewrite from OpenVibe.AI's `live.arena.drama` (≤ 20 an hour): drama-news voice, roast what happened on stream, never invent an event or accuse, no identity slurs, sexual content, threats, doxxing or self-harm jokes; quotes are the streamer's own words. Alert level 1-5 (🚨). A subject with alert ≥ 3 is notified.
++- **Reactions** — 🔥 💀 🤡 W L, one per viewer per story (`arena_drama_reactions`; the person, else a salted address hash).
++- **Lolcow Index** — cow points from stories over the last 7 days (newer counts more; rage quits, crashes, meltdowns, tears, ghost towns, dramatic titles, falling off, ducking a beef), 0-100 against the top cow, who wears the 🐄.
++- **Clout Rankings** — the last 7 days: hours live ×2 + average viewers ×10 + peak ×3 + chat messages ÷ 50 + new followers ×5 + the clout points of their stories (spikes, records, comebacks, streaks, beef wins) + beef wins ×10 − half their cow points. This week's moves against the snapshot taken each Monday (`arena_clout_weeks`); tags COOKED (cow meter 70+), ON THE RISE, FALLING, UNDEFEATED.
++- **Rap sheet** (`/arena/<username>`, above the fighter page) — cow meter, clout rank, beef W–L, every story about them.
++- **Opting out** — `PUT /api/arena/opt-out {out}` (the "keep themselves out" link): no new stories about them, the old ones hidden; they can come back. Staff hide a story with `POST /api/arena/drama/:id/hide`.
++- **Beefs and the ears** (below) are unchanged: a name said on mic opens a beef, the other side is on the clock, silence is a forfeit.
--**No personas, no portraits, no AI ring names** (removed 2026-09-11): fighters are the streamers, under their own names and avatars. The only AI on this page is the judge.
++API: `GET /api/arena/drama?limit&before&user`, `/drama/:id`, `POST /drama/:id/react {reaction}`, `GET /lolcow`, `/clout`, `/rap/:user`, `GET|PUT /opt-out`. Pages: `/arena`, `/arena/drama/<id>`, `/arena/<username>`, `/arena/beef/<id>`, `/arena/live/<username>`.
--**Backfill** (`server/arena/backfill.js`): the listener only hears live cams, so past speech (last 7 days, ended streams) is judged by the job every 20 min (and 90 s after boot): chunks of ≥ 20 words, only the ones that look spicy or name a fighter go to a model (≤ 160 calls per run), name-drops become `callout` moments with the target attached (no clocks for old speech), the rest `trash` moments — dated when they were *said*, VOD-linked. A per-fighter cursor keeps it incremental. Admin: `POST /api/arena/backfill`.
--
--With AI off everything still works: keyword judges, template headlines.
++The transcripts all of this reads come from Parakeet TDT 0.6B v3 since 2026-10-16 (`server/ai/transcribe.js`; whisper for other languages).
## Speech policy
@@@ -30,7 +32,7 @@ The roster is **whoever has been heard**: every streamer with transcribed speech
| **Stamina** | minutes of speech heard (90 d) | 0.10 |
| **Pace** | words per minute on mic | 0.08 |
--POWER = weighted sum + a **mouth bonus** (≤ +12: recent XP ÷ 25 + 3 per beef win in 7 days). Audience numbers (viewers, chat volume, followers, clips, tips) do not exist anywhere in the Arena.
++POWER = weighted sum + a **mouth bonus** (≤ +12: recent XP ÷ 25 + 3 per beef win in 7 days). These mic ratings (the fighter page's POWER) use no audience numbers; the Clout Rankings above are where viewers, chat and followers count.
**Persona** (`chat` role, 24 h TTL): fighter name, title, class, element, moves, weakness, `taunt` + three `taunts` (ragebait in *their speaking voice*), `typing_style` (how they talk), `spoken_as` (nicknames/mishearings, fed to name detection), six `custom_stats` unique to them and `stat_quips` — written from their transcript lines, their best judged shit talk, who they call out, who calls them out and what they rant at. **Quotes**: AI-picked lines from the transcripts, VOD-linked. **Portraits**: the ones drawn earlier from their own stream frames stay; no new ones are drawn (the image provider was called with the shared key, and Live calls no model provider itself since roadmap WS-O task 2).
@@@ -69,10 +71,11 @@ Every hit is also written to the mic ledger as a `beef_hit` moment. Headlines (o
## Pages
--`/arena` — Battle Cam: your beefs on the clock · **live cams** (thumbnail, lock indicator, last judged line, open beefs, pending words) · open + settled beefs · **the feed** (every judged line, newest first, aimed-at chips, ▶ to the VOD second, 🔊 in their voice) · the ladder (rows expand: the seven mic stats radar, taunts, quips) · rules.
++`/arena` — ARENA ALERT: masthead and BREAKING ticker · the cams on air · the top story of the day · BREAKING (every story, reactions, ▶ to the moment, 🔊 the quote in their voice) · Lolcow Index · Clout Rankings · Beef Watch · Hot Mic (the latest judged lines) · how the desk works.
++`/arena/drama/<id>` — one story with its receipts (the measured facts) and more on the same streamer.
`/arena/beef/<id>` — tale of the tape, tug-of-war, live clock, ringside feed, receipts, hype buttons.
`/arena/live/<username>` — the ears.
--`/arena/<username>` — POWER, Trash Level card (judged lines, avg score, bangers, answered-when-called-out), the seven mic stats with drill-downs, their lines on record, characteristics radar, rap sheet, ragebait bubbles, on the mic (voice meters + quotes), rivalries, beefs.
++`/arena/<username>` — the rap sheet (cow meter, clout rank, beef W–L, their stories), then POWER, Trash Level card (judged lines, avg score, bangers, answered-when-called-out), the seven mic stats with drill-downs, their lines on record, characteristics radar, rap sheet, ragebait bubbles, on the mic (voice meters + quotes), rivalries, beefs.
Old `/arena/topic/*` and `/arena/chatter/*` links show a "this part is gone" note.
ddiff --git a/migrations/0008_arena_drama.sql b/migrations/0008_arena_drama.sql
nnew file mode 100644
iindex 00000000..c1f0a067
---- /dev/null
++++ b/migrations/0008_arena_drama.sql
@@@ -0,0 +1,58 @@
++-- phase: expand
++-- Arena v4 (2026-10-16, the owner: "drama alert type competitive shit … based on streamer activity"): the Arena Alert
++-- desk's stories, viewers' reactions to them, the weekly Clout Rankings and Lolcow Index snapshots, and the streamers
++-- who keep themselves out (server/arena/drama.js).
++--
++-- A story is one measured event (a rage quit, a viewer crash, a meltdown heard on mic, a beef verdict…): its facts as
++-- measured, a template headline at once and an AI rewrite later, how much it moves the Lolcow Index (cow_points) and
++-- the Clout Rankings (clout_points). dedupe_key keeps one story per event however often the detectors run.
++CREATE TABLE IF NOT EXISTS arena_drama (
++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
++ kind text COLLATE "C" NOT NULL,
++ user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE,
++ other_user_id bigint REFERENCES users(id) ON DELETE SET NULL,
++ stream_id bigint,
++ vod_id bigint,
++ sec bigint,
++ dedupe_key text COLLATE "C" NOT NULL UNIQUE,
++ facts_json text COLLATE "C" NOT NULL DEFAULT '{}',
++ quote text COLLATE "C",
++ headline text COLLATE "C" NOT NULL,
++ blurb text COLLATE "C",
++ alert integer NOT NULL DEFAULT 1 CHECK (alert BETWEEN 1 AND 5),
++ cow_points double precision NOT NULL DEFAULT 0,
++ clout_points double precision NOT NULL DEFAULT 0,
++ ai integer NOT NULL DEFAULT 0,
++ status text COLLATE "C" NOT NULL DEFAULT 'live' CHECK (status IN ('live', 'hidden')),
++ happened_at text COLLATE "C" NOT NULL,
++ created_at text COLLATE "C" DEFAULT ov_now()
++);
++CREATE INDEX IF NOT EXISTS idx_arena_drama_recent ON arena_drama (status, happened_at DESC, id DESC);
++CREATE INDEX IF NOT EXISTS idx_arena_drama_user ON arena_drama (user_id, happened_at DESC);
++CREATE INDEX IF NOT EXISTS idx_arena_drama_other ON arena_drama (other_user_id);
++
++-- One reaction per viewer per story (voter_key as arena_beef_hype: the person, else a salted address hash).
++CREATE TABLE IF NOT EXISTS arena_drama_reactions (
++ drama_id bigint NOT NULL REFERENCES arena_drama(id) ON DELETE CASCADE,
++ voter_key text COLLATE "C" NOT NULL,
++ reaction text COLLATE "C" NOT NULL CHECK (reaction IN ('fire', 'skull', 'clown', 'w', 'l')),
++ created_at text COLLATE "C" DEFAULT ov_now(),
++ PRIMARY KEY (drama_id, voter_key)
++);
++
++-- The rankings as they stood at the start of each week (Monday, UTC), so this week's moves can be shown.
++CREATE TABLE IF NOT EXISTS arena_clout_weeks (
++ week text COLLATE "C" NOT NULL,
++ user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE,
++ clout_rank integer NOT NULL,
++ clout double precision NOT NULL,
++ cow double precision NOT NULL,
++ facts_json text COLLATE "C",
++ PRIMARY KEY (week, user_id)
++);
++
++-- Streamers who keep themselves out of the Arena: no story is written about them and their old ones are hidden.
++CREATE TABLE IF NOT EXISTS arena_opt_outs (
++ user_id bigint PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
++ created_at text COLLATE "C" DEFAULT ov_now()
++);
ddiff --git a/public/css/features/arena-alert.css b/public/css/features/arena-alert.css
nnew file mode 100644
iindex 00000000..976c6782
---- /dev/null
++++ b/public/css/features/arena-alert.css
@@@ -0,0 +1,130 @@
++/* ARENA ALERT — the Arena's drama desk (public/js/arena-alert.js). Tabloid red and siren yellow over the site theme. */
++.aa { --aa-red: #e11d48; --aa-red-deep: #9f1239; --aa-yellow: #facc15; --aa-ink: #0b0b0f; padding-bottom: 32px; }
++
++/* Masthead + ticker */
++.aa-mast { margin: 18px 0 18px; padding: 18px 20px 0; border-radius: 18px; overflow: hidden; color: #fff;
++ background: linear-gradient(135deg, var(--aa-red) 0%, var(--aa-red-deep) 70%, #4c0519 100%); box-shadow: 0 10px 30px rgba(225, 29, 72, 0.25); }
++.aa-mast-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
++.aa-title { margin: 0; font-size: clamp(2rem, 6vw, 3.6rem); font-weight: 900; letter-spacing: -0.03em; line-height: 0.95; text-transform: uppercase; font-style: italic; }
++.aa-title span { color: var(--aa-yellow); text-shadow: 0 2px 0 var(--aa-ink); }
++.aa-siren { display: inline-block; animation: aa-siren 1s steps(2, end) infinite; }
++@keyframes aa-siren { 50% { filter: hue-rotate(200deg) brightness(1.3); transform: rotate(-8deg) scale(1.08); } }
++.aa-onair { display: inline-flex; align-items: center; gap: 8px; padding: 6px 12px; border-radius: 999px; background: rgba(0, 0, 0, 0.35); font-weight: 800; font-size: 0.8rem; letter-spacing: 0.08em; }
++.aa-sub { margin: 10px 0 14px; max-width: 760px; font-size: 1.02rem; color: rgba(255, 255, 255, 0.9); }
++.aa-ticker { display: flex; align-items: stretch; margin: 0 -20px; background: var(--aa-yellow); color: var(--aa-ink); font-weight: 800; font-size: 0.92rem; }
++.aa-ticker-label { flex: none; padding: 9px 14px; background: var(--aa-ink); color: var(--aa-yellow); letter-spacing: 0.1em; font-style: italic; }
++.aa-ticker-viewport { overflow: hidden; flex: 1; min-width: 0; mask-image: linear-gradient(90deg, transparent, #000 24px, #000 calc(100% - 24px), transparent); }
++.aa-ticker-track { display: inline-flex; gap: 42px; padding: 9px 0; white-space: nowrap; animation: aa-crawl 60s linear infinite; }
++.aa-ticker:hover .aa-ticker-track { animation-play-state: paused; }
++@keyframes aa-crawl { from { transform: translateX(0); } to { transform: translateX(-50%); } }
++.aa-tick { text-transform: uppercase; }
++
++/* Section headings */
++.aa-h { margin: 0 0 12px; font-size: 1.05rem; font-weight: 900; letter-spacing: 0.06em; text-transform: uppercase; font-style: italic; }
++.aa-h small { margin-left: 6px; font-weight: 600; font-style: normal; letter-spacing: 0; text-transform: none; color: var(--text-secondary); font-size: 0.8rem; }
++.aa-feed > .aa-h { display: inline-block; padding: 4px 12px; background: var(--aa-red); color: #fff; border-radius: 6px; transform: skew(-8deg); }
++
++/* Layout */
++.aa-cams { margin: 0 0 18px; }
++.aa-top { margin: 0 0 22px; }
++.aa-grid { display: grid; grid-template-columns: minmax(0, 1fr) 360px; gap: 22px; align-items: start; }
++.aa-side { display: flex; flex-direction: column; gap: 16px; position: sticky; top: 70px; }
++.aa-box { padding: 16px; border-radius: 16px; background: var(--bg-card); border: 1px solid var(--border); }
++.aa-quiet { color: var(--text-secondary); font-size: 0.92rem; margin: 0; }
++.aa-quiet-big { padding: 28px 20px; border: 2px dashed var(--border); border-radius: 16px; text-align: center; }
++.aa-more { display: block; margin: 14px auto 0; }
++
++/* Story cards */
++.aa-story { position: relative; margin: 0 0 14px; padding: 14px 16px; border-radius: 16px; background: var(--bg-card); border: 1px solid var(--border); border-left: 5px solid var(--border-light); }
++.aa-story.aa-alert-3 { border-left-color: var(--aa-yellow); }
++.aa-story.aa-alert-4, .aa-story.aa-alert-5 { border-left-color: var(--aa-red); box-shadow: 0 0 0 1px color-mix(in srgb, var(--aa-red) 35%, transparent); }
++.aa-story-big { padding: 22px; border-left-width: 8px; background-image: radial-gradient(80% 60% at 100% 0%, color-mix(in srgb, var(--aa-red) 16%, transparent), transparent); }
++.aa-story-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 10px; }
++.aa-badge { padding: 3px 9px; border-radius: 6px; background: var(--aa-ink); color: var(--aa-yellow); font-size: 0.72rem; font-weight: 900; letter-spacing: 0.08em; text-transform: uppercase; font-style: italic; }
++.aa-kind-beef_open .aa-badge, .aa-kind-beef_win .aa-badge, .aa-kind-beef_forfeit .aa-badge { background: var(--aa-red); color: #fff; }
++.aa-kind-viewer_spike .aa-badge, .aa-kind-peak_record .aa-badge, .aa-kind-streak .aa-badge, .aa-kind-the_return .aa-badge { background: #15803d; color: #fff; }
++.aa-sirens { font-size: 0.85rem; letter-spacing: 1px; }
++.aa-time { margin-left: auto; color: var(--text-secondary); font-size: 0.8rem; }
++.aa-story-body { display: flex; gap: 14px; align-items: flex-start; }
++.aa-who { display: flex; align-items: center; gap: 6px; flex: none; }
++.aa-vs { font-weight: 900; font-style: italic; font-size: 0.75rem; color: var(--aa-red); }
++.aa-av { display: inline-flex; width: 44px; height: 44px; border-radius: 12px; overflow: hidden; background: var(--bg-secondary, #222); align-items: center; justify-content: center; font-weight: 900; color: var(--text-primary); text-decoration: none; border: 2px solid var(--border); }
++.aa-av img { width: 100%; height: 100%; object-fit: cover; }
++.aa-story-big .aa-av { width: 72px; height: 72px; border-radius: 16px; border-color: var(--aa-yellow); }
++.aa-row .aa-av { width: 32px; height: 32px; border-radius: 9px; }
++.aa-story-text { min-width: 0; flex: 1; }
++.aa-headline { margin: 0 0 6px; font-size: 1.15rem; line-height: 1.2; font-weight: 900; letter-spacing: -0.01em; text-transform: uppercase; overflow-wrap: anywhere; }
++.aa-headline a { color: var(--text-primary); text-decoration: none; }
++.aa-headline a:hover { color: var(--aa-red); }
++.aa-story-big .aa-headline { font-size: clamp(1.4rem, 3.4vw, 2.2rem); }
++.aa-blurb { margin: 0 0 8px; color: var(--text-secondary); overflow-wrap: anywhere; }
++.aa-story-big .aa-blurb { font-size: 1.05rem; color: var(--text-primary); }
++.aa-quote { display: flex; align-items: flex-start; gap: 8px; margin: 6px 0 8px; padding: 10px 12px; border-radius: 10px; background: color-mix(in srgb, var(--aa-yellow) 12%, transparent); border-left: 3px solid var(--aa-yellow); font-style: italic; font-weight: 600; overflow-wrap: anywhere; }
++.aa-quote span::before { content: '“'; } .aa-quote span::after { content: '”'; }
++.aa-byline { margin: 0; font-size: 0.85rem; color: var(--text-secondary); }
++.aa-name { font-weight: 800; color: var(--text-primary); text-decoration: none; }
++.aa-name:hover { color: var(--aa-red); }
++.aa-story-foot { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: 10px; }
++.aa-react { display: inline-flex; gap: 6px; flex-wrap: wrap; }
++.aa-react-btn { display: inline-flex; align-items: center; gap: 5px; min-height: 34px; padding: 4px 10px; border-radius: 999px; border: 1px solid var(--border); background: transparent; color: var(--text-primary); font-weight: 800; cursor: pointer; transition: transform 0.12s, border-color 0.12s, background 0.12s; }
++.aa-react-btn:hover { border-color: var(--aa-red); }
++.aa-react-btn.is-mine { background: var(--aa-red); border-color: var(--aa-red); color: #fff; }
++.aa-react-btn.is-pop { transform: scale(1.18); }
++.aa-react-n { font-variant-numeric: tabular-nums; font-size: 0.85rem; }
++.aa-watch { font-size: 0.85rem; font-weight: 700; }
++.aa-ai { margin-left: auto; font-size: 0.7rem; font-weight: 800; letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-muted); }
++
++/* Boards */
++.aa-board { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
++.aa-row { display: grid; grid-template-columns: 28px 32px minmax(0, 1fr) auto; gap: 10px; align-items: center; }
++.aa-rank { font-weight: 900; text-align: center; color: var(--text-secondary); }
++.aa-rank.is-crown { font-size: 1.3rem; }
++.aa-row-main { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
++.aa-row-main .aa-name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
++.aa-row-sub { font-size: 0.75rem; color: var(--text-secondary); overflow-wrap: anywhere; }
++.aa-score { font-weight: 900; font-variant-numeric: tabular-nums; }
++.aa-meter { display: block; height: 7px; border-radius: 99px; background: var(--border); overflow: hidden; }
++.aa-meter span { display: block; height: 100%; width: var(--m); background: repeating-linear-gradient(135deg, #f5f5f4 0 8px, #1c1917 8px 16px); }
++.aa-move { display: inline-block; margin-left: 6px; font-size: 0.72rem; font-weight: 900; color: var(--text-secondary); }
++.aa-move.is-up { color: #16a34a; } .aa-move.is-down { color: var(--aa-red); } .aa-move.is-new { color: var(--aa-yellow); }
++.aa-tags { display: flex; flex-wrap: wrap; gap: 4px; }
++.aa-tag { padding: 1px 6px; border-radius: 5px; font-size: 0.64rem; font-weight: 900; letter-spacing: 0.05em; background: var(--border); color: var(--text-primary); }
++.aa-tag-cooked { background: var(--aa-red); color: #fff; }
++.aa-tag-on-the-rise, .aa-tag-undefeated { background: #15803d; color: #fff; }
++.aa-tag-falling { background: #78350f; color: #fff; }
++.aa-mic, .aa-beefs { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
++.aa-mic q { font-style: italic; font-weight: 600; overflow-wrap: anywhere; }
++.aa-beefs li { display: flex; align-items: center; justify-content: space-between; gap: 8px; flex-wrap: wrap; }
++.aa-settled { margin: 12px 0 6px; text-transform: uppercase; letter-spacing: 0.06em; font-weight: 800; }
++.aa-link { padding: 0; border: 0; background: none; color: var(--accent); font: inherit; text-decoration: underline; cursor: pointer; }
++
++/* Rap sheet, receipts, how it works */
++.aa-rap { margin: 16px 0; }
++.aa-rap-stats { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; margin-bottom: 10px; }
++.aa-rap-stats div { padding: 10px; border-radius: 12px; background: color-mix(in srgb, var(--aa-red) 8%, transparent); text-align: center; }
++.aa-rap-stats b { display: block; font-size: 1.5rem; font-weight: 900; }
++.aa-rap-stats span { font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-secondary); }
++.aa-facts dl { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 10px; margin: 0; }
++.aa-facts dt { font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-secondary); }
++.aa-facts dd { margin: 2px 0 0; font-weight: 800; overflow-wrap: anywhere; }
++.aa-how { margin-top: 26px; padding: 18px 20px; border-radius: 16px; border: 1px solid var(--border); }
++.aa-how ul { margin: 0; padding-left: 18px; color: var(--text-secondary); }
++.aa-how li { margin-bottom: 8px; }
++.aa-how b { color: var(--text-primary); }
++
++@media (max-width: 960px) {
++ .aa-grid { grid-template-columns: 1fr; }
++ .aa-side { position: static; }
++}
++@media (max-width: 560px) {
++ .aa-mast { padding: 14px 14px 0; border-radius: 14px; }
++ .aa-ticker { margin: 0 -14px; }
++ .aa-story { padding: 12px; }
++ .aa-story-body { gap: 10px; }
++ .aa-av { width: 36px; height: 36px; border-radius: 10px; }
++ .aa-story-big .aa-av { width: 52px; height: 52px; border-radius: 12px; }
++ .aa-who { flex-direction: column; }
++ .aa-time { margin-left: 0; }
++ .aa-rap-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
++}
++@media (prefers-reduced-motion: reduce) { .aa-siren, .aa-ticker-track { animation: none; } }
ddiff --git a/public/features.json b/public/features.json
iindex 8aac6c54..d584486b 100644
---- a/public/features.json
++++ b/public/features.json
@@@ -245,10 +245,12 @@
},
"arena": {
"css": [
-- "/css/features/arena.css"
++ "/css/features/arena.css",
++ "/css/features/arena-alert.css"
],
"js": [
-- "/js/arena.js"
++ "/js/arena.js",
++ "/js/arena-alert.js"
]
},
"recap": {
ddiff --git a/public/js/arena-alert.js b/public/js/arena-alert.js
nnew file mode 100644
iindex 00000000..483c8466
---- /dev/null
++++ b/public/js/arena-alert.js
@@@ -0,0 +1,233 @@
++/**
++ * OpenVibe.Live — Arena v4: ARENA ALERT, the drama desk (server/arena/drama.js)
++ *
++ * Routes (from loadArenaPage in arena.js, which also owns the shared helpers _aEsc, _aA, _aAgo…):
++ * /arena the desk: masthead + ticker, the top story, BREAKING, Lolcow Index, Clout Rankings,
++ * Beef Watch, Hot Mic, the cams on air
++ * /arena/drama/<id> one story
++ * /arena/<username> the rap sheet on top of the fighter page
++ *
++ * Every story comes from something measured on the site (a stream's length, its viewer and chat snapshots, sounds and
++ * speech on its timeline, a beef's verdict); headlines may be AI-written from those facts. All text goes through _aEsc.
++ */
++'use strict';
++
++const AA_KIND = {
++ rage_quit: ['RAGE QUIT', '🚪'], viewer_crash: ['VIEWER CRASH', '📉'], viewer_spike: ['BLOWING UP', '📈'], chat_explosion: ['CHAT GOES FERAL', '💬'],
++ meltdown: ['MELTDOWN', '😤'], tears: ['TEARS', '😭'], marathon: ['MARATHON', '⏰'], ghost_town: ['GHOST TOWN', '👻'], the_return: ['THE RETURN', '👀'],
++ title_drama: ['TITLE DRAMA', '🍿'], peak_record: ['NEW PEAK', '🏆'], fell_off: ['FELL OFF?', '🕳️'], streak: ['NO DAYS OFF', '🔥'],
++ quote_of_day: ['QUOTE OF THE DAY', '🎤'], beef_open: ['BEEF', '🥩'], beef_win: ['COOKED', '🍳'], beef_forfeit: ['DUCKED', '🐔'],
++};
++const AA_REACT = [['fire', '🔥'], ['skull', '💀'], ['clown', '🤡'], ['w', 'W'], ['l', 'L']];
++let _aaTickerTop = 0;
++
++function _aaUser(u) { return u ? _aA(_aFighterLink(u), _aEsc(u.display_name || u.username), 'aa-name') : ''; }
++function _aaAvatar(u, size = 40) {
++ if (!u) return '';
++ const inner = u.avatar_url ? `<img src="${_aEsc(u.avatar_url)}" alt="" loading="lazy" width="${size}" height="${size}">` : `<span>${_aEsc(_aInitial(u))}</span>`;
++ return _aA(_aFighterLink(u), inner, 'aa-av', u.display_name || u.username);
++}
++function _aaSirens(n) { return `<span class="aa-sirens" aria-label="alert level ${n} of 5">${'🚨'.repeat(Math.max(1, Math.min(5, n)))}</span>`; }
++function _aaWatch(s) {
++ if (s.vod_id) return _aA(`/vod/${s.vod_id}?t=${Math.max(0, Math.floor(s.sec || 0))}`, '<i class="fa-solid fa-play"></i> Watch it happen', 'aa-watch');
++ if (s.user && s.kind !== 'fell_off') return _aA(_aChannelLink(s.user), '<i class="fa-solid fa-tower-broadcast"></i> Their channel', 'aa-watch');
++ return '';
++}
++function _aaReactions(s) {
++ return `<div class="aa-react" data-story="${s.id}" role="group" aria-label="React">${AA_REACT.map(([k, label]) => `<button type="button" class="aa-react-btn${s.my_reaction === k ? ' is-mine' : ''}" data-react="${k}" aria-pressed="${s.my_reaction === k}"><span class="aa-react-i">${label}</span><span class="aa-react-n">${_aNum(s.reactions?.[k] || 0)}</span></button>`).join('')}</div>`;
++}
++function _aaStory(s, { big = false } = {}) {
++ const [badge, icon] = AA_KIND[s.kind] || [String(s.kind || '').toUpperCase(), '🚨'];
++ return `<article class="aa-story aa-alert-${s.alert} aa-kind-${_aEsc(s.kind)}${big ? ' aa-story-big' : ''}" id="aa-story-${s.id}">
++ <div class="aa-story-head"><span class="aa-badge">${icon} ${_aEsc(badge)}</span>${_aaSirens(s.alert)}<time class="aa-time" datetime="${_aEsc(s.happened_at)}">${_aEsc(_aAgo(s.happened_at))}</time></div>
++ <div class="aa-story-body">
++ <div class="aa-who">${_aaAvatar(s.user, big ? 72 : 44)}${s.other ? `<span class="aa-vs">VS</span>${_aaAvatar(s.other, big ? 72 : 44)}` : ''}</div>
++ <div class="aa-story-text">
++ <h3 class="aa-headline">${_aA(`/arena/drama/${s.id}`, _aEsc(s.headline))}</h3>
++ ${s.blurb ? `<p class="aa-blurb">${_aEsc(s.blurb)}</p>` : ''}
++ ${s.quote ? `<blockquote class="aa-quote"><span>${_aEsc(s.quote)}</span> ${_aSpeakBtn(s.quote, 'arena-speak', s.user?.username)}</blockquote>` : ''}
++ <p class="aa-byline">${_aaUser(s.user)}${s.other ? ` · ${_aaUser(s.other)}` : ''}</p>
++ </div>
++ </div>
++ <div class="aa-story-foot">${_aaReactions(s)}${_aaWatch(s)}${s.ai ? '<span class="aa-ai" title="Headline written by AI from the measured facts">AI desk</span>' : ''}</div>
++ </article>`;
++}
++function _aaTicker(stories) {
++ if (!stories.length) return '';
++ const items = stories.slice(0, 12).map((s) => `<span class="aa-tick">${_aEsc((AA_KIND[s.kind] || ['', '🚨'])[1])} ${_aEsc(s.headline)}</span>`).join('');
++ return `<div class="aa-ticker" aria-label="Latest headlines"><span class="aa-ticker-label">BREAKING</span><div class="aa-ticker-viewport"><div class="aa-ticker-track">${items}${items}</div></div></div>`;
++}
++function _aaCow(index) {
++ if (!index.length) return `<p class="aa-quiet">Nobody has been cooked this week. Yet.</p>`;
++ return `<ol class="aa-board">${index.slice(0, 10).map((c, i) => `<li class="aa-row">
++ <span class="aa-rank${i === 0 ? ' is-crown' : ''}">${i === 0 ? '🐄' : i + 1}</span>${_aaAvatar(c.user, 32)}
++ <span class="aa-row-main">${_aaUser(c.user)}<span class="aa-meter" style="--m:${c.meter}%"><span></span></span><span class="aa-row-sub">${Object.entries(c.kinds).map(([k, n]) => `${_aEsc((AA_KIND[k] || [k])[0].toLowerCase())}${n > 1 ? ` ×${n}` : ''}`).join(' · ')}</span></span>
++ <span class="aa-score" title="Cow points this week">${c.meter}</span></li>`).join('')}</ol>`;
++}
++function _aaMove(c) {
++ if (c.move == null) return '<span class="aa-move is-new">NEW</span>';
++ if (c.move > 0) return `<span class="aa-move is-up">▲${c.move}</span>`;
++ if (c.move < 0) return `<span class="aa-move is-down">▼${-c.move}</span>`;
++ return '<span class="aa-move">–</span>';
++}
++function _aaClout(rankings) {
++ if (!rankings.length) return `<p class="aa-quiet">Nobody streamed this week.</p>`;
++ return `<ol class="aa-board">${rankings.slice(0, 12).map((c) => `<li class="aa-row">
++ <span class="aa-rank">${c.rank}</span>${_aaAvatar(c.user, 32)}
++ <span class="aa-row-main">${_aaUser(c.user)} ${_aaMove(c)}<span class="aa-row-sub">${c.facts.hours}h live · peak ${c.facts.peak} · ${_aNum(c.facts.messages)} chat${c.facts.beef_wins ? ` · ${c.facts.beef_wins} beef W` : ''}</span>${c.tags.length ? `<span class="aa-tags">${c.tags.map((t) => `<span class="aa-tag aa-tag-${_aEsc(t.toLowerCase().replace(/\s+/g, '-'))}">${_aEsc(t)}</span>`).join('')}</span>` : ''}</span>
++ <span class="aa-score" title="Clout this week">${_aNum(c.clout)}</span></li>`).join('')}</ol>`;
++}
++function _aaHotMic(feed) {
++ if (!feed.length) return `<p class="aa-quiet">The ears have not caught anything spicy yet.</p>`;
++ return `<ul class="aa-mic">${feed.slice(0, 8).map((m) => `<li><q>${_aEsc(m.text)}</q> <span class="aa-row-sub">${m.user ? _aaUser(m.user) : ''}${m.aimed_at ? ` → ${_aEsc(m.aimed_at)}` : ''} · ${_aQ(m.quality)} ${_aPlay(m.vod_id, m.sec)}</span></li>`).join('')}</ul>`;
++}
++function _aaBeefs(beefs) {
++ const open = beefs.open || [], done = (beefs.resolved || []).slice(0, 3);
++ const line = (b) => `<li>${_aA(_aBeefLink(b), `<b>${_aEsc(b.a.user.display_name || b.a.user.username)}</b> <span class="aa-vs">VS</span> <b>${_aEsc(b.b.user.display_name || b.b.user.username)}</b>`)} ${b.status === 'open' ? _aClockTag(b) : `<span class="aa-row-sub">${b.winner_user_id ? `${_aEsc(((b.winner_user_id === b.a.user.id ? b.a : b.b).user.display_name) || (b.winner_user_id === b.a.user.id ? b.a : b.b).user.username)} won` : 'draw'}</span>`}</li>`;
++ return `${open.length ? `<ul class="aa-beefs">${open.map(line).join('')}</ul>` : '<p class="aa-quiet">No open beef. One name said on mic changes that.</p>'}${done.length ? `<p class="aa-row-sub aa-settled">Settled</p><ul class="aa-beefs">${done.map(line).join('')}</ul>` : ''}`;
++}
++function _aaBindReactions(root) {
++ root.querySelectorAll('.aa-react').forEach((group) => {
++ if (group.dataset.bound) return;
++ group.dataset.bound = '1';
++ group.addEventListener('click', async (e) => {
++ const btn = e.target.closest('.aa-react-btn');
++ if (!btn) return;
++ const id = group.dataset.story;
++ try {
++ const r = await api(`/arena/drama/${id}/react`, { method: 'POST', body: { reaction: btn.dataset.react } });
++ group.querySelectorAll('.aa-react-btn').forEach((b) => {
++ const mine = b === btn;
++ b.classList.toggle('is-mine', mine);
++ b.setAttribute('aria-pressed', String(mine));
++ const n = b.querySelector('.aa-react-n');
++ if (n) n.textContent = _aNum(r.reactions?.[b.dataset.react] || 0);
++ });
++ btn.classList.add('is-pop');
++ setTimeout(() => btn.classList.remove('is-pop'), 400);
++ } catch (err) { _aToast(err?.message || 'Could not react', 'error'); }
++ });
++ });
++ _aBindSpeak(root);
++}
++
++async function _aRenderAlertHome(root) {
++ root.innerHTML = _aSpinner('Checking the timeline for drama…');
++ const safe = (p, d) => p.catch(() => d);
++ const [drama, cow, clout, beefs, live, feed] = await Promise.all([
++ api('/arena/drama?limit=30'), safe(api('/arena/lolcow'), { index: [] }), safe(api('/arena/clout'), { rankings: [] }),
++ safe(api('/arena/beefs'), { open: [], recent: [] }), safe(api('/arena/live'), { live: [] }), safe(api('/arena/feed?limit=12'), { feed: [] }),
++ ]);
++ const stories = drama.stories || [];
++ const dayAgo = Date.now() - 86400000;
++ const top = [...stories].filter((s) => new Date(String(s.happened_at).replace(' ', 'T') + 'Z').getTime() >= dayAgo).sort((a, b) => b.alert - a.alert || b.id - a.id)[0] || stories[0] || null;
++ _aaTickerTop = stories[0]?.id || 0;
++ const rest = stories.filter((s) => !top || s.id !== top.id);
++ root.innerHTML = `<div class="aa">
++ <header class="aa-mast">
++ <div class="aa-mast-row">
++ <h1 class="aa-title"><span class="aa-siren" aria-hidden="true">🚨</span> ARENA <span>ALERT</span></h1>
++ <span class="aa-onair"><span class="arena-live-dot arena-live-dot-sm"></span> ${(live.live || []).length ? `${live.live.length} ON AIR` : 'WATCHING'}</span>
++ </div>
++ <p class="aa-sub">Rage quits, meltdowns, viewer crashes, beefs and L's from every stream on OpenVibe. Measured on the site, timestamped, then roasted.</p>
++ ${_aaTicker(stories)}
++ </header>
++ ${(live.live || []).length ? `<section class="arena-live arena-cams aa-cams" id="arena-live">${_aRenderCams(live.live)}</section>` : ''}
++ ${top ? `<section class="aa-top" aria-label="Top story">${_aaStory(top, { big: true })}</section>` : ''}
++ <div class="aa-grid">
++ <section class="aa-feed" aria-labelledby="aa-feed-h">
++ <h2 id="aa-feed-h" class="aa-h">BREAKING</h2>
++ <div id="aa-feed-list">${rest.length ? rest.map((s) => _aaStory(s)).join('') : (top ? '' : `<div class="aa-quiet aa-quiet-big"><b>The desk is listening.</b> The next rage quit, viewer crash or meltdown on any stream lands here within five minutes.</div>`)}</div>
++ ${stories.length >= 30 ? `<button type="button" class="btn aa-more" id="aa-more" data-before="${stories[stories.length - 1].id}">Older drama</button>` : ''}
++ </section>
++ <aside class="aa-side">
++ <section class="aa-box aa-box-cow" aria-labelledby="aa-cow-h"><h2 id="aa-cow-h" class="aa-h">🐄 Lolcow Index <small>this week</small></h2>${_aaCow(cow.index || [])}</section>
++ <section class="aa-box aa-box-clout" aria-labelledby="aa-clout-h"><h2 id="aa-clout-h" class="aa-h">📈 Clout Rankings <small>last 7 days</small></h2>${_aaClout(clout.rankings || [])}</section>
++ <section class="aa-box" aria-labelledby="aa-beef-h"><h2 id="aa-beef-h" class="aa-h">🥩 Beef Watch</h2>${_aaBeefs(beefs)}</section>
++ <section class="aa-box" aria-labelledby="aa-mic-h"><h2 id="aa-mic-h" class="aa-h">🎤 Hot Mic</h2>${_aaHotMic(feed.feed || [])}</section>
++ </aside>
++ </div>
++ <section class="aa-how">
++ <h2 class="aa-h">How the desk works</h2>
++ <ul>
++ <li><b>Measured, not made up.</b> Every five minutes the desk reads what happened on every stream: how long it lasted, its viewer and chat counts minute by minute, what the mic heard (speech and sounds like yelling or crying), titles, records and beefs. Each story states those numbers.</li>
++ <li><b>Lolcow Index.</b> Rage quits, viewer crashes, meltdowns, tears, ghost-town streams, dramatic titles, falling off and losing beefs add cow points (newer counts more). The top cow wears the crown.</li>
++ <li><b>Clout Rankings.</b> Hours live, viewers, chat, new followers, records, comebacks and beef wins over the last 7 days, with this week's moves. COOKED means your cow meter is 70+.</li>
++ <li><b>Beefs.</b> Call another streamer out on mic and the beef opens itself; they are on the clock. Silence is a forfeit, and a forfeit is a story.</li>
++ <li><b>React.</b> 🔥 💀 🤡 W or L, one per story. Streamers who would rather not be covered can ${_aMe() ? `<button type="button" class="aa-link" id="aa-optout">keep themselves out of the Arena</button>` : 'sign in and keep themselves out of the Arena'}.</li>
++ </ul>
++ </section>
++ </div>`;
++ _aaBindReactions(root);
++ _aBindHome(root);
++ document.getElementById('aa-more')?.addEventListener('click', async (e) => {
++ const btn = e.currentTarget;
++ btn.disabled = true;
++ try {
++ const older = await api(`/arena/drama?limit=30&before=${encodeURIComponent(btn.dataset.before)}`);
++ const list = document.getElementById('aa-feed-list');
++ list.insertAdjacentHTML('beforeend', (older.stories || []).map((s) => _aaStory(s)).join(''));
++ _aaBindReactions(root);
++ if ((older.stories || []).length < 30) btn.remove(); else { btn.dataset.before = older.stories[older.stories.length - 1].id; btn.disabled = false; }
++ } catch { btn.disabled = false; }
++ });
++ document.getElementById('aa-optout')?.addEventListener('click', _aaOptOutDialog);
++ _aEvery(30000, async () => {
++ try {
++ const d = await api('/arena/drama?limit=10');
++ const fresh = (d.stories || []).filter((s) => s.id > _aaTickerTop);
++ if (!fresh.length) return;
++ _aaTickerTop = d.stories[0].id;
++ const list = document.getElementById('aa-feed-list');
++ if (list) { list.insertAdjacentHTML('afterbegin', fresh.map((s) => _aaStory(s)).join('')); _aaBindReactions(root); _aFlash(list, `${fresh.length} new stor${fresh.length === 1 ? 'y' : 'ies'}`); }
++ } catch { /* keep the last render */ }
++ });
++ _aEvery(1000, () => _aTickClocks(root));
++}
++
++async function _aaOptOutDialog() {
++ try {
++ const cur = await api('/arena/opt-out');
++ const out = !cur.opted_out;
++ const ok = confirm(out ? 'Keep yourself out of the Arena? No new stories will be written about you and your old ones come down. You can come back any time.' : 'Come back into the Arena? New stories about your streams will be written again.');
++ if (!ok) return;
++ await api('/arena/opt-out', { method: 'PUT', body: { out } });
++ _aToast(out ? 'You are out of the Arena.' : 'Welcome back to the Arena.', 'success');
++ loadArenaPage(['arena']);
++ } catch (err) { _aToast(err?.message || 'Could not change that', 'error'); }
++}
++
++async function _aRenderStory(root, id) {
++ root.innerHTML = _aSpinner('Pulling the receipts…');
++ const s = await api(`/arena/drama/${encodeURIComponent(id)}`);
++ const more = s.user ? await api(`/arena/drama?limit=6&user=${encodeURIComponent(s.user.username)}`).catch(() => ({ stories: [] })) : { stories: [] };
++ const facts = Object.entries(s.facts || {}).filter(([k, v]) => v != null && typeof v !== 'object' && k !== 'other');
++ root.innerHTML = `<div class="aa">
++ <div class="arena-back">${_aA('/arena', '<i class="fa-solid fa-arrow-left"></i> ARENA ALERT')}</div>
++ ${_aaStory(s, { big: true })}
++ ${facts.length ? `<section class="aa-box aa-facts"><h2 class="aa-h">The receipts</h2><dl>${facts.map(([k, v]) => `<div><dt>${_aEsc(k.replace(/_/g, ' '))}</dt><dd>${_aEsc(String(v))}</dd></div>`).join('')}</dl></section>` : ''}
++ ${(more.stories || []).filter((x) => x.id !== s.id).length ? `<section class="aa-feed"><h2 class="aa-h">More on ${_aEsc(s.user.display_name || s.user.username)}</h2>${more.stories.filter((x) => x.id !== s.id).map((x) => _aaStory(x)).join('')}</section>` : ''}
++ </div>`;
++ _aaBindReactions(root);
++}
++
++/** The rap sheet: shown above the fighter page at /arena/<username>. */
++async function _aRenderRapSheet(el, username) {
++ if (!el) return;
++ let r;
++ try { r = await api(`/arena/rap/${encodeURIComponent(username)}`); } catch { el.innerHTML = ''; return; }
++ if (r.opted_out) { el.innerHTML = `<div class="aa-box aa-quiet">${_aEsc(r.user.display_name || r.user.username)} keeps themselves out of the Arena.</div>`; return; }
++ const counts = Object.entries(r.counts || {}).sort((a, b) => b[1] - a[1]);
++ el.innerHTML = `<section class="aa-rap aa-box">
++ <h2 class="aa-h">📁 Rap sheet</h2>
++ <div class="aa-rap-stats">
++ <div><b>${r.cow ? r.cow.meter : 0}</b><span>cow meter</span></div>
++ <div><b>${r.clout ? `#${r.clout.rank}` : '–'}</b><span>clout rank</span></div>
++ <div><b>${r.record.w}–${r.record.l}</b><span>beefs W–L</span></div>
++ <div><b>${r.stories.length}</b><span>stories</span></div>
++ </div>
++ ${counts.length ? `<p class="aa-row-sub">${counts.map(([k, n]) => `${_aEsc((AA_KIND[k] || [k])[0].toLowerCase())} ×${n}`).join(' · ')}</p>` : '<p class="aa-quiet">Clean record. For now.</p>'}
++ ${r.stories.slice(0, 5).map((s) => _aaStory(s)).join('')}
++ </section>`;
++ _aaBindReactions(el);
++}
ddiff --git a/public/js/arena.js b/public/js/arena.js
iindex 681b506e..828cfd9c 100644
---- a/public/js/arena.js
++++ b/public/js/arena.js
@@@ -1,8 +1,10 @@
/**
-- * OpenVibe.Live — Arena tab — BATTLE CAM mode (pure mic)
++ * OpenVibe.Live — Arena tab. The home is ARENA ALERT, the drama desk (public/js/arena-alert.js, Arena v4); this file
++ * keeps the shared helpers, beefs, the ears and the fighter pages.
*
* Routes (handled from app.js):
-- * /arena live cams · the shit-talk feed · beefs · the ladder
++ * /arena ARENA ALERT (arena-alert.js)
++ * /arena/drama/<id> one story (arena-alert.js)
* /arena/beef/<id> one beef: tug-of-war, clock, ringside feed, receipts
* /arena/live/<username> the ears: what the listener hears from a live fighter (auto-refresh)
* /arena/<username> fighter profile (mic stats, voice + quotes, level, receipts, beefs, rivalries)
@@@ -152,93 +154,20 @@ async function loadArenaPage(segments = []) {
try {
if (first === 'beef' && second) return await _aRenderBeef(root, Number(second));
if (first === 'live' && second) return await _aRenderConsole(root, second);
++ if (first === 'drama' && second) return await _aRenderStory(root, second);
if (first === 'topic' || first === 'chatter') return _aGone(root);
-- if (first) return await _aRenderFighter(root, first);
-- return await _aRenderHome(root);
++ if (first) {
++ root.innerHTML = '<div id="aa-rap-slot"></div><div id="arena-fighter-slot"></div>';
++ await Promise.all([_aRenderRapSheet(document.getElementById('aa-rap-slot'), first), _aRenderFighter(document.getElementById('arena-fighter-slot'), first)]);
++ return;
++ }
++ return await _aRenderAlertHome(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 _aGone(root) {
-- root.innerHTML = `<div class="arena-empty"><i class="fa-solid fa-microphone-lines"></i><p>That part of the Arena is gone. It's <b>pure mic</b> now — no boards, no topics, no chat levels. Everything that counts is said on stream.</p>${_aA('/arena', 'Back to the cams', 'btn btn-primary')}</div>`;
--}
--
--// ── Home: Battle Cam ─────────────────────────────────────────
--
--async function _aRenderHome(root) {
-- root.innerHTML = _aSpinner('Putting the ears on…');
-- const [feed, beefs, live, roster] = await Promise.all([api('/arena/feed?limit=40').catch(() => ({ feed: [] })), api('/arena/beefs'), api('/arena/live').catch(() => ({ live: [] })), api('/arena/fighters')]);
-- _arenaRoster = roster;
-- if (Array.isArray(roster.stats) && roster.stats.length) { ARENA_STATS = roster.stats; ARENA_STAT_LABEL = Object.fromEntries(roster.stats.map(k => [k, roster.stat_meta?.[k]?.label || k])); }
-- _arenaFeedTop = feed.feed?.[0]?.id || 0;
-- root.innerHTML = `
-- <div class="arena-hero arena-hero-cam">
-- <div>
-- <h1><i class="fa-solid fa-microphone-lines"></i> Battle Cam</h1>
-- <p class="arena-lede">Pure mic. No personas, no AI characters, no chat points, no votes. This page is the shit talk that was actually said on stream — judged, scored, and replayable at the second it happened. Call another streamer out on mic and the beef opens itself; they're on the clock, silence is a forfeit. Talk shit at chat, at the mods, at the world, and it lands in the feed and levels you up.</p>
-- </div>
-- <div class="arena-hero-actions">
-- <span class="arena-note">${roster.ai ? '<i class="fa-solid fa-ear-listen"></i> AI judge on' : '<i class="fa-solid fa-ear-deaf"></i> AI off — keyword judging'}</span>
-- ${_aMe() ? _aA('/broadcast', '<i class="fa-solid fa-tower-broadcast"></i> Get on a cam', 'btn btn-primary') : ''}
-- </div>
-- </div>
-- <section class="arena-me" id="arena-me">${_aMe() ? _aSpinner('Checking your beefs…') : `<div class="arena-me-guest"><i class="fa-solid fa-microphone"></i> <b>Sign in</b>, go live with transcription on, and the Arena hears you. Say a name. See what happens.</div>`}</section>
-- <section class="arena-live arena-cams" id="arena-live">${_aRenderCams(live.live || [])}</section>
-- <section class="arena-beefs" id="arena-beefs">${_aRenderBeefs(beefs)}</section>
-- <section class="arena-feed-section" id="arena-feed">${_aRenderFeed(feed.feed || [])}</section>
-- <section class="arena-leaderboard">
-- <div class="arena-board-head">
-- <h2><i class="fa-solid fa-ranking-star"></i> The ladder <small>${roster.fighters.length} fighters · every number from the mic</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 Battle Cam works</h3>
-- <ul>
-- <li><b>The ears.</b> Every 15 s the Arena reads the live transcription of every fighter's cam. That is the only input. Chat can <code>!hype</code> a side of a beef and that is all chat can do.</li>
-- <li><b>Callouts → beefs.</b> Say another fighter's name (however the mic hears it) while talking shit → the ears lock on, the judge scores it, the beef opens and they're on the clock: <b>15 min if live, 24 h if not</b>. Every answer flips the clock. Silence is a forfeit. Best mouth after 24 h wins. Beat someone ranked 4+ spots above you → <b>upset</b>.</li>
-- <li><b>Free shit talk.</b> Not aimed at a fighter? Still judged. Ranting at chat, the mods, other platforms, the game — if it's actually shit talk it lands in the feed with a score and pays Trash Level XP. Gameplay narration and small talk score nothing.</li>
-- <li><b>The ladder.</b> Seven ratings, all mic: <b>Heat</b> (how good the shit talk is) · <b>Aim</b> (callouts per hour on mic) · <b>Kills</b> (beefs won) · <b>Mouth</b> (share of stream spent talking) · <b>Clapback</b> (answering when called out) · <b>Stamina</b> (minutes of speech) · <b>Pace</b> (words per minute). Percentiles across the roster → POWER, plus a mouth bonus for recent XP and wins.</li>
-- <li><b>Language.</b> Nothing is censored for being offensive. The only lines that don't count: real threats, anything sexual about minors, and doxxing.</li>
-- </ul>
-- </section>`;
-- _aRenderList(roster.fighters);
-- _aBindHome(root);
-- if (_aMe()) _aRenderMe().catch(() => { const el = document.getElementById('arena-me'); if (el) el.innerHTML = ''; });
-- document.getElementById('arena-search')?.addEventListener('input', (e) => {
-- const q = e.target.value.trim().toLowerCase();
-- _aRenderList(!q ? roster.fighters : roster.fighters.filter(f => [f.user.display_name, f.user.username].filter(Boolean).some(s => s.toLowerCase().includes(q))));
-- });
-- _aEvery(12000, async () => {
-- try {
-- const [b, l, fd] = await Promise.all([api('/arena/beefs'), api('/arena/live'), api('/arena/feed?limit=40')]);
-- const beefsEl = document.getElementById('arena-beefs'), liveEl = document.getElementById('arena-live'), feedEl = document.getElementById('arena-feed');
-- if (beefsEl) beefsEl.innerHTML = _aRenderBeefs(b);
-- if (liveEl) liveEl.innerHTML = _aRenderCams(l.live || []);
-- if (feedEl) { const top = fd.feed?.[0]?.id || 0; feedEl.innerHTML = _aRenderFeed(fd.feed || [], { fresh: top > _arenaFeedTop ? fd.feed.filter(m => m.id > _arenaFeedTop).length : 0 }); if (top > _arenaFeedTop) _aFlash(feedEl, 'new line on the mic'); _arenaFeedTop = top; }
-- _aBindHome(root);
-- } catch { /* keep the last render */ }
-- });
-- _aEvery(1000, () => _aTickClocks(root));
--}
--
--async function _aRenderMe() {
-- const el = document.getElementById('arena-me');
-- if (!el) return;
-- const me = await api('/arena/me');
-- if (!me.on_roster) { el.innerHTML = `<div class="arena-me-guest"><i class="fa-solid fa-microphone-slash"></i> You're not on the roster yet — the Arena only knows what the transcription hears. Go live with audio transcription on and say something worth judging.</div>`; return; }
-- const f = me.fighter, l = f.level, pct = l.xp_per_level ? Math.round((l.xp_into_level / l.xp_per_level) * 100) : 0;
-- el.innerHTML = `<div class="arena-me-inner">
-- <div class="arena-me-left">${_aPortrait(f, 'sm')}<div><div class="arena-me-name"><b>${_aEsc(f.fighter_name)}</b> <span class="arena-lvl">TRASH LVL ${l.level}</span> <span class="arena-note">#${f.rank} · PWR ${f.power} · ${f.record.wins}W–${f.record.losses}L</span></div>
-- <span class="arena-xp-track"><span class="arena-xp-fill" style="width:${pct}%"></span></span>
-- <small class="arena-note">${l.xp_into_level}/${l.xp_per_level} to level ${l.level + 1} · <b>+${l.recent_xp} XP</b> this week · ${f.mic.moments} judged lines (30d) · avg ${f.mic.avg_quality}/10</small></div></div>
-- <div class="arena-me-right">
-- ${me.on_clock.length ? me.on_clock.map(b => `<div class="arena-me-alert">${_aA(_aBeefLink(b), `<i class="fa-solid fa-stopwatch"></i> <b>${_aEsc((b.a.user.id === f.user.id ? b.b : b.a).fighter_name)}</b> called you out — answer on your own cam`)} ${_aClockTag(b)}</div>`).join('') : `<div class="arena-me-row arena-note"><i class="fa-solid fa-circle-check"></i> Nobody has you on the clock.</div>`}
-- ${me.moments.length ? `<div class="arena-me-row"><i class="fa-solid fa-quote-left"></i> last judged: <q>${_aEsc(me.moments[0].text)}</q> ${_aQ(me.moments[0].quality)}</div>` : ''}
-- <div class="arena-me-row">${_aA(_aFighterLink(f.user), 'your fighter page <i class="fa-solid fa-arrow-right"></i>', 'arena-subject-open')} ${me.live ? _aA(_aConsoleLink(f.user), '<i class="fa-solid fa-ear-listen"></i> your ears', 'arena-tag arena-tag-hot') : ''}</div>
-- </div>
-- </div>`;
++ root.innerHTML = `<div class="arena-empty"><i class="fa-solid fa-microphone-lines"></i><p>That part of the Arena is gone. The drama is on ARENA ALERT now.</p>${_aA('/arena', 'Back to ARENA ALERT', 'btn btn-primary')}</div>`;
}
function _aRenderCams(live) {
ddiff --git a/server/arena/arena-job.js b/server/arena/arena-job.js
iindex 2776f6c0..bfb66755 100644
---- a/server/arena/arena-job.js
++++ b/server/arena/arena-job.js
@@@ -39,11 +39,17 @@ async function start() {
_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 (backfill of past speech every 20 min; listener every 15 s; beef clocks every 60 s) — Battle Cam mode: pure mic');
++ console.log('[Arena] job started (drama desk every 5 min; backfill of past speech every 20 min; listener every 15 s; beef clocks every 60 s)');
}
++let _lastDrama = 0;
async function housekeeping() {
if (!await arena.arenaEnabled()) return;
++ // The drama desk (drama.js): every 5 min, read what streamers did and write the stories.
++ if (Date.now() - _lastDrama >= 5 * 60 * 1000) {
++ _lastDrama = Date.now();
++ try { const r = await require('./drama').detect(); if (r.created) console.log(`[Arena] drama desk: ${r.created} new stor${r.created === 1 ? 'y' : 'ies'}`); } catch (e) { console.warn('[Arena] drama desk:', e.message); }
++ }
// Clocks only: forfeit beefs whose clock ran out, hard-end the ones past 24 h. There is no chat
// scan, no discovery and no lore — the Arena is pure mic (listener.js).
try { await require('./beef').tick(); } catch (e) { console.warn('[Arena] beef tick:', e.message); }
ddiff --git a/server/arena/beef.js b/server/arena/beef.js
iindex 09a39171..169d5bb8 100644
---- a/server/arena/beef.js
++++ b/server/arena/beef.js
@@@ -89,6 +89,7 @@ async function recordHit(speakerId, targetId, hit) {
console.log(`[Arena] beef #${beef.id} opened: user ${speakerId} → user ${targetId}${history.fights ? ' (rematch)' : ''}`);
try { require('./notify').arenaNotify(targetId, { type: 'beef_open', title: `${await nameOf(speakerId)} has words for you${history.fights ? ' — rematch' : ''}`, message: `${hit.best_line ? `“${String(hit.best_line).slice(0, 120)}” — ` : ''}answer on your own stream within ${await isLive(targetId) ? `${RESPONSE_LIVE_MIN} min` : `${RESPONSE_OFFLINE_HOURS} h`} or forfeit.`, icon: '🥊', url: `/arena/beef/${beef.id}`, key: `open:${beef.id}`, senderId: speakerId, senderName: await nameOf(speakerId) }); } catch { /* */ }
const id = beef.id;
++ require('./drama').beefStory('open', { id, a_user_id: speakerId, b_user_id: targetId }, { about: hit.about, opening_line: hit.best_line }).catch(() => {});
headlineFor('open', { a: await nameOf(speakerId), b: await nameOf(targetId), opening_line: hit.best_line, about: hit.about, rematch: history.fights > 0, record_between: history }).then(async h => { if (h) await db.run('UPDATE arena_beefs SET headline = ? WHERE id = ?', [h, id]); }).catch(() => {});
}
const side = beef.a_user_id === speakerId ? 'a' : 'b';
@@@ -135,6 +136,10 @@ async function resolve(beef, resolution, winnerId) {
if (winnerId) await mic().addXp(winnerId, XP_BEEF_WIN + (upset ? 20 : 0), upset ? 'beef_upset_win' : 'beef_win', beef.id);
console.log(`[Arena] beef #${beef.id} resolved: ${resolution}${winnerId ? ` winner user ${winnerId}` : ' draw'}${upset ? ' UPSET' : ''}`);
try { const n = require('./notify'); for (const uid of [beef.a_user_id, beef.b_user_id]) { const won = winnerId === uid, draw = winnerId == null; n.arenaNotify(uid, { type: 'beef_over', title: draw ? `Draw with ${await nameOf(uid === beef.a_user_id ? beef.b_user_id : beef.a_user_id)}` : won ? `You won the beef with ${await nameOf(loserId)}${upset ? ' — UPSET' : ''}` : `${await nameOf(winnerId)} won the beef${resolution === 'forfeit' ? ' — you ran out the clock' : ' on points'}`, message: won ? `+${XP_BEEF_WIN + (upset ? 20 : 0)} XP. ${t.a}–${t.b}.` : `${t.a}–${t.b}. Say their name on mic for a rematch.`, icon: won ? '🏆' : draw ? '🤝' : '💀', url: `/arena/beef/${beef.id}`, key: `over:${beef.id}` }); } } catch { /* */ }
++ if (winnerId && loserId) {
++ const ws = winnerId === beef.a_user_id ? t.a : t.b, ls = winnerId === beef.a_user_id ? t.b : t.a;
++ require('./drama').beefStory(resolution === 'forfeit' ? 'forfeit' : 'win', beef, { winnerId, loserId, scoreWinner: ws, scoreLoser: ls, upset: !!upset }).catch(() => {});
++ }
const id = beef.id;
headlineFor(resolution, ctx).then(async h => { if (h) await db.run('UPDATE arena_beefs SET result_headline = ? WHERE id = ?', [h, id]); }).catch(() => {});
try { await arena().loadRoster(true); } catch { /* */ }
ddiff --git a/server/arena/drama.js b/server/arena/drama.js
nnew file mode 100644
iindex 00000000..a5cb1939
---- /dev/null
++++ b/server/arena/drama.js
@@@ -0,0 +1,405 @@
++/**
++ * drama.js — ARENA ALERT, the Arena's drama-news desk (Arena v4, 2026-10-16; the owner: "drama alert type competitive
++ * shit … based on streamer activity").
++ *
++ * Detectors read what streamers actually did and turn each event into a story (arena_drama, one per event by
++ * dedupe_key). Every 5 min (arena-job.js) over the last 3 hours:
++ *
++ * rage_quit a stream over in under 15 min from someone who usually goes 45+
++ * viewer_crash viewers halve within 20 min (from 3+) viewer_spike viewers double within 20 min (to 4+)
++ * chat_explosion chat 3x the stream's usual rate (15+ in 5 min) meltdown / tears 3+ yells, screams, shouts or sobs
++ * heard within 2 min (the sound timeline), with what they said around it
++ * marathon 8+ hours live ghost_town an hour or more to 0-1 viewers, no chat
++ * the_return live again after 14+ days away title_drama a title that is a cry for help
++ * peak_record a new personal viewer peak (3+)
++ * and once a day: fell_off (4+ streams the month before, none for 7 days), streak (every 7 days in a row live),
++ * quote_of_day (the best judged mic line). Beefs report themselves (beef.js → beefStory).
++ *
++ * A story is written at once from a template (grounded, already spicy) and rewritten by OpenVibe.AI's
++ * live.arena.drama in the desk's voice (headline, blurb, alert 1-5) when AI is on, at most AI_PER_HOUR an hour.
++ * Each story moves two scores: cow_points (the Lolcow Index: rage quits, crashes, meltdowns, L's) and clout_points
++ * (the Clout Rankings: spikes, records, marathons, W's). Streamers in arena_opt_outs get no stories.
++ *
++ * detect({ now }) run every detector; returns { created }
++ * beefStory(kind, beef, ctx) a beef opened / won / forfeited
++ * feed({ limit, before, user, voterKey }) stories, newest first, with reaction counts (and the viewer's)
++ * react(id, reaction, voterKey) one reaction per viewer per story (changing it replaces it)
++ * lolcowIndex({ days }) · cloutRankings() the two boards; rankings carry this week's moves
++ * rapSheet(userId) one streamer's dossier
++ * setOptOut(userId, out) keep a streamer out (and hide their stories) or let them back in
++ */
++'use strict';
++
++const db = require('../db/database');
++
++const AI_PER_HOUR = 20;
++const WINDOW_MS = 3 * 3600 * 1000;
++const REACTIONS = ['fire', 'skull', 'clown', 'w', 'l'];
++const RAGE_SOUNDS = new Set(['Yell', 'Screaming', 'Shout', 'Bellow', 'Battle cry', 'Groan', 'Wail, moan']);
++const TEAR_SOUNDS = new Set(['Crying, sobbing', 'Whimper']);
++const TITLE_DRAMA = /\b(quit(?:ting)?|i'?m done|done with|last stream|retir(?:e|ing|ed)|drama|exposed|expos(?:e|ing)|banned|apolog(?:y|ize|ise)|beef|rigged|scam(?:med)?|cancel+ed|rant|meltdown|crash(?:ing)? out|rage|tilted|hate (?:this|my)|worst (?:day|stream))\b/i;
++
++const toMs = (s) => { if (!s) return NaN; const t = String(s); return Date.parse(t.includes('T') ? t : `${t.replace(' ', 'T')}Z`); };
++const fmt = (ms) => new Date(ms).toISOString().slice(0, 19).replace('T', ' ');
++const day = (ms) => new Date(ms).toISOString().slice(0, 10);
++const median = (xs) => { const a = xs.filter((x) => Number.isFinite(x)).sort((p, q) => p - q); return a.length ? a[Math.floor(a.length / 2)] : 0; };
++const arena = () => require('./arena-service');
++
++const _names = new Map();
++async function nameOf(userId) {
++ if (_names.has(userId)) return _names.get(userId);
++ const u = await db.get('SELECT username, display_name FROM users WHERE id = ?', [userId]);
++ const n = u ? (u.display_name || u.username) : `user ${userId}`;
++ if (_names.size > 2000) _names.clear();
++ _names.set(userId, n);
++ return n;
++}
++async function optedOut(userId) { return !!await db.get('SELECT 1 FROM arena_opt_outs WHERE user_id = ?', [userId]); }
++
++// ── Templates: the story before (or without) AI. Facts only, numbers stated. ──
++const T = {
++ rage_quit: (n, f) => [`🚨 ${n} RAGE QUIT? Stream over after ${f.minutes_live} minutes`, `${n} usually goes ${f.usual_minutes} minutes. Today it lasted ${f.minutes_live}.${f.title ? ` The title was "${f.title}".` : ''}`, 3, 14, 0],
++ viewer_crash: (n, f) => [`📉 ${n}'S VIEWERS ABANDON SHIP: ${f.from} → ${f.to} in ${f.minutes} minutes`, `${f.from} people were watching ${n}. ${f.minutes} minutes later, ${f.to}.`, f.from >= 6 ? 3 : 2, 9, 0],
++ viewer_spike: (n, f) => [`📈 ${n} IS BLOWING UP: ${f.from} → ${f.to} viewers`, `${n} went from ${f.from} to ${f.to} viewers in ${f.minutes} minutes. Something is happening.`, 2, 0, 9],
++ chat_explosion: (n, f) => [`💬 ${n}'S CHAT GOES FERAL: ${f.messages_5m} messages in 5 minutes`, `${n}'s chat usually runs at ${f.usual_5m} messages every 5 minutes. It just hit ${f.messages_5m}.`, 2, 0, 7],
++ meltdown: (n, f) => [`😤 ${n} LOSES IT ON STREAM`, `${f.count} yells, screams and shouts in ${f.seconds} seconds on ${n}'s stream.`, 3, 12, 2],
++ tears: (n, f) => [`😭 ${n} IN TEARS ON STREAM?`, `Crying was heard ${f.count} times in ${f.seconds} seconds on ${n}'s stream.`, 3, 14, 0],
++ marathon: (n, f) => [`⏰ ${n} HAS BEEN LIVE FOR ${f.hours} HOURS. TOUCH GRASS?`, `${n} started at ${f.started} UTC and has not stopped.`, 2, 3, 6],
++ ghost_town: (n, f) => [`👻 ${n} STREAMED ${f.minutes} MINUTES TO AN EMPTY ROOM`, `Peak: ${f.peak} viewer${f.peak === 1 ? '' : 's'}. Chat messages: ${f.messages}.`, 1, 7, 0],
++ the_return: (n, f) => [`👀 ${n} IS BACK after ${f.days_away} days`, `${n}'s last stream was ${f.days_away} days ago. They are live again${f.title ? `: "${f.title}"` : ''}.`, 2, 0, 6],
++ title_drama: (n, f) => [`🍿 ${n}'S TITLE SAYS IT ALL: "${f.title}"`, `That is what ${n} called today's stream.`, 2, 5, 2],
++ peak_record: (n, f) => [`🏆 ${n} HITS A NEW PEAK: ${f.peak} viewers`, `${n}'s best before this was ${f.previous_best}.`, 2, 0, 10],
++ fell_off: (n, f) => [`📉 DID ${n} FALL OFF? No stream in ${f.days_quiet} days`, `${n} streamed ${f.streams_before} times the month before. Since then: nothing.`, 2, 9, 0],
++ streak: (n, f) => [`🔥 ${n}: ${f.days} DAYS STRAIGHT, NO DAYS OFF`, `${n} has been live every day for ${f.days} days.`, 2, 0, 8],
++ quote_of_day: (n, f) => [`🎤 QUOTE OF THE DAY: ${n}`, f.about ? `On ${f.about}. The judge gave it ${f.quality}/10.` : `The judge gave it ${f.quality}/10.`, 2, 0, 5],
++ beef_open: (n, f) => [`🥩 BEEF: ${n} vs ${f.other}`, f.about ? `${n} came for ${f.other} on mic: ${f.about}. ${f.other} is on the clock.` : `${n} came for ${f.other} on mic. ${f.other} is on the clock.`, 3, 0, 5],
++ beef_win: (n, f) => [`🏆 ${n} COOKS ${f.other}${f.upset ? ' IN AN UPSET' : ''}`, `${n} won the beef with ${f.other}, ${f.score_winner}–${f.score_loser}.`, f.upset ? 4 : 3, 0, 12],
++ beef_forfeit: (n, f) => [`🐔 ${n} DUCKS ${f.other}`, `${f.other} called ${n} out on mic. ${n} never answered and ran out the clock.`, 3, 15, 0],
++};
++
++let _aiCalls = [];
++function aiBudgetLeft() {
++ const now = Date.now();
++ _aiCalls = _aiCalls.filter((t) => now - t < 3600_000);
++ return _aiCalls.length < AI_PER_HOUR;
++}
++
++/** Store a story (once per dedupe key) and have AI rewrite it. Returns the new id, or null when it existed or is opted out. */
++async function createStory({ kind, userId, otherUserId = null, streamId = null, vodId = null, sec = null, dedupeKey, facts = {}, quote = null, happenedAt }) {
++ if (!T[kind] || !userId || await optedOut(userId)) return null;
++ if (await db.get('SELECT 1 FROM arena_drama WHERE dedupe_key = ?', [dedupeKey])) return null;
++ const name = await nameOf(userId);
++ const fx = { ...facts, ...(otherUserId ? { other: facts.other || await nameOf(otherUserId) } : {}) };
++ const [headline, blurb, alert, cow, clout] = T[kind](name, fx);
++ const cleanQuote = quote && !arena()._isBannedText(quote) ? String(quote).replace(/\s+/g, ' ').trim().slice(0, 400) : null;
++ const r = await db.run(`INSERT INTO arena_drama (kind, user_id, other_user_id, stream_id, vod_id, sec, dedupe_key, facts_json, quote, headline, blurb, alert, cow_points, clout_points, happened_at)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (dedupe_key) DO NOTHING RETURNING id`,
++ [kind, userId, otherUserId, streamId, vodId, sec == null ? null : Math.max(0, Math.floor(sec)), dedupeKey, JSON.stringify(fx), cleanQuote, headline.slice(0, 160), blurb.slice(0, 480), alert, cow, clout, fmt(happenedAt || Date.now())]);
++ const id = r && (r.lastInsertRowid || (r.rows && r.rows[0] && r.rows[0].id));
++ if (!id) return null;
++ if (alert >= 3) {
++ try { require('./notify').arenaNotify(userId, { type: 'drama', title: `You made the Arena: ${headline.slice(0, 100)}`, message: blurb.slice(0, 200), icon: '🚨', url: `/arena/drama/${id}`, key: `drama:${id}` }); } catch { /* */ }
++ }
++ rewrite(id, { kind, streamer: name, other: fx.other || null, facts: fx, quote: cleanQuote }, userId).catch(() => {});
++ return id;
++}
++
++async function rewrite(id, payload, userId) {
++ if (!aiBudgetLeft() || !await arena().aiOn()) return;
++ _aiCalls.push(Date.now());
++ const out = await require('../ai/ai-service').structured('live.arena.drama', payload, { meter: { kind: 'arena_drama', role: 'chat', ownerUserId: userId, source: 'arena' } });
++ if (!out || !out.headline || !out.blurb) return;
++ if (arena()._isBannedText(out.headline) || arena()._isBannedText(out.blurb)) return;
++ await db.run('UPDATE arena_drama SET headline = ?, blurb = ?, alert = ?, ai = 1 WHERE id = ?', [String(out.headline).slice(0, 160), String(out.blurb).slice(0, 480), Math.min(5, Math.max(1, Number(out.alert) || 1)), id]);
++}
++
++// ── Detectors ──
++async function streamsSince(sinceTxt) {
++ return await db.all(`SELECT id, user_id, title, category, started_at, ended_at, duration_seconds, peak_viewers, is_live FROM streams
++ WHERE (is_live = 1 OR ended_at >= ? OR started_at >= ?) AND user_id IS NOT NULL ORDER BY id`, [sinceTxt, sinceTxt]);
++}
++
++async function detectStreams(streams, now, since) {
++ const out = [];
++ for (const s of streams) {
++ const started = toMs(s.started_at), ended = toMs(s.ended_at);
++ const live = Number(s.is_live) === 1;
++ const minutes = Math.round((Number(s.duration_seconds) || ((live ? now : ended) - started) / 1000 || 0) / 60);
++ // rage quit: a short stream from someone who usually goes long.
++ if (!live && ended >= since && minutes >= 1 && minutes < 15) {
++ const prior = await db.all('SELECT duration_seconds FROM streams WHERE user_id = ? AND id < ? AND duration_seconds >= 60 ORDER BY id DESC LIMIT 10', [s.user_id, s.id]);
++ const usual = Math.round(median(prior.map((p) => Number(p.duration_seconds))) / 60);
++ if (prior.length >= 3 && usual >= 45) out.push({ kind: 'rage_quit', userId: s.user_id, streamId: s.id, dedupeKey: `rage_quit:${s.id}`, facts: { minutes_live: minutes, usual_minutes: usual, title: s.title || null }, happenedAt: ended });
++ }
++ // marathon
++ if (minutes >= 480) out.push({ kind: 'marathon', userId: s.user_id, streamId: s.id, dedupeKey: `marathon:${s.id}`, facts: { hours: Math.floor(minutes / 60), started: fmt(started).slice(0, 16) }, happenedAt: live ? now : ended });
++ // ghost town: an hour or more, nobody there, nobody typing.
++ if (!live && ended >= since && minutes >= 60 && (Number(s.peak_viewers) || 0) <= 1) {
++ const a = await db.get('SELECT total_messages FROM stream_analytics WHERE stream_id = ?', [s.id]);
++ const chat = await db.get('SELECT COALESCE(MAX(chat_messages_5m), 0) AS m FROM viewer_snapshots WHERE stream_id = ?', [s.id]);
++ const messages = Number(a && a.total_messages) || 0;
++ if (!messages && !Number(chat && chat.m)) out.push({ kind: 'ghost_town', userId: s.user_id, streamId: s.id, dedupeKey: `ghost_town:${s.id}`, facts: { minutes, peak: Number(s.peak_viewers) || 0, messages: 0 }, happenedAt: ended });
++ }
++ // the return
++ if (started >= since) {
++ const prev = await db.get('SELECT ended_at FROM streams WHERE user_id = ? AND id < ? AND ended_at IS NOT NULL ORDER BY id DESC LIMIT 1', [s.user_id, s.id]);
++ const away = prev ? Math.floor((started - toMs(prev.ended_at)) / 86400000) : 0;
++ if (away >= 14) out.push({ kind: 'the_return', userId: s.user_id, streamId: s.id, dedupeKey: `the_return:${s.id}`, facts: { days_away: away, title: s.title || null }, happenedAt: started });
++ }
++ // title drama
++ if (s.title && TITLE_DRAMA.test(s.title) && (live || ended >= since)) {
++ out.push({ kind: 'title_drama', userId: s.user_id, streamId: s.id, dedupeKey: `title_drama:${s.id}:${String(s.title).toLowerCase().replace(/[^a-z0-9]+/g, '').slice(0, 60)}`, facts: { title: String(s.title).slice(0, 120) }, happenedAt: live ? now : ended });
++ }
++ // a new personal peak
++ if (!live && ended >= since && (Number(s.peak_viewers) || 0) >= 3) {
++ const best = await db.get('SELECT MAX(peak_viewers) AS p, COUNT(*) AS n FROM streams WHERE user_id = ? AND id < ?', [s.user_id, s.id]);
++ if (best && Number(best.n) >= 3 && Number(s.peak_viewers) > (Number(best.p) || 0)) out.push({ kind: 'peak_record', userId: s.user_id, streamId: s.id, dedupeKey: `peak_record:${s.id}`, facts: { peak: Number(s.peak_viewers), previous_best: Number(best.p) || 0 }, happenedAt: ended });
++ }
++ }
++ return out;
++}
++
++/** Viewer crashes and spikes, chat explosions: the minute-by-minute snapshots of each stream in the window. */
++async function detectSnapshots(streams, since) {
++ const out = [];
++ for (const s of streams) {
++ const rows = await db.all('SELECT viewer_count, chat_messages_5m, recorded_at FROM viewer_snapshots WHERE stream_id = ? ORDER BY recorded_at', [s.id]);
++ if (rows.length < 5) continue;
++ const pts = rows.map((r) => ({ t: toMs(r.recorded_at), v: Number(r.viewer_count) || 0, c: Number(r.chat_messages_5m) || 0 }));
++ const usualChat = median(pts.map((p) => p.c));
++ let crash = null, spike = null, chat = null;
++ for (let i = 1; i < pts.length; i++) {
++ const p = pts[i];
++ if (p.t < since) continue;
++ const prev = pts.slice(0, i).filter((q) => p.t - q.t <= 20 * 60000);
++ if (!prev.length) continue;
++ const hi = prev.reduce((m, q) => (q.v > m.v ? q : m), prev[0]);
++ const lo = prev.reduce((m, q) => (q.v < m.v ? q : m), prev[0]);
++ if (hi.v >= 3 && p.v <= hi.v / 2 && (!crash || hi.v - p.v > crash.from - crash.to)) crash = { from: hi.v, to: p.v, minutes: Math.max(1, Math.round((p.t - hi.t) / 60000)), t: p.t };
++ if (p.v >= 4 && p.v >= 2 * Math.max(1, lo.v) && (!spike || p.v - lo.v > spike.to - spike.from)) spike = { from: lo.v, to: p.v, minutes: Math.max(1, Math.round((p.t - lo.t) / 60000)), t: p.t };
++ if (p.c >= 15 && p.c >= 3 * Math.max(1, usualChat) && (!chat || p.c > chat.messages_5m)) chat = { messages_5m: p.c, usual_5m: usualChat, t: p.t };
++ }
++ const hour = (t) => new Date(t).toISOString().slice(0, 13);
++ if (crash) out.push({ kind: 'viewer_crash', userId: s.user_id, streamId: s.id, dedupeKey: `viewer_crash:${s.id}:${hour(crash.t)}`, facts: { from: crash.from, to: crash.to, minutes: crash.minutes }, happenedAt: crash.t });
++ if (spike) out.push({ kind: 'viewer_spike', userId: s.user_id, streamId: s.id, dedupeKey: `viewer_spike:${s.id}:${hour(spike.t)}`, facts: { from: spike.from, to: spike.to, minutes: spike.minutes }, happenedAt: spike.t });
++ if (chat) out.push({ kind: 'chat_explosion', userId: s.user_id, streamId: s.id, dedupeKey: `chat_explosion:${s.id}:${hour(chat.t)}`, facts: { messages_5m: chat.messages_5m, usual_5m: chat.usual_5m }, happenedAt: chat.t });
++ }
++ return out;
++}
++
++/** Meltdowns and tears: 3+ rage (or crying) sounds within 2 minutes, with the longest line said around them. */
++async function detectSounds(streams, sinceTxt) {
++ const out = [];
++ for (const s of streams) {
++ const sounds = await db.all(`SELECT label, start_sec, vod_id FROM stream_timeline_events WHERE stream_id = ? AND kind = 'sound' AND created_at >= ? ORDER BY start_sec`, [s.id, sinceTxt]);
++ for (const [kind, set] of [['meltdown', RAGE_SOUNDS], ['tears', TEAR_SOUNDS]]) {
++ const hits = sounds.filter((x) => set.has(x.label));
++ let best = null;
++ for (let i = 0; i < hits.length; i++) {
++ let j = i;
++ while (j + 1 < hits.length && hits[j + 1].start_sec - hits[i].start_sec <= 120) j++;
++ const n = j - i + 1;
++ if (n >= 3 && (!best || n > best.n)) best = { n, from: hits[i], to: hits[j] };
++ }
++ if (!best) continue;
++ const lines = await db.all(`SELECT text FROM stream_timeline_events WHERE stream_id = ? AND kind = 'speech' AND start_sec BETWEEN ? AND ? ORDER BY start_sec`, [s.id, best.from.start_sec - 20, best.to.start_sec + 20]);
++ const quote = lines.map((l) => String(l.text || '')).filter((t) => t && !arena()._isBannedText(t)).sort((a, b) => b.length - a.length)[0] || null;
++ const counts = {};
++ for (const h of hits.slice(hits.indexOf(best.from), hits.indexOf(best.to) + 1)) counts[h.label] = (counts[h.label] || 0) + 1;
++ out.push({ kind, userId: s.user_id, streamId: s.id, vodId: best.from.vod_id || null, sec: best.from.start_sec, dedupeKey: `${kind}:${s.id}:${Math.floor(best.from.start_sec / 600)}`,
++ facts: { count: best.n, seconds: Math.max(1, Math.round(best.to.start_sec - best.from.start_sec)), sounds: counts }, quote, happenedAt: Date.now() });
++ }
++ }
++ return out;
++}
++
++/** Once a day: who fell off, who has not missed a day, and the quote of the day. */
++async function detectDaily(now) {
++ const out = [];
++ const today = day(now);
++ const users = await db.all(`SELECT user_id, COUNT(*) AS n FROM streams WHERE started_at >= ? AND started_at < ? AND user_id IS NOT NULL GROUP BY user_id HAVING COUNT(*) >= 4`, [fmt(now - 38 * 86400000), fmt(now - 8 * 86400000)]);
++ for (const u of users) {
++ const recent = await db.get('SELECT MAX(COALESCE(ended_at, started_at)) AS last FROM streams WHERE user_id = ?', [u.user_id]);
++ const quiet = Math.floor((now - toMs(recent && recent.last)) / 86400000);
++ if (quiet >= 7) out.push({ kind: 'fell_off', userId: u.user_id, dedupeKey: `fell_off:${u.user_id}:${day(toMs(recent.last))}`, facts: { days_quiet: quiet, streams_before: Number(u.n) }, happenedAt: now });
++ }
++ const days = await db.all(`SELECT user_id, substr(started_at, 1, 10) AS d FROM streams WHERE started_at >= ? AND user_id IS NOT NULL GROUP BY user_id, substr(started_at, 1, 10)`, [fmt(now - 60 * 86400000)]);
++ const byUser = new Map();
++ for (const r of days) { if (!byUser.has(r.user_id)) byUser.set(r.user_id, new Set()); byUser.get(r.user_id).add(r.d); }
++ for (const [userId, set] of byUser) {
++ // Consecutive days live, ending today (or yesterday, when today's stream has not started yet).
++ const from = set.has(today) ? 0 : 1;
++ let n = 0;
++ while (set.has(day(now - (from + n) * 86400000))) n++;
++ if (n >= 7 && n % 7 === 0) out.push({ kind: 'streak', userId, dedupeKey: `streak:${userId}:${n}:${today}`, facts: { days: n }, happenedAt: now });
++ }
++ const yesterday = day(now - 86400000);
++ const q = await db.get(`SELECT user_id, text, about, quality, vod_id, sec, stream_id FROM arena_mic_moments WHERE substr(COALESCE(said_at, created_at), 1, 10) = ? AND quality >= 7 ORDER BY quality DESC, id LIMIT 1`, [yesterday]);
++ if (q) out.push({ kind: 'quote_of_day', userId: q.user_id, streamId: q.stream_id, vodId: q.vod_id, sec: q.sec, dedupeKey: `quote_of_day:${yesterday}`, facts: { quality: Number(q.quality), about: q.about || null }, quote: q.text, happenedAt: now });
++ return out;
++}
++
++let _busy = false;
++async function detect({ now = Date.now() } = {}) {
++ if (_busy) return { created: 0, busy: true };
++ _busy = true;
++ try {
++ const since = now - WINDOW_MS;
++ const sinceTxt = fmt(since);
++ const streams = await streamsSince(sinceTxt);
++ const found = [
++ ...await detectStreams(streams, now, since),
++ ...await detectSnapshots(streams, since),
++ ...await detectSounds(streams, sinceTxt),
++ ...await detectDaily(now),
++ ];
++ let created = 0;
++ for (const f of found) { try { if (await createStory(f)) created++; } catch (e) { console.warn('[Arena] drama story:', f.kind, e.message); } }
++ return { created, found: found.length };
++ } finally { _busy = false; }
++}
++
++/** beef.js reports: 'open' (a called out b), 'win' (winner, loser), 'forfeit' (the one who ducked, the caller). */
++async function beefStory(kind, beef, ctx = {}) {
++ if (kind === 'open') return await createStory({ kind: 'beef_open', userId: beef.a_user_id, otherUserId: beef.b_user_id, dedupeKey: `beef_open:${beef.id}`, facts: { about: ctx.about || null }, quote: ctx.opening_line || null, happenedAt: Date.now() });
++ if (kind === 'win' && ctx.winnerId && ctx.loserId) return await createStory({ kind: 'beef_win', userId: ctx.winnerId, otherUserId: ctx.loserId, dedupeKey: `beef_win:${beef.id}`, facts: { score_winner: ctx.scoreWinner, score_loser: ctx.scoreLoser, upset: !!ctx.upset }, happenedAt: Date.now() });
++ if (kind === 'forfeit' && ctx.loserId && ctx.winnerId) return await createStory({ kind: 'beef_forfeit', userId: ctx.loserId, otherUserId: ctx.winnerId, dedupeKey: `beef_forfeit:${beef.id}`, facts: {}, happenedAt: Date.now() });
++ return null;
++}
++
++// ── Reading ──
++async function userCard(userId) {
++ if (!userId) return null;
++ const u = await db.get('SELECT id, username, display_name, avatar_url FROM users WHERE id = ?', [userId]);
++ return u ? { id: u.id, username: u.username, display_name: u.display_name || u.username, avatar_url: u.avatar_url || null } : null;
++}
++async function storyView(row, voterKey = null) {
++ const counts = Object.fromEntries(REACTIONS.map((r) => [r, 0]));
++ for (const c of await db.all('SELECT reaction, COUNT(*) AS n FROM arena_drama_reactions WHERE drama_id = ? GROUP BY reaction', [row.id])) counts[c.reaction] = Number(c.n);
++ const mine = voterKey ? await db.get('SELECT reaction FROM arena_drama_reactions WHERE drama_id = ? AND voter_key = ?', [row.id, voterKey]) : null;
++ let facts = {};
++ try { facts = JSON.parse(row.facts_json || '{}'); } catch { /* */ }
++ return {
++ id: row.id, kind: row.kind, headline: row.headline, blurb: row.blurb, quote: row.quote, alert: Number(row.alert), ai: !!row.ai,
++ user: await userCard(row.user_id), other: await userCard(row.other_user_id), facts,
++ stream_id: row.stream_id, vod_id: row.vod_id, sec: row.sec, happened_at: row.happened_at,
++ reactions: counts, my_reaction: mine ? mine.reaction : null,
++ };
++}
++async function feed({ limit = 30, before = null, userId = null, voterKey = null } = {}) {
++ const args = [];
++ let where = "d.status = 'live'";
++ if (before) { where += ' AND d.id < ?'; args.push(Number(before)); }
++ if (userId) { where += ' AND (d.user_id = ? OR d.other_user_id = ?)'; args.push(userId, userId); }
++ const rows = await db.all(`SELECT d.* FROM arena_drama d WHERE ${where} ORDER BY d.happened_at DESC, d.id DESC LIMIT ?`, [...args, Math.min(100, Math.max(1, limit))]);
++ return await Promise.all(rows.map((r) => storyView(r, voterKey)));
++}
++async function story(id, voterKey = null) {
++ const row = await db.get("SELECT * FROM arena_drama WHERE id = ? AND status = 'live'", [Number(id)]);
++ return row ? await storyView(row, voterKey) : null;
++}
++async function react(id, reaction, voterKey) {
++ if (!REACTIONS.includes(reaction)) throw new Error(`reaction must be one of ${REACTIONS.join(', ')}`);
++ if (!await db.get("SELECT 1 FROM arena_drama WHERE id = ? AND status = 'live'", [Number(id)])) throw new Error('No such story');
++ await db.run(`INSERT INTO arena_drama_reactions (drama_id, voter_key, reaction) VALUES (?, ?, ?)
++ ON CONFLICT (drama_id, voter_key) DO UPDATE SET reaction = excluded.reaction, created_at = ov_now()`, [Number(id), voterKey, reaction]);
++ return (await story(id, voterKey)).reactions;
++}
++
++// ── The boards ──
++const weekStart = (ms) => { const d = new Date(ms); const dow = (d.getUTCDay() + 6) % 7; return day(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()) - dow * 86400000); };
++
++/** Lolcow Index: cow points over the last `days` (newer counts more), L's included; 0-100 against the top. */
++async function lolcowIndex({ days = 7, now = Date.now() } = {}) {
++ const rows = await db.all(`SELECT user_id, kind, cow_points, happened_at FROM arena_drama WHERE status = 'live' AND cow_points > 0 AND happened_at >= ?`, [fmt(now - days * 86400000)]);
++ const by = new Map();
++ for (const r of rows) {
++ const age = (now - toMs(r.happened_at)) / (days * 86400000);
++ const pts = Number(r.cow_points) * (1 - 0.5 * Math.min(1, Math.max(0, age)));
++ const e = by.get(r.user_id) || { score: 0, kinds: {} };
++ e.score += pts;
++ e.kinds[r.kind] = (e.kinds[r.kind] || 0) + 1;
++ by.set(r.user_id, e);
++ }
++ const top = Math.max(1, ...[...by.values()].map((e) => e.score));
++ const list = await Promise.all([...by.entries()].filter(([uid]) => uid).map(async ([uid, e]) => ({ user: await userCard(uid), score: Math.round(e.score), meter: Math.round((e.score / top) * 100), kinds: e.kinds })));
++ return list.filter((x) => x.user).sort((a, b) => b.score - a.score).slice(0, 25);
++}
++
++/** Clout: what each streamer did in the last 7 days (hours live, viewers, chat, followers) plus their stories' clout. */
++async function cloutScores(now = Date.now()) {
++ const since = fmt(now - 7 * 86400000);
++ const rows = await db.all(`SELECT s.user_id, SUM(COALESCE(s.duration_seconds, 0)) AS secs, MAX(s.peak_viewers) AS peak, COUNT(*) AS streams,
++ SUM(COALESCE(a.avg_viewers, 0) * COALESCE(s.duration_seconds, 0)) AS viewer_secs, SUM(COALESCE(a.total_messages, 0)) AS messages, SUM(COALESCE(a.new_followers, 0)) AS followers
++ FROM streams s LEFT JOIN stream_analytics a ON a.stream_id = s.id WHERE s.started_at >= ? AND s.user_id IS NOT NULL GROUP BY s.user_id`, [since]);
++ const drama = await db.all(`SELECT user_id, SUM(clout_points) AS clout, SUM(cow_points) AS cow FROM arena_drama WHERE status = 'live' AND happened_at >= ? GROUP BY user_id`, [since]);
++ const wins = await db.all(`SELECT winner_user_id AS user_id, COUNT(*) AS n FROM arena_beefs WHERE status = 'resolved' AND winner_user_id IS NOT NULL AND resolved_at >= ? GROUP BY winner_user_id`, [since]);
++ const by = new Map();
++ const get = (uid) => { if (!by.has(uid)) by.set(uid, { hours: 0, peak: 0, avg_viewers: 0, messages: 0, followers: 0, streams: 0, story_clout: 0, cow: 0, beef_wins: 0 }); return by.get(uid); };
++ for (const r of rows) {
++ const e = get(r.user_id);
++ const secs = Number(r.secs) || 0;
++ Object.assign(e, { hours: Math.round(secs / 360) / 10, peak: Number(r.peak) || 0, avg_viewers: secs ? Math.round((Number(r.viewer_secs) || 0) / secs * 10) / 10 : 0, messages: Number(r.messages) || 0, followers: Number(r.followers) || 0, streams: Number(r.streams) || 0 });
++ }
++ for (const r of drama) { const e = get(r.user_id); e.story_clout = Number(r.clout) || 0; e.cow = Number(r.cow) || 0; }
++ for (const r of wins) get(r.user_id).beef_wins = Number(r.n) || 0;
++ for (const e of by.values()) e.clout = Math.round(e.hours * 2 + e.avg_viewers * 10 + e.peak * 3 + e.messages / 50 + e.followers * 5 + e.story_clout + e.beef_wins * 10 - e.cow * 0.5);
++ return by;
++}
++
++async function cloutRankings({ now = Date.now() } = {}) {
++ const by = await cloutScores(now);
++ const ranked = [...by.entries()].filter(([uid]) => uid).sort((a, b) => b[1].clout - a[1].clout);
++ // This week's moves: against the snapshot taken at the start of the week (taken here, once a week).
++ const week = weekStart(now);
++ const prevWeek = weekStart(now - 7 * 86400000);
++ if (!await db.get('SELECT 1 FROM arena_clout_weeks WHERE week = ? LIMIT 1', [week])) {
++ let i = 0;
++ for (const [uid, e] of ranked) await db.run('INSERT INTO arena_clout_weeks (week, user_id, clout_rank, clout, cow, facts_json) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT DO NOTHING', [week, uid, ++i, e.clout, e.cow, JSON.stringify(e)]);
++ }
++ const before = new Map((await db.all('SELECT user_id, clout_rank FROM arena_clout_weeks WHERE week = ?', [prevWeek])).map((r) => [r.user_id, Number(r.clout_rank)]));
++ const cows = new Map((await lolcowIndex({ now })).map((c) => [c.user.id, c.meter]));
++ const out = [];
++ let rank = 0;
++ for (const [uid, e] of ranked.slice(0, 50)) {
++ rank++;
++ const was = before.get(uid) || null;
++ const move = was ? was - rank : null;
++ const cow = cows.get(uid) || 0;
++ const tags = [];
++ if (cow >= 70) tags.push('COOKED');
++ if (move != null && move >= 2) tags.push('ON THE RISE');
++ if (move != null && move <= -2) tags.push('FALLING');
++ if (e.beef_wins >= 2) tags.push('UNDEFEATED');
++ const user = await userCard(uid);
++ if (user) out.push({ rank, user, clout: e.clout, move, was, tags, cow, facts: e });
++ }
++ return out;
++}
++
++async function rapSheet(userId) {
++ const user = await userCard(userId);
++ if (!user) return null;
++ const stories = await feed({ userId, limit: 40 });
++ const cow = (await lolcowIndex()).find((c) => c.user.id === userId) || null;
++ const clout = (await cloutRankings()).find((c) => c.user.id === userId) || null;
++ const record = await db.get(`SELECT SUM(CASE WHEN winner_user_id = ? THEN 1 ELSE 0 END) AS w, SUM(CASE WHEN winner_user_id IS NOT NULL AND winner_user_id <> ? THEN 1 ELSE 0 END) AS l
++ FROM arena_beefs WHERE status = 'resolved' AND (a_user_id = ? OR b_user_id = ?)`, [userId, userId, userId, userId]);
++ const counts = {};
++ for (const s of stories) counts[s.kind] = (counts[s.kind] || 0) + 1;
++ return { user, stories, cow, clout, record: { w: Number(record && record.w) || 0, l: Number(record && record.l) || 0 }, counts, opted_out: await optedOut(userId) };
++}
++
++async function setOptOut(userId, out) {
++ if (out) {
++ await db.run('INSERT INTO arena_opt_outs (user_id) VALUES (?) ON CONFLICT DO NOTHING', [userId]);
++ await db.run("UPDATE arena_drama SET status = 'hidden' WHERE user_id = ? OR other_user_id = ?", [userId, userId]);
++ } else {
++ await db.run('DELETE FROM arena_opt_outs WHERE user_id = ?', [userId]);
++ }
++ return { opted_out: !!out };
++}
++
++async function hide(id) { await db.run("UPDATE arena_drama SET status = 'hidden' WHERE id = ?", [Number(id)]); }
++
++module.exports = { detect, beefStory, createStory, feed, story, react, lolcowIndex, cloutRankings, rapSheet, setOptOut, optedOut, hide, REACTIONS, TEMPLATES: T, _test: { detectStreams, detectSnapshots, detectSounds, detectDaily, weekStart, toMs, fmt } };
ddiff --git a/server/arena/routes.js b/server/arena/routes.js
iindex 467b1368..a33a8fbc 100644
---- a/server/arena/routes.js
++++ b/server/arena/routes.js
@@@ -9,6 +9,14 @@
* GET /levels Trash Level ladder (XP from mic only)
* GET /me the signed-in fighter's own state (beefs on them, level, moments)
* GET /voice/:user?t=<text> the line read in that user's chat TTS voice
++ *
++ * Arena v4, the drama desk (drama.js):
++ * GET /drama?limit&before&user ARENA ALERT stories, newest first, with reactions
++ * GET /drama/:id · POST /drama/:id/react {reaction} one reaction per viewer (fire, skull, clown, w, l)
++ * POST /drama/:id/hide admin: take a story down
++ * GET /lolcow · /clout the Lolcow Index and the Clout Rankings (with this week's moves)
++ * GET /rap/:user one streamer's rap sheet
++ * GET /opt-out · PUT /opt-out {out} a streamer keeps themselves out of the Arena (or comes back)
*/
'use strict';
@@@ -147,6 +155,42 @@ router.get('/me', requireAuth, async (req, res) => {
} catch (err) { fail(res, err, 'Failed to load your arena'); }
});
++// ── Arena v4: the drama desk ──
++const drama = require('./drama');
++router.get('/drama', optionalAuth, async (req, res) => {
++ try {
++ const limit = Math.min(60, Math.max(1, parseInt(req.query.limit, 10) || 30));
++ const user = req.query.user ? await userFrom(req.query.user) : null;
++ if (req.query.user && !user) return res.status(404).json({ error: 'No such user' });
++ res.set('Cache-Control', 'no-store');
++ res.json({ stories: await drama.feed({ limit, before: req.query.before || null, userId: user ? user.id : null, voterKey: await arena.voterKeyFor(req) }), ai: await arena.aiOn() });
++ } catch (err) { fail(res, err, 'Failed to load the drama'); }
++});
++router.get('/drama/:id', optionalAuth, async (req, res) => {
++ try { const s = await drama.story(req.params.id, await arena.voterKeyFor(req)); if (!s) return res.status(404).json({ error: 'No such story' }); res.set('Cache-Control', 'no-store'); res.json(s); }
++ catch (err) { fail(res, err, 'Failed to load the story'); }
++});
++router.post('/drama/:id/react', optionalAuth, async (req, res) => {
++ try { res.json({ reactions: await drama.react(req.params.id, String(req.body?.reaction || ''), await arena.voterKeyFor(req)) }); }
++ catch (err) { res.status(400).json({ error: err.message }); }
++});
++router.post('/drama/:id/hide', requireAuth, permissions.requireAdmin, async (req, res) => {
++ try { await drama.hide(req.params.id); res.json({ ok: true }); } catch (err) { fail(res, err, 'Failed to hide'); }
++});
++router.get('/lolcow', async (req, res) => { try { res.set('Cache-Control', 'public, max-age=60'); res.json({ index: await drama.lolcowIndex() }); } catch (err) { fail(res, err, 'Failed to load the Lolcow Index'); } });
++router.get('/clout', async (req, res) => { try { res.set('Cache-Control', 'public, max-age=60'); res.json({ rankings: await drama.cloutRankings() }); } catch (err) { fail(res, err, 'Failed to load the rankings'); } });
++router.get('/rap/:user', async (req, res) => {
++ try {
++ const user = await userFrom(req.params.user);
++ if (!user) return res.status(404).json({ error: 'No such user' });
++ const sheet = await drama.rapSheet(user.id);
++ res.set('Cache-Control', 'no-store');
++ res.json(sheet);
++ } catch (err) { fail(res, err, 'Failed to load the rap sheet'); }
++});
++router.get('/opt-out', requireAuth, async (req, res) => { try { res.json({ opted_out: await drama.optedOut(req.user.id) }); } catch (err) { fail(res, err, 'Failed'); } });
++router.put('/opt-out', requireAuth, async (req, res) => { try { res.json(await drama.setOptOut(req.user.id, !!req.body?.out)); } catch (err) { fail(res, err, 'Failed'); } });
++
router.get('/levels', async (req, res) => { try { res.json({ levels: await mic.levelsLeaderboard(20) }); } catch (err) { fail(res, err, 'Failed'); } });
module.exports = router;
ddiff --git a/server/auth/account-data.js b/server/auth/account-data.js
iindex 7194dfe2..6ed3b134 100644
---- a/server/auth/account-data.js
++++ b/server/auth/account-data.js
@@@ -41,7 +41,7 @@ const OVERRIDE_DELETE = new Set(['ip_log.user_id', 'vpn_approvals.user_id']);
// Copies of the person's name kept beside a person column: cleared wherever the row stays.
const NAME_COLS = ['username', 'display_name', 'user_name', 'sender_name', 'sender_username', 'author_name', 'author_username', 'avatar_url', 'user_avatar'];
// Person columns the schema declares without a foreign key.
--const EXTRA = [['channel_points', 'streamer_id'], ['arena_mic_moments', 'target_user_id'], ['arena_topics', 'created_by'], ['arena_topics', 'target_user_id']];
++const EXTRA = [['channel_points', 'streamer_id'], ['arena_mic_moments', 'target_user_id'], ['arena_drama', 'other_user_id'], ['arena_topics', 'created_by'], ['arena_topics', 'target_user_id']];
const SECRET_COL = /(^|_)(token|tokens|secret|hash|password|code|key|keys|p256dh|auth|cookie|cookies|credentials)($|_)/i;
const ROW_LIMIT = 2000;
const PART_BUDGET = 18 * 1024 * 1024;
ddiff --git a/test/arena-drama.test.js b/test/arena-drama.test.js
nnew file mode 100644
iindex 00000000..695f8f49
---- /dev/null
++++ b/test/arena-drama.test.js
@@@ -0,0 +1,110 @@
++'use strict';
++// Arena v4, the drama desk (server/arena/drama.js): what streamers did becomes ARENA ALERT stories, once per event —
++// a rage quit and a dramatic title, a viewer crash and a chat explosion from the minute snapshots, a meltdown heard on
++// the sound timeline with what was said around it, a ghost-town stream, a comeback, a beef; viewers react once each;
++// the Lolcow Index and the Clout Rankings read the stories and the week's activity; a streamer who opts out gets no
++// stories and their old ones are hidden. Runs on a temp database with AI off (template headlines).
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++
++const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-arena-drama-'));
++process.env.ARENA_IMAGE_PATH = path.join(tmp, 'arena');
++console.warn = () => {};
++
++const db = require('../server/db/database');
++(async () => {
++ await db.initDb();
++ const drama = require('../server/arena/drama');
++ const { fmt } = drama._test;
++ const NOW = Date.now();
++ const ago = (min) => fmt(NOW - min * 60000);
++
++ const mk = async (username) => { await db.createUser({ username, email: `${username}@x`, password_hash: 'x', display_name: username, stream_key: username.padEnd(32, '0') }); const id = (await db.get('SELECT id FROM users WHERE username = ?', [username])).id; await db.ensureChannel(id); return id; };
++ const stream = async (uid, { startMin, endMin = null, peak = 0, title = 'just chatting' }) => {
++ const id = Number((await db.createStream({ user_id: uid, title, category: 'irl', protocol: 'rtmp' })).lastInsertRowid);
++ const live = endMin == null;
++ await db.run('UPDATE streams SET is_live = ?, started_at = ?, ended_at = ?, duration_seconds = ?, peak_viewers = ?, title = ? WHERE id = ?',
++ [live ? 1 : 0, ago(startMin), live ? null : ago(endMin), live ? null : (startMin - endMin) * 60, peak, title, id]);
++ return id;
++ };
++ const rager = await mk('rager'), crasher = await mk('crasher'), screamer = await mk('screamer'), ghost = await mk('ghost'), returner = await mk('returner'), viewer = await mk('viewer');
++
++ // rager: four long streams before, then six minutes and a title that says it all.
++ for (let d = 4; d >= 1; d--) await stream(rager, { startMin: d * 1440 + 120, endMin: d * 1440, peak: 2 });
++ await stream(rager, { startMin: 36, endMin: 30, peak: 2, title: "I'm done with this game" });
++ // crasher: live 40 min; 8 viewers for 15 min then 2; chat at 2 then 30.
++ const cs = await stream(crasher, { startMin: 40, peak: 8 });
++ for (let m = 30; m >= 0; m--) await db.run('INSERT INTO viewer_snapshots (stream_id, viewer_count, chat_messages_5m, recorded_at) VALUES (?, ?, ?, ?)', [cs, m > 15 ? 8 : 2, m === 3 ? 30 : 2, ago(m)]);
++ // screamer: live; three rage sounds inside two minutes, and a line said between them.
++ const ss = await stream(screamer, { startMin: 20, peak: 1 });
++ await db.addTimelineEvents([
++ { stream_id: ss, user_id: screamer, vod_id: null, kind: 'sound', start_sec: 100, end_sec: 101, text: null, label: 'Yell', confidence: 0.8 },
++ { stream_id: ss, user_id: screamer, vod_id: null, kind: 'sound', start_sec: 130, end_sec: 131, text: null, label: 'Screaming', confidence: 0.8 },
++ { stream_id: ss, user_id: screamer, vod_id: null, kind: 'sound', start_sec: 160, end_sec: 161, text: null, label: 'Shout', confidence: 0.8 },
++ { stream_id: ss, user_id: screamer, vod_id: null, kind: 'speech', start_sec: 140, end_sec: 145, text: 'I swear this game is rigged against me every single time', label: null, confidence: 0.9 },
++ ]);
++ // ghost: 90 minutes, nobody watching, nobody typing.
++ await stream(ghost, { startMin: 150, endMin: 60, peak: 0 });
++ // returner: away 20 days, live again.
++ await stream(returner, { startMin: 20 * 1440 + 60, endMin: 20 * 1440, peak: 1 });
++ await stream(returner, { startMin: 30, peak: 1, title: 'back' });
++
++ const first = await drama.detect({ now: NOW });
++ const kinds = (await db.all('SELECT kind, user_id FROM arena_drama ORDER BY id')).map((r) => `${r.kind}:${r.user_id}`);
++ for (const k of [`rage_quit:${rager}`, `title_drama:${rager}`, `viewer_crash:${crasher}`, `chat_explosion:${crasher}`, `meltdown:${screamer}`, `ghost_town:${ghost}`, `the_return:${returner}`]) {
++ assert.ok(kinds.includes(k), `${k} in ${kinds.join(', ')}`);
++ }
++ assert.ok(!kinds.some((k) => k.startsWith('viewer_spike:')), 'no spike from a crash');
++ assert.strictEqual((await drama.detect({ now: NOW })).created, 0, 'one story per event, however often the desk runs');
++ assert.ok(first.created >= 7);
++
++ const rq = await db.get("SELECT * FROM arena_drama WHERE kind = 'rage_quit'");
++ assert.strictEqual(rq.headline, '🚨 rager RAGE QUIT? Stream over after 6 minutes');
++ assert.match(rq.blurb, /usually goes 120 minutes\. Today it lasted 6\. The title was "I'm done with this game"\./);
++ const md = await db.get("SELECT * FROM arena_drama WHERE kind = 'meltdown'");
++ assert.strictEqual(md.quote, 'I swear this game is rigged against me every single time');
++ assert.deepStrictEqual(JSON.parse(md.facts_json), { count: 3, seconds: 60, sounds: { Yell: 1, Screaming: 1, Shout: 1 } });
++ const crash = JSON.parse((await db.get("SELECT facts_json FROM arena_drama WHERE kind = 'viewer_crash'")).facts_json);
++ assert.deepStrictEqual([crash.from, crash.to], [8, 2]);
++
++ // Reactions: one per viewer, changing it replaces it.
++ await drama.react(rq.id, 'w', `user:${viewer}`);
++ let r = await drama.react(rq.id, 'clown', `user:${viewer}`);
++ assert.deepStrictEqual([r.w, r.clown], [0, 1]);
++ await drama.react(rq.id, 'clown', 'ip:abc');
++ const view = await drama.story(rq.id, `user:${viewer}`);
++ assert.deepStrictEqual([view.reactions.clown, view.my_reaction, view.user.username], [2, 'clown', 'rager']);
++ await assert.rejects(drama.react(rq.id, 'heart', 'ip:abc'), /reaction must be one of/);
++
++ // The boards.
++ const cows = await drama.lolcowIndex({ now: NOW });
++ assert.strictEqual(cows[0].user.username, 'rager', 'the rage quitter tops the Lolcow Index');
++ assert.strictEqual(cows[0].meter, 100);
++ const clout = await drama.cloutRankings({ now: NOW });
++ assert.ok(clout.length >= 5 && clout.every((c, i) => c.rank === i + 1));
++ assert.ok(clout.find((c) => c.user.username === 'rager').tags.includes('COOKED'));
++ assert.ok(await db.get('SELECT 1 FROM arena_clout_weeks WHERE week = ?', [drama._test.weekStart(NOW)]), "the week's snapshot is kept for next week's moves");
++
++ // Feed and rap sheet.
++ const feed = await drama.feed({ limit: 50 });
++ assert.ok(feed.length >= 7 && feed.every((s) => s.headline && s.user));
++ const sheet = await drama.rapSheet(rager);
++ assert.deepStrictEqual([sheet.counts.rage_quit, sheet.counts.title_drama, sheet.opted_out], [1, 1, false]);
++
++ // A beef reports itself.
++ const beefId = await drama.beefStory('open', { id: 991, a_user_id: crasher, b_user_id: screamer }, { about: 'his aim', opening_line: 'you could not hit a barn' });
++ const b = await drama.story(beefId);
++ assert.deepStrictEqual([b.kind, b.user.username, b.other.username, b.headline], ['beef_open', 'crasher', 'screamer', '🥩 BEEF: crasher vs screamer']);
++
++ // Opting out hides the old stories and stops new ones.
++ await drama.setOptOut(rager, true);
++ assert.ok(!(await drama.feed({ limit: 50 })).some((s) => s.user.id === rager));
++ assert.strictEqual(await drama.createStory({ kind: 'marathon', userId: rager, dedupeKey: 'marathon:test', facts: { hours: 9, started: 'x' }, happenedAt: NOW }), null);
++ await drama.setOptOut(rager, false);
++ assert.ok(await drama.createStory({ kind: 'marathon', userId: rager, dedupeKey: 'marathon:test', facts: { hours: 9, started: 'x' }, happenedAt: NOW }));
++
++ console.log('arena-drama: all checks passed');
++ process.exit(0);
++})().catch((e) => { console.error(e); process.exit(1); });