openvibers/openvibe.live Commits
Arena cleanup, step 2: server/arena goes (after the N-1 fixture re-record) (#264)
Changes
@@@ -1,115 +1,8 @@--> **The Arena is gone (2026-10-19).** It became [The Rundown](rundown.md): what streamers talked about, written up — no beefs, ratings, Lolcow Index or Clout Rankings. Its jobs are off and `/arena` redirects to `/rundown`; this page describes the old code until it is removed.++# The Arena (retired 2026-10-19)--# Arena — ARENA ALERT (v4, 2026-10-16)++The Arena (`/arena`: ARENA ALERT, the Lolcow Index, Clout Rankings and mic-judged beefs) became **[The Rundown](rundown.md)**: what streamers talked about, written up, with no beefs, ratings or rankings. In the owner's words, the Arena was "kinda lame", and the Lolcow and Clout boards were "cringe".--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.----- **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** (3+ yells/screams/shouts within 2 min on the sound timeline AND a heated line of the streamer's own beside them, which is quoted: the sound timeline hears a game's or a match's crowd too, so noise alone is no story, and there are no tears stories), **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 when they go on (both sides have named each other), their win and forfeit (a forfeit story only when the side that went silent was live while on the clock). 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 (a rewrite that adds an accusing word the template does not use, such as a threat or an attack, is dropped and the template stays), 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, and the story is announced in chat as **ARENA ALERT** (their stream room, mirrored to global chat, while they are live; else global chat; at most 6 an hour).--- **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, 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 fighter's whole name said on mic opens a beef, the other side is on the clock, silence is a forfeit.----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>`.----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----The platform does not censor language. Offensive, provocative, taboo words — slurs included — are allowed, are never filtered, and are never a reason for the judge to score low. The only lines that don't count (`server/arena/arena-service.js` `isBannedText`, plus the judge's `flagged`) are **behaviour**: direct threats of violence (`kys`, "I'll kill you"), sexual content about minors, and doxxing (addresses, phone numbers). Those lines are dropped from the feed, quotes and scoring; nothing else is.----## The roster + ratings (`server/arena/arena-service.js`)----The roster is **whoever has been heard**: every streamer with transcribed speech in the last 45 days. A streamer with 9,000 viewers and no transcription is not a fighter. Ratings are 40–99 percentiles across the roster (a roster of one is a flat 70):----| Stat | From | Weight |--|---|---|---|--| **Heat** | average judge score of their mic moments (30 d) | 0.22 |--| **Aim** | callouts + beef hits per hour on mic (30 d) | 0.18 |--| **Kills** | beefs won | 0.16 |--| **Mouth** | share of stream time spent talking | 0.14 |--| **Clapback** | beefs answered on the clock ÷ beefs they were called out in | 0.12 |--| **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). 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).----## The mic ledger (`server/arena/mic.js`)----`arena_mic_moments` — one row per judged line: `kind` (`trash` for free-standing shit talk, `beef_hit` when it fed a beef), `target_user_id` + `beef_id` for callouts, `aimed_at` (free text: "chat", "the mods", a name…), `text`, `about`, `quality`, `announcer`, VOD id + second, `said_at`. `feed()` is the live feed; `micStats(userId)` is what the ratings read.----**XP → Trash Level** (`arena_trash_levels`, level = 1 + XP ÷ 50): `trash` moment = quality × 0.8 · beef hit = quality · beef open +5 · beef win +40 (+20 upset) · hype received +1. Nothing else pays XP. The best line on record (highest quality) sticks to the profile.----## The ears (`server/arena/listener.js`)----Every 15 s, for every roster stream that is live **and** has a transcript line in the last 30 min:----1. New speech lines are read.--2. **Name detection** (`server/arena/names.js`) — a fighter is named only when their **whole** username or display name is said as it is spelled (camelCase/snake split, leet undone and spacing are fine: "mnss 22741", "grizzly bear"); pieces of a handle ("fix it" of finditfixit, "mnss"), nicknames (fighter name, `spoken_as`, a handle's first word) and fuzzy or phonetic sound-alikes never count (`findMentions(…, { wholeOnly: true })`). On 2026-10-18, 46 of the first 51 beefs had no whole name said (a League stream's "Minions" had matched mnss22741 phonetically); they were voided (`status = 'void'`, out of every record). The speaker never matches themselves.--3. **Focus lock** — a name-drop locks the ears on that fighter for 2 min; everything said afterwards goes to the **beef judge** with the context of what was already said (`about_target`, `aimed_at_target`, quality, best verbatim line, announcer call). A hit, only in lines that say the target's name (what follows is context: a game's voice lines after one transcribed "Adam" once scored 107–0), → `beef.recordHit()`; every hit extends the lock 3 min (cap 20 min without a fresh name-drop); two off-target chunks drop it; a different name switches targets.--4. **Free talk** — lines said while not locked pool for the **mic judge** (≥ 20 words, ≥ 30 s between calls per stream): `is_trash_talk`, quality, best line, `aimed_at`, announcer. Quality ≥ 4 → a `trash` moment in the feed. An `aimed_at` that is a roster fighter is dropped (nobody named them; a whole name said would have locked the ears instead), so free talk never opens a beef.--5. One judge call per stream per tick; keyword heuristics when AI is off.----`/arena/live/<username>` shows exactly what the ears hear: hot mic lines, the lock and its context, the last beef and mic judgements, their recent lines in the feed, open beefs.----## Beefs (`server/arena/beef.js`)----| Rule | Value |--|---|---|--| Opens when | the beef judge confirms ≥ 20 words aimed at a fighter after their whole name was said: that makes it **pending**, told only to the named fighter and shown nowhere; it **opens** (public, ARENA ALERT, chat) when they answer on their own stream naming the caller, and an unanswered callout ends **expired** with no forfeit, story or record (one-sided matches were greetings and mistranscriptions, 2026-10-18) |--| Clock | the other side must answer on their own cam within **15 min if live**, **24 h if offline** (an offline clock tightens to 15 min the moment they go live) |--| Answer | any judged hit from the side on the clock; every hit flips the clock |--| Forfeit | clock runs out → the silent side loses |--| Hard end | 24 h after opening → higher **total** wins (`total = Σ hit quality + crowd hype (max 10)`), equal = draw |--| Upset | winner ranked ≥ 4 places below the loser (+20 XP) |--| Rematch | same pair again → flagged, with the rivalry's record and **receipts** (best lines from earlier beefs) |--| Crowd | `!hype` / Hype button: one per person per side — the only thing chat can do |----Every hit is also written to the mic ledger as a `beef_hit` moment. Headlines (open + result) are AI-written with templates as fallback; the announcer's one-liner per hit comes from the judge.----## Pages----`/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>` — 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.----## Chat commands (`server/arena/arena-chat.js`)----`!hype` (the streamer's newest open beef) · `!beef` · `!arena [user]`. One command per person every 4 s. `!topic`, `!bounty` and `!board` no longer exist.----## API (`/api/arena`)----| Method | Path | Notes |--|---|---|---|--| GET | `/status` · `/fighters` · `/fighters/:user` · `/fighters/:user/stat/:stat` · `/live` | roster (`stats`, `stat_meta`, per-fighter `mic`, `last_line`), card (+ `beefs`, `rivalries`, `moments`, `best_lines`, `mic`), drill-down, live cams (`ears`, `last_moment`, `open_beefs`) |--| GET | `/feed?limit&since` | the shit-talk feed |--| POST | `/fighters/:user/refresh` | admin — regenerate persona |--| GET | `/console/:user` | the ears: listener state, hot mic, level, mic stats, open beefs, recent moments |--| GET | `/beefs` · `/beefs/:id` · POST `/beefs/:id/hype {side}` | one hype per person per side; anonymous by hashed IP |--| GET | `/levels` | Trash Level ladder |--| GET | `/me` | signed in: fighter brief, level, record, mic stats, open beefs, beefs where you are on the clock, recent moments |--| GET | `/voice/:user?t=<text>` | the line in that user's chat TTS voice (cached on disk + a week in the browser) |----## Settings----| Key | Default | Effect |--|---|---|---|--| `arena_enabled` | `true` | `false` → API 404s |--| `ai_timeline_enabled` | `false` | the audio transcription the whole Arena runs on — must be on |--| `arena_vote_salt` | `JWT_SECRET` | salt for anonymous hype hashing |----Background job (`server/arena/arena-job.js`): personas/portraits every 20 min (bounded, budget-aware) · listener every 15 s · beef clocks every 60 s.----Tests: `node test/arena.test.js` (roster, mic ratings, filter, quotes) · `node test/arena-mic.test.js` (ledger, beefs, listener tick, chat, API) · `node test/arena-voice.test.js` — all on a temp DB, no AI needed.----## Voice----Every 🔊 in the Arena calls `GET /api/arena/voice/<username>?t=<line>` (`server/arena/voice.js`): synthesized once in the streamer's equipped cosmetic chat voice (else their per-identity chat voice), cached on disk and for a week in the browser. `announcer` reads headlines in `arena_announcer_voice`. `arena_voice_daily_max` (2000) caps fresh syntheses per day; per-IP limits are 8/min anonymous, 30/min signed in.++- `/arena` and its pages redirect (301) to `/rundown`, and a fighter's `/arena/<username>` goes to `/rundown/<username>`.++- Chat still forwards the Arena's commands. `!arena [user]` answers with the streamer's latest episode and topics, and `!hype` and `!beef` say beefs are gone (`server/rundown/chat-commands.js`).++- The Rundown kept the Arena's opt-outs (`arena_opt_outs`), whole-name matching (`server/rundown/names.js`) and the line-in-their-chat-voice player (`server/rundown/voice.js`, setting `arena_voice_daily_max`).++- The Arena's pages, styles, jobs, `/api/arena` and AI portraits were removed on 2026-10-19. Its tables (roster, mic moments, beefs, drama, trash levels) are dropped by a later contract migration. Until then, account export and deletion still cover them (`server/auth/account-data.js`).@@@ -909,7 +909,7 @@ router.post('/tts-voice/preview', permissions.requireGlobalMod, async (req, res)voice: req.body?.voice, pitch: req.body?.pitch, speed: req.body?.speed, gap: req.body?.gap,});if (!result?.audio) return res.status(503).json({ error: 'TTS unavailable (espeak-ng not installed?)' });-- let url = null; try { const stashed = require('../arena/voice').stash(result.audio, result.mimeType); if (stashed) url = `/api/tts/audio/${stashed.file}`; } catch { /* */ }++ let url = null; try { const stashed = require('../rundown/voice').stash(result.audio, result.mimeType); if (stashed) url = `/api/tts/audio/${stashed.file}`; } catch { /* */ }res.json({ audio: result.audio, mimeType: result.mimeType || 'audio/wav', url });} catch (err) {console.error('[Mod] tts-voice preview error:', err.message);@@@ -32,7 +32,6 @@ const KIND_TO_WORKFLOW = {ai_viewers_director: 'live.viewers.plan', ai_viewers_reply: 'live.viewers.reply', ai_viewers: 'live.viewers.line', ai_viewers_fold: 'live.viewers.fold',chat_global: 'live.chat.insight', chat_user: 'live.chat.insight', chat_relay: 'live.chat.insight', chat_anon: 'live.chat.insight', combined_overview: 'live.chat.insight', session_titles: 'live.chat.insight',moment_vod_rank: 'live.moments.rank', moment_pick: 'live.moments.pick', auto_clip_confirm: 'live.clips.confirm', hero_slogans: 'live.hero.slogans', easter_egg: 'live.easter_egg', home_star: 'live.home.star',-- arena_persona: 'live.arena.persona', arena_quotes: 'live.arena.quotes', arena_scene: 'live.arena.scene', arena_beef_judge: 'live.arena.judge', arena_mic_judge: 'live.arena.judge', arena_headline: 'live.arena.headline',status_check: 'live.status_check',};const ROLES = ['chat', 'vision', 'director', 'summary', 'legacy'];ddeleted file mode 100644@@@ -1,59 +0,0 @@--/**-- * OpenVibe.Live — The Rundown's chat commands (the Arena's three, which Chat still forwards to-- * /internal/chat-effects/arena-command; the Arena became The Rundown on 2026-10-19).-- *-- * !arena [user] what the streamer (or user) has been talking about on The Rundown + a link-- * !hype, !beef beefs are gone: a pointer to The Rundown-- *-- * Called from ChatServer.handleBangCommand; returns true when the command was handled. Replies go to-- * the sender as a system line.-- */--'use strict';----const db = require('../db/database');----const COMMANDS = ['!hype', '!beef', '!arena'];--const RATE_MS = 4000;--const _last = new Map(); // voterKey → ms----function voterKey(client) {-- if (client.user?.id) return `user:${client.user.id}`;-- if (client.anonId) return `anon:${client.anonId}`;-- return `ip:${String(client.ip || '')}`;--}--function limited(map, key, ms) {-- const now = Date.now();-- if (now - (map.get(key) || 0) < ms) return true;-- map.set(key, now);-- return false;--}--function base() { try { const c = require('../config'); return String(c.baseUrl || '').replace(/\/$/, ''); } catch { return ''; } }----async function handle(chat, ws, client, cmd, parts) {-- if (!COMMANDS.includes(cmd)) return false;-- const reply = (message) => chat.sendTo(ws, { type: 'system', message });-- if (limited(_last, voterKey(client), RATE_MS)) { reply('Easy — one command every few seconds.'); return true; }-- const work = (async () => {-- try {-- if (cmd !== '!arena') return reply(`Beefs are gone. The Rundown writes up what streamers talk about instead → ${base()}/rundown`);-- const stream = client.streamId ? await db.getStreamById(client.streamId) : null;-- const target = parts[1] ? await db.get('SELECT id, username, display_name FROM users WHERE lower(username) = lower(?)', [String(parts[1]).replace(/^@/, '')])-- : (stream ? await db.get('SELECT id, username, display_name FROM users WHERE id = ?', [stream.user_id]) : null);-- if (!target) return reply(parts[1] ? `Nobody called ${String(parts[1]).slice(0, 30)} here.` : `What streamers talked about, written up → ${base()}/rundown`);-- const read = require('../rundown/read');-- const page = await read.streamer(target.username);-- const name = target.display_name || target.username;-- if (!page || page.opted_out) return reply(`${name} keeps themselves out of The Rundown.`);-- const ep = page.episodes[0];-- const topics = page.topics.slice(0, 3).map((t) => t.title);-- if (!ep && !topics.length) return reply(`Nothing on The Rundown about ${name} yet: it takes talk on a transcribed stream → ${base()}/rundown`);-- return reply(`📰 ${name}${ep ? ` — latest episode: “${ep.title}”` : ''}${topics.length ? ` · talks about ${topics.join(', ')}` : ''} → ${base()}/rundown/${encodeURIComponent(target.username)}`);-- } catch (err) {-- reply(`The Rundown: ${err.message}`);-- }-- })();-- if (typeof chat.track === 'function') chat.track(work);-- return true;--}----module.exports = { handle, COMMANDS, _voterKey: voterKey };ddeleted file mode 100644@@@ -1,64 +0,0 @@--/**-- * arena-job.js — keeps Arena personas (and, when enabled, portraits) warm for the roster-- * so the tab loads instantly instead of generating on first view.-- *-- * Every 20 min: refresh up to BATCH stale personas (24 h TTL) for the highest-ranked-- * fighters first, and up to IMAGE_BATCH stale portraits (7 d TTL). Bounded per tick so a-- * big roster cannot burn the AI budget in one go; no-ops entirely while AI is off or over-- * budget (the shared gate in server/ai/llm.js).-- */--'use strict';----const arena = require('./arena-service');----const INTERVAL_MS = 20 * 60 * 1000;--const CLOCK_MS = 60 * 1000;--let _timer = null;--let _clock = null;--let _busy = false;----// Personas and portraits are no longer generated: the Arena shows only what was said on mic.--// The periodic tick now judges past speech (backfill) so the feed reflects the record, and--// catches up anything the live listener missed.--async function tick() {-- if (_busy || !await arena.arenaEnabled() || !await arena.aiOn()) return;-- _busy = true;-- try { await require('./backfill').run(); } catch (e) { console.warn('[Arena] backfill:', e.message); }-- finally { _busy = false; }--}----async function start() {-- if (_timer) return;-- await arena.ensureTables();-- _timer = setInterval(() => tick().catch(e => console.warn('[Arena] job:', e.message)), INTERVAL_MS);-- if (_timer.unref) _timer.unref();-- setTimeout(() => tick().catch(() => {}), 90_000).unref?.();-- // The ears: every 15 s the listener reads live transcripts — name-drops → beef judge, free talk → mic judge.-- try { require('./listener').start(); } catch (e) { console.warn('[Arena] listener not started:', e.message); }-- // Clocks: forfeit beefs whose clock ran out, hard-end the ones past 24 h.-- _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 (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); }--}----function stop() {-- if (_timer) { clearInterval(_timer); _timer = null; }-- if (_clock) { clearInterval(_clock); _clock = null; }-- try { require('./listener').stop(); } catch { /* */ }--}----module.exports = { start, stop, tick, housekeeping };ddeleted file mode 100644@@@ -1,507 +0,0 @@--/**-- * OpenVibe.Live — Arena (roster, ratings, personas, portraits, quotes) — Battle Cam mode-- *-- * The Arena is PURE MIC. Viewer counts, chat volume, followers, clips and tips do not exist-- * here; the only input is what a streamer says into the microphone (the continuous audio-- * transcription in stream_timeline_events), judged by listener.js and stored by mic.js.-- * This module owns the roster:-- *-- * roster → every streamer with transcribed speech in the last ACTIVE_DAYS days.-- * stats → seven 40–99 ratings, all from the mic ledger, as percentiles across the-- * roster: HEAT (how good the shit talk is), AIM (callouts + beef hits per-- * hour on mic), KILLS (beef wins), MOUTH (share of stream time talking),-- * CLAPBACK (answering when called out), STAMINA (minutes of speech), PACE-- * (words per minute) — plus an overall POWER carrying the mouth bonus-- * (recent XP + beef wins).-- * persona → AI "character select" bio written from their TRANSCRIPTS, cached 24 h.-- * quotes → AI-picked "things they actually said" from the transcripts, VOD-linked.-- * image → a character portrait drawn earlier from their own stream frames (no new ones).-- *-- * Nothing here touches the AI unless AI is enabled and within budget (server/ai/llm.js).-- */--'use strict';----const fs = require('fs');--const path = require('path');--const crypto = require('crypto');--const db = require('../db/database');--const llm = require('../ai/llm');----const paths = require('../paths');--const ARENA_DIR = paths.dir('ARENA_IMAGE_PATH', 'arena');--const ACTIVE_DAYS = 45;--const STATS_WINDOW_DAYS = 90;--const PERSONA_TTL_MS = 24 * 60 * 60 * 1000;--const QUOTES_TTL_MS = 24 * 60 * 60 * 1000;--const RATING_MIN = 40;--const MIN_QUOTE_LINES = 20;--const TALK_BONUS_MAX = 12;--const MIC_WINDOW_DAYS = 30;--const STAT_KEYS = ['heat', 'aim', 'kills', 'mouth', 'clapback', 'stamina', 'pace'];--const STAT_META = {-- heat: { label: 'Heat', desc: 'how good the shit talk is — average judge score of their mic moments (30d)' },-- aim: { label: 'Aim', desc: 'callouts + beef hits per hour on mic (30d)' },-- kills: { label: 'Kills', desc: 'beefs won' },-- mouth: { label: 'Mouth', desc: 'share of stream time spent talking' },-- clapback: { label: 'Clapback', desc: 'answering when called out — beefs answered on the clock' },-- stamina: { label: 'Stamina', desc: 'minutes of speech heard (90d)' },-- pace: { label: 'Pace', desc: 'words per minute on mic' },--};--const STAT_WEIGHTS = { heat: 0.22, aim: 0.18, kills: 0.16, mouth: 0.14, clapback: 0.12, stamina: 0.10, pace: 0.08 };--const HYPE_PATTERNS = ["let's go", 'lets go', 'no way', 'oh my god', 'insane', 'clutch', 'holy', 'gg', 'unreal', 'what the', 'bro', 'chat,', 'chat ', 'yo ', 'welcome', 'lfg', 'poggers', 'pog'];----// ── Behavior line (NOT a vocabulary filter) ────────────────--// This platform does not censor words: offensive, controversial, provocative and taboo--// language — slurs included — is allowed and is never a reason to hide transcript text--// or void an Arena entry. What is filtered is BEHAVIOR, not speech: direct threats of--// violence, incitement to self-harm, sexual content involving minors, and doxxing.--// Matched on a lightly normalised copy (lower-case, leetspeak folded, punctuation--// stripped). Applied before model calls on transcript text and before rendering.--const BANNED_PHRASES = [-- /\bkill\s+(?:your|ur)\s*self\b/i, /\bkys\b/i, /\bgo\s+(?:die|hang\s+yourself|drink\s+bleach)\b/i,-- /\b(?:i'?ll|i will|i'?m gonna|i am going to|gonna|going to|we'?ll|we will)\s+(?:kill|shoot|stab|murder|beat\s+(?:you|him|her|them)\s+to\s+death|find\s+and\s+kill|burn)\s+(?:you|him|her|them|your\s+family|his\s+family|her\s+family)\b/i,-- /\b(?:child|kid|minor|underage|preteen)\s*(?:porn|sex|nude|nudes)\b/i, /\bcp\s+(?:link|links|pics|vid|vids)\b/i, /\b(?:molest|rape)\s+(?:a\s+|that\s+|the\s+)?(?:kid|child|minor|baby)\b/i,-- /\b(?:home|house)\s+address\s+is\s+\d/i, /\b(?:his|her|their|your)\s+(?:real\s+)?(?:home\s+)?(?:address|phone\s+number|social\s+security(?:\s+number)?)\s+is\s+(?:\d|[a-z]+\s+\d)/i, /\bdox+(?:ed|ing|x)?\s+(?:him|her|them|you)\b/i,--];--function normalizeForFilter(text) {-- return String(text || '').toLowerCase()-- .replace(/[1!]/g, 'i').replace(/3/g, 'e').replace(/[4@]/g, 'a').replace(/0/g, 'o').replace(/\$/g, 's')-- .replace(/[^a-z\s']/g, ' ').replace(/\s+/g, ' ');--}--function isBannedText(text) {-- const raw = String(text || '');-- const norm = normalizeForFilter(raw);-- return BANNED_PHRASES.some(re => re.test(raw) || re.test(norm));--}----// ── Tables ───────────────────────────────────────────────────----let _tablesReady = false;--function ensureTables() {-- if (_tablesReady) return;-- try { fs.mkdirSync(ARENA_DIR, { recursive: true }); } catch { /* */ }-- _tablesReady = true;--}----// ── Settings ─────────────────────────────────────────────────----async function setting(key, fallback = '') {-- try { const v = await db.getSetting(key); return v === undefined || v === null || v === '' ? fallback : v; } catch { return fallback; }--}--async function boolSetting(key, fallback = false) {-- const v = await setting(key, null);-- if (v === null) return fallback;-- return v === true || v === 'true' || v === 1 || v === '1';--}--async function arenaEnabled() { return await boolSetting('arena_enabled', true); }--async function aiOn() { try { return await llm.isEnabled() && await llm.withinBudget(); } catch { return false; } }--function imageGenAvailable() { return false; } // see "Image" below----// ── Raw stats ────────────────────────────────────────────────----/** The roster is whoever has been HEARD: transcribed speech in the last ACTIVE_DAYS days. */--async function activeStreamerIds() {-- return (await db.all(`-- SELECT DISTINCT e.user_id FROM stream_timeline_events e JOIN users u ON u.id = e.user_id-- WHERE e.kind = 'speech' AND e.user_id IS NOT NULL AND e.created_at >= datetime('now', ?) AND COALESCE(u.is_banned, 0) = 0-- `, [`-${ACTIVE_DAYS} days`])).map(r => r.user_id);--}----async function voiceStatsFor(userId, win) {-- const hypeSql = HYPE_PATTERNS.map(() => "CASE WHEN text ILIKE ? THEN 1 ELSE 0 END").join(' + ');-- const hypeParams = HYPE_PATTERNS.map(p => `%${p}%`);-- let speech = {};-- try {-- speech = await db.get(`-- SELECT COUNT(*) AS lines,-- COALESCE(SUM(COALESCE(end_sec, start_sec + 3) - start_sec), 0)::bigint AS speech_sec,-- COALESCE(SUM(LENGTH(text) - LENGTH(REPLACE(text, ' ', '')) + 1), 0)::bigint AS words,-- COUNT(*) FILTER (WHERE text ILIKE '%!%') AS exclaims,-- COUNT(*) FILTER (WHERE text ILIKE '%?%') AS questions,-- COALESCE(SUM(${hypeSql}), 0)::bigint AS hype_hits,-- COUNT(DISTINCT stream_id) AS streams_heard-- FROM stream_timeline_events-- WHERE user_id = ? AND kind = 'speech' AND created_at >= datetime('now', ?)-- `, [...hypeParams, userId, win]) || {};-- } catch { speech = {}; }-- let covered = 0, laughs = 0, topSounds = [];-- try {-- covered = (await db.get(`SELECT COALESCE(SUM(duration_seconds), 0)::bigint AS sec FROM streams WHERE user_id = ? AND duration_seconds > 0 AND id IN (SELECT DISTINCT stream_id FROM stream_timeline_events WHERE user_id = ? AND created_at >= datetime('now', ?))`, [userId, userId, win]))?.sec || 0;-- laughs = (await db.get(`SELECT COUNT(*) AS n FROM stream_timeline_events WHERE user_id = ? AND kind = 'sound' AND (LOWER(label) ILIKE '%laugh%' OR LOWER(label) ILIKE '%giggle%' OR LOWER(label) ILIKE '%chuckle%') AND created_at >= datetime('now', ?)`, [userId, win]))?.n || 0;-- topSounds = await db.all(`SELECT label, COUNT(*) AS n FROM stream_timeline_events WHERE user_id = ? AND kind = 'sound' AND label IS NOT NULL AND created_at >= datetime('now', ?) GROUP BY label ORDER BY n DESC LIMIT 5`, [userId, win]);-- } catch { /* */ }-- const speechSec = Number(speech.speech_sec) || 0;-- const coveredHours = Math.max(covered / 3600, 0.05);-- const speechMin = Math.max(speechSec / 60, 0.1);-- const talkRatio = covered > 0 ? Math.min(1, speechSec / covered) : 0;-- const wpm = speech.words ? speech.words / speechMin : 0;-- const hypePerHour = (speech.hype_hits || 0) / coveredHours;-- const laughsPerHour = laughs / coveredHours;-- const hasData = (speech.lines || 0) >= 5;-- return {-- has_data: hasData, lines: speech.lines || 0, streams_heard: speech.streams_heard || 0,-- speech_minutes: Number((speechSec / 60).toFixed(1)), talk_ratio_pct: Number((talkRatio * 100).toFixed(1)),-- wpm: Number(wpm.toFixed(0)), words: speech.words || 0, exclaims: speech.exclaims || 0, questions: speech.questions || 0,-- hype_hits: speech.hype_hits || 0, hype_per_hour: Number(hypePerHour.toFixed(1)), laughs, laughs_per_hour: Number(laughsPerHour.toFixed(1)),-- top_sounds: topSounds.map(s => ({ label: s.label, n: s.n })),-- voice_score: hasData ? Number((talkRatio * 100 + Math.min(hypePerHour, 60) * 0.5 + Math.min(laughsPerHour, 30) + Math.min(wpm, 200) / 20).toFixed(2)) : 0,-- };--}----/** Everything the ratings are built from: the transcripts (voice) + the mic ledger (mic). No audience numbers. */--async function rawStatsFor(userId) {-- const win = `-${STATS_WINDOW_DAYS} days`;-- const agg = await db.get(`SELECT COUNT(*) AS streams, (COALESCE(SUM(duration_seconds), 0) / 3600.0)::float8 AS hours, MAX(ended_at) AS last_live_at FROM streams WHERE user_id = ? AND duration_seconds > 0 AND started_at >= datetime('now', ?)`, [userId, win]) || {};-- const category = (await db.get(`SELECT COALESCE(NULLIF(ai_category, ''), category) AS category, COUNT(*) AS n FROM streams WHERE user_id = ? AND duration_seconds > 0 AND COALESCE(NULLIF(ai_category, ''), category) IS NOT NULL AND COALESCE(NULLIF(ai_category, ''), category) != '' GROUP BY 1 ORDER BY n DESC LIMIT 1`, [userId]))?.category || null;-- let micStats = {};-- try { micStats = await require('./mic').micStats(userId, MIC_WINDOW_DAYS); } catch { micStats = {}; }-- return {-- window_days: STATS_WINDOW_DAYS, mic_window_days: MIC_WINDOW_DAYS, streams: agg.streams || 0, hours: Number((agg.hours || 0).toFixed(1)),-- last_live_at: agg.last_live_at || null, category, voice: await voiceStatsFor(userId, win), mic: micStats,-- };--}----const METRIC_FOR_STAT = {-- heat: (r) => (r.mic ? r.mic.avg_quality : 0),-- aim: (r) => (r.mic ? r.mic.hits_per_mic_hour : 0),-- kills: (r) => (r.mic ? r.mic.wins : 0),-- mouth: (r) => (r.voice ? r.voice.talk_ratio_pct : 0),-- clapback: (r) => (r.mic ? r.mic.clapback_rate : 0),-- stamina: (r) => (r.voice ? r.voice.speech_minutes : 0),-- pace: (r) => (r.voice ? r.voice.wpm : 0),--};----/** Percentile ratings across a roster: rating = 40 + 59 × percentile; a roster of one is a flat 70. */--function computeRatings(rosterRaw) {-- const ids = Object.keys(rosterRaw);-- const out = {};-- for (const id of ids) out[id] = {};-- for (const stat of STAT_KEYS) {-- const values = ids.map(id => METRIC_FOR_STAT[stat](rosterRaw[id]) || 0);-- const sorted = [...values].sort((a, b) => a - b);-- ids.forEach((id, i) => {-- const v = values[i];-- let pct;-- if (ids.length < 2) pct = 0.5;-- else { const below = sorted.filter(x => x < v).length; const equal = sorted.filter(x => x === v).length; pct = (below + (equal - 1) / 2) / (ids.length - 1); }-- out[id][stat] = Math.round(RATING_MIN + (99 - RATING_MIN) * Math.max(0, Math.min(1, pct)));-- });-- }-- for (const id of ids) out[id].power = Math.round(STAT_KEYS.reduce((sum, k) => sum + out[id][k] * STAT_WEIGHTS[k], 0));-- return out;--}----/** Trash Talk bonus on POWER: recent XP (7 days) + beef wins, capped. */--async function talkBonus(userId) {-- try {-- const mic = require('./mic'), beef = require('./beef');-- return Math.min(TALK_BONUS_MAX, Math.round(await mic.recentXp(userId) / 25) + await beef.recentWins(userId) * 3);-- } catch { return 0; }--}----// ── Roster cache ─────────────────────────────────────────────----let _roster = null;--const ROSTER_TTL_MS = 3 * 60 * 1000;----async function loadRoster(force = false) {-- if (!force && _roster && Date.now() - _roster.at < ROSTER_TTL_MS) return _roster;-- await ensureTables();-- const ids = await activeStreamerIds();-- const rawById = {};-- for (const id of ids) rawById[id] = await rawStatsFor(id);-- const ratings = computeRatings(rawById);-- const byId = {};-- for (const id of ids) {-- const user = await db.getUserById(id);-- if (!user) continue;-- const bonus = await talkBonus(id);-- ratings[id].base_power = ratings[id].power;-- ratings[id].talk_bonus = bonus;-- ratings[id].power = Math.min(99 + TALK_BONUS_MAX, ratings[id].power + bonus);-- byId[id] = { user: publicUser(user), raw: rawById[id], ratings: ratings[id] };-- try { await db.run('INSERT INTO arena_profiles (user_id, stats_json, updated_at) VALUES (?, ?, ov_now()) ON CONFLICT(user_id) DO UPDATE SET stats_json = excluded.stats_json, updated_at = ov_now()', [id, JSON.stringify({ raw: rawById[id], ratings: ratings[id] })]); } catch { /* */ }-- }-- const order = Object.keys(byId).map(Number).sort((a, b) => byId[b].ratings.power - byId[a].ratings.power || a - b);-- _roster = { at: Date.now(), byId, order };-- return _roster;--}----function publicUser(u) {-- return { id: u.id, username: u.username, display_name: u.display_name || u.username, avatar_url: u.avatar_url || null, profile_color: u.profile_color || null, bio: u.bio || '' };--}----// ── Persona (AI) ─────────────────────────────────────────────----async function profileRow(userId) { await ensureTables(); return await db.get('SELECT * FROM arena_profiles WHERE user_id = ?', [userId]) || null; }--function parseJson(text, fallback = null) { if (!text) return fallback; try { return JSON.parse(text); } catch { return fallback; } }--function freshWithin(ts, ttl) { return !!ts && Date.now() - Date.parse(ts + 'Z') < ttl; }--function personaIsFresh(row) { return !!(row && row.persona_json) && freshWithin(row.persona_generated_at, PERSONA_TTL_MS); }--function quotesAreFresh(row) { return !!(row && row.quotes_json) && freshWithin(row.quotes_generated_at, QUOTES_TTL_MS); }----async function gatherContext(userId) {-- const ctx = {};-- // Pure mic: the voice comes from what they SAY. Their most recent lines, their spiciest judged-- // lines, who they have called out, their beef record and what the camera saw are the facts.-- try { ctx.overview = (await db.getStreamerOverview(userId))?.overview || null; } catch { /* */ }-- try { ctx.memories = (await db.all('SELECT description FROM stream_memories WHERE user_id = ? ORDER BY captured_at DESC LIMIT 4', [userId])).map(m => m.description).filter(Boolean); } catch { ctx.memories = []; }-- try { ctx.titles = (await db.all('SELECT title FROM streams WHERE user_id = ? AND duration_seconds > 0 GROUP BY title ORDER BY MAX(started_at) DESC LIMIT 8', [userId])).map(r => r.title).filter(Boolean); } catch { ctx.titles = []; }-- try { ctx.said = (await db.all(`SELECT text FROM stream_timeline_events WHERE user_id = ? AND kind = 'speech' AND LENGTH(text) BETWEEN 30 AND 160 ORDER BY created_at DESC LIMIT 60`, [userId])).map(r => r.text).filter(t => !isBannedText(t)).slice(0, 30); } catch { ctx.said = []; }-- try {-- const mic = require('./mic');-- ctx.shit_talk = (await mic.bestLines(userId, 8)).map(m => `${m.text}${m.aimed_at ? ` [at ${m.aimed_at}]` : ''} (${m.quality}/10)`);-- ctx.called_out = (await Promise.all((await db.all(`SELECT target_user_id, COUNT(*) AS n FROM arena_mic_moments WHERE user_id = ? AND target_user_id IS NOT NULL GROUP BY target_user_id ORDER BY n DESC LIMIT 5`, [userId])).map(async r => `${await mic.nameOf(r.target_user_id)} (${r.n}×)`)));-- ctx.called_out_by = (await Promise.all((await db.all(`SELECT user_id, COUNT(*) AS n FROM arena_mic_moments WHERE target_user_id = ? GROUP BY user_id ORDER BY n DESC LIMIT 5`, [userId])).map(async r => `${await mic.nameOf(r.user_id)} (${r.n}×)`)));-- ctx.aimed_at = (await db.all(`SELECT aimed_at, COUNT(*) AS n FROM arena_mic_moments WHERE user_id = ? AND aimed_at IS NOT NULL AND target_user_id IS NULL GROUP BY aimed_at ORDER BY n DESC LIMIT 6`, [userId])).map(r => `${r.aimed_at} (${r.n}×)`);-- } catch { ctx.shit_talk = []; ctx.called_out = []; ctx.called_out_by = []; ctx.aimed_at = []; }-- try { const q = parseJson((await profileRow(userId))?.quotes_json); ctx.quotes = q && Array.isArray(q.picks) ? q.picks.map(p => p.text).slice(0, 6) : []; ctx.mic_style = q?.mic_style || null; } catch { ctx.quotes = []; }-- return ctx;--}------async function generatePersona(userId, { force = false } = {}) {-- await ensureTables();-- const row = await profileRow(userId);-- if (!force && personaIsFresh(row)) return parseJson(row.persona_json);-- if (!await aiOn()) return row ? parseJson(row.persona_json) : null;-- const roster = await loadRoster();-- const entry = roster.byId[userId] || { user: publicUser(await db.getUserById(userId) || { id: userId, username: `user${userId}` }), raw: await rawStatsFor(userId), ratings: null };-- const ctx = await gatherContext(userId);-- const stats = entry.ratings || Object.fromEntries(STAT_KEYS.map(k => [k, 70]).concat([['power', 70]]));-- const m = entry.raw.mic || {};-- const facts = {-- name: entry.user.display_name, handle: entry.user.username, category: entry.raw.category, ratings: stats,-- numbers: { hours_live_90d: entry.raw.hours,-- on_mic: entry.raw.voice.has_data ? { talk_share_pct: entry.raw.voice.talk_ratio_pct, words_per_minute: entry.raw.voice.wpm, speech_minutes: entry.raw.voice.speech_minutes, hype_words_per_hour: entry.raw.voice.hype_per_hour, laughs_per_hour: entry.raw.voice.laughs_per_hour, stream_sounds: entry.raw.voice.top_sounds.map(s => s.label) } : 'no transcript data yet',-- shit_talk_30d: { judged_moments: m.moments || 0, average_quality: m.avg_quality || 0, bangers_7_plus: m.bangers || 0, beef_hits: m.beef_hits || 0, beefs_won: m.wins || 0, beefs_lost: m.losses || 0, times_called_out: m.targeted || 0, times_answered: m.answered || 0 } },-- ai_overview: ctx.overview, recent_stream_titles: ctx.titles, what_the_camera_saw_recently: ctx.memories, things_they_said_on_stream: ctx.said,-- their_best_shit_talk: ctx.shit_talk || [], who_they_call_out: ctx.called_out || [], who_calls_them_out: ctx.called_out_by || [], what_they_rant_at: ctx.aimed_at || [], quotes: ctx.quotes || [], mic_style: ctx.mic_style || null,-- roster_rivals: await (async () => { try { return (await Promise.all((await loadRoster()).order.filter(id => id !== userId).slice(0, 8).map(async id => { const p = parseJson((await profileRow(id))?.persona_json); return `${(await loadRoster()).byId[id].user.username}${p?.fighter_name ? ` (${p.fighter_name})` : ''}`; }))); } catch { return []; } })(),-- };-- // The bio's rules and shape are OpenVibe.AI's versioned template live.arena.persona (WS-O task 2); Live sends the facts.-- const persona = await require('../ai/ai-service').structured('live.arena.persona', { facts }, { meter: { kind: 'arena_persona', role: 'summary', ownerUserId: userId, source: 'arena' } });-- if (!persona) { console.warn(`[Arena] persona generation failed for user ${userId}`); return row ? parseJson(row.persona_json) : null; }-- await db.run(`INSERT INTO arena_profiles (user_id, persona_json, persona_model, persona_generated_at, updated_at) VALUES (?, ?, ?, ov_now(), ov_now())-- ON CONFLICT(user_id) DO UPDATE SET persona_json = excluded.persona_json, persona_model = excluded.persona_model, persona_generated_at = ov_now(), updated_at = ov_now()`, [userId, JSON.stringify(persona), 'openvibe-ai:live.arena.persona']);-- console.log(`[Arena] persona for ${entry.user.username}: "${persona.fighter_name}" (${persona.class})`);-- return persona;--}----function fallbackPersona(entry) {-- const r = entry.ratings || {};-- const best = STAT_KEYS.reduce((a, b) => ((r[b] || 0) > (r[a] || 0) ? b : a), STAT_KEYS[0]);-- const cls = { heat: 'Rushdown', aim: 'Sniper', kills: 'Assassin', mouth: 'Caster', clapback: 'Counter', stamina: 'Tank', pace: 'Zoner' }[best];-- return {-- fighter_name: entry.user.display_name, title: `The ${STAT_META[best].label} Specialist`, class: cls,-- element: entry.raw.category ? entry.raw.category.replace(/[-_]/g, ' ') : 'Static',-- signature_move: { name: `${STAT_META[best].label} Surge`, description: `Turns ${STAT_META[best].desc} into raw damage.` },-- special: { name: 'Hot Mic', description: 'Forgets the mic is on. That is the move.' }, weakness: 'Dead air.', taunt: 'Say my name on stream and see what happens.',-- lore: `${entry.user.display_name} shows up, talks, and leaves the ladder slightly different than they found it.`,-- catchphrase: 'Let him cook.', entrance_music: 'Untitled Loop (feat. Notification Sound)', taunts: [], typing_style: null, spoken_as: [], custom_stats: [],-- stat_quips: Object.fromEntries(STAT_KEYS.map(k => [k, STAT_META[k].desc])), _fallback: true,-- };--}----// ── Quotes ───────────────────────────────────────────────────----async function quoteCandidates(userId, limit = 90) {-- let rows = [];-- try { rows = await db.all(`SELECT id, stream_id, vod_id, start_sec, text FROM stream_timeline_events WHERE user_id = ? AND kind = 'speech' AND created_at >= datetime('now', ?) AND LENGTH(text) BETWEEN 25 AND 220 ORDER BY created_at DESC LIMIT 1500`, [userId, `-${STATS_WINDOW_DAYS} days`]); } catch { rows = []; }-- const score = (t) => { const s = t.toLowerCase(); let n = (t.match(/!/g) || []).length * 2 + (t.match(/\?/g) || []).length; for (const p of HYPE_PATTERNS) if (s.includes(p)) n += 3; if (/\b(i|we|you|chat)\b/.test(s)) n += 1; if (/\b(um+|uh+|like like)\b/.test(s)) n -= 1; return n; };-- const scored = rows.filter(r => !isBannedText(r.text)).map(r => ({ ...r, score: score(r.text) })).sort((x, y) => y.score - x.score);-- const picked = scored.slice(0, Math.ceil(limit / 2));-- const rest = scored.slice(Math.ceil(limit / 2));-- const step = Math.max(1, Math.floor(rest.length / Math.max(1, limit - picked.length)));-- for (let i = 0; i < rest.length && picked.length < limit; i += step) picked.push(rest[i]);-- return picked;--}--function materializeQuotes(candidates, sel) {-- const pick = (i, why) => { const c = candidates[i]; return c && !isBannedText(c.text) ? { text: c.text, stream_id: c.stream_id, vod_id: c.vod_id, start_sec: Math.max(0, Math.floor(Number(c.start_sec) || 0) - 2), why } : null; };-- const picks = (sel.picks || []).map(p => pick(p.index, p.why)).filter(Boolean);-- return { picks, walkout: pick(sel.walkout, 'walkout line') || picks[0] || null, voice_verdict: sel.voice_verdict || null, mic_style: sel.mic_style || null };--}--async function generateQuotes(userId, { force = false } = {}) {-- await ensureTables();-- const row = await profileRow(userId);-- if (!force && quotesAreFresh(row)) return parseJson(row.quotes_json);-- const candidates = await quoteCandidates(userId);-- if (candidates.length < MIN_QUOTE_LINES) return null;-- let result = null;-- if (await aiOn()) {-- try {-- // The picking rules are OpenVibe.AI's versioned template live.arena.quotes (WS-O task 2); Live sends the lines.-- const sel = await require('../ai/ai-service').structured('live.arena.quotes', { lines: candidates.map(c => String(c.text).slice(0, 400)).slice(0, 120) }, { meter: { kind: 'arena_quotes', role: 'summary', ownerUserId: userId, source: 'arena' } });-- if (sel && Array.isArray(sel.picks)) result = materializeQuotes(candidates, sel);-- } catch (e) { console.warn('[Arena] quotes:', e.message); }-- }-- if (!result || !result.picks.length) result = materializeQuotes(candidates, { picks: candidates.slice(0, 5).map((c, i) => ({ index: i, why: 'straight from the transcript' })), walkout: 0 });-- if (!await aiOn()) result._fallback = true;-- await db.run(`INSERT INTO arena_profiles (user_id, quotes_json, quotes_generated_at, updated_at) VALUES (?, ?, ov_now(), ov_now()) ON CONFLICT(user_id) DO UPDATE SET quotes_json = excluded.quotes_json, quotes_generated_at = ov_now(), updated_at = ov_now()`, [userId, JSON.stringify(result)]);-- return result;--}----// ── Image ────────────────────────────────────────────────────--// Portraits drawn earlier stay on the cards; no new ones are drawn. They came from the site's image--// provider called with the shared key, and Live calls no model provider itself any more (roadmap--// WS-O task 2); OpenVibe.AI has no image workflow yet.----function imageUrlFor(row) {-- if (!row || !row.image_path) return null;-- const base = path.basename(row.image_path);-- return fs.existsSync(path.join(ARENA_DIR, base)) ? `/data/arena/${base}` : null;--}--async function generateImage(userId) {-- await ensureTables();-- return imageUrlFor(await profileRow(userId));--}----// ── Fighter cards ────────────────────────────────────────────----async function resolveUser(usernameOrId) {-- if (/^\d+$/.test(String(usernameOrId))) return await db.getUserById(Number(usernameOrId));-- return await db.get('SELECT * FROM users WHERE lower(username) = lower(?)', [String(usernameOrId)]);--}--async function isLive(userId) { return !!await db.get('SELECT 1 FROM streams WHERE user_id = ? AND is_live = 1 LIMIT 1', [userId]); }----async function cardFor(userId, roster, { includeRaw = true, includeQuotes = false } = {}) {-- const entry = roster.byId[userId];-- if (!entry) return null;-- const row = await profileRow(userId);-- const persona = parseJson(row?.persona_json) || fallbackPersona(entry);-- const mic = require('./mic'), beef = require('./beef');-- const card = {-- user: entry.user, rank: roster.order.indexOf(userId) + 1, roster_size: roster.order.length,-- ratings: entry.ratings, stat_meta: STAT_META, raw: includeRaw ? entry.raw : undefined, voice: entry.raw.voice, mic: entry.raw.mic || null,-- persona, persona_is_fallback: !!persona._fallback, persona_generated_at: row?.persona_generated_at || null,-- image_url: imageUrlFor(row), image_prompt: row?.image_prompt || null, image_model: row?.image_model || null, image_pending: false,-- record: await beef.recordFor(userId), level: await mic.levelView(userId), live: await isLive(userId),-- };-- if (includeQuotes) card.quotes = parseJson(row?.quotes_json) || null;-- return card;--}----async function getFighter(usernameOrId, { generate = true } = {}) {-- const user = await resolveUser(usernameOrId);-- if (!user) return null;-- const roster = await loadRoster();-- if (!roster.byId[user.id]) return { user: publicUser(user), not_on_roster: true, reason: `nothing heard on mic in the last ${ACTIVE_DAYS} days — the Arena only knows what the transcription hears` };-- // Battle Cam: no persona / portrait / quote generation on view — the page shows only what-- // was said on mic (mic.js). `generate` is kept for the admin refresh route.-- if (generate === 'force') {-- try { await generatePersona(user.id); } catch (e) { console.warn('[Arena] persona:', e.message); }-- }-- const card = await cardFor(user.id, roster, { includeQuotes: true });-- card.image_pending = false;-- card.image_generation = imageGenAvailable() ? 'ai' : 'off';-- try { card.beefs = await require('./beef').forUser(user.id, 8); } catch { card.beefs = []; }-- return card;--}----async function listFighters() {-- const roster = await loadRoster();-- return (await Promise.all(roster.order.map(async id => {-- const c = await cardFor(id, roster, { includeRaw: false });-- return {-- user: c.user, rank: c.rank, ratings: c.ratings, record: c.record, live: c.live, image_url: c.image_url, level: { level: c.level.level, xp: c.level.xp }, mic: c.mic,-- persona: { fighter_name: c.persona.fighter_name, title: c.persona.title, class: c.persona.class, element: c.persona.element, taunt: c.persona.taunt, taunts: c.persona.taunts || [], typing_style: c.persona.typing_style || null, lore: c.persona.lore, signature_move: c.persona.signature_move, stat_quips: c.persona.stat_quips, custom_stats: Array.isArray(c.persona.custom_stats) ? c.persona.custom_stats : [] },-- persona_is_fallback: c.persona_is_fallback, category: roster.byId[id].raw.category, last_live_at: roster.byId[id].raw.last_live_at,-- voice: { has_data: c.voice.has_data, talk_ratio_pct: c.voice.talk_ratio_pct, speech_minutes: c.voice.speech_minutes, wpm: c.voice.wpm },-- last_line: await (async () => { try { const m = await require('./mic').latestFor(id); return m ? { text: m.text, quality: m.quality, aimed_at: m.aimed_at, target: m.target, at: m.at, vod_id: m.vod_id, sec: m.sec } : null; } catch { return null; } })(),-- };-- })));--}----async function getStatDetail(userId, stat) {-- if (!STAT_KEYS.includes(stat)) return null;-- const roster = await loadRoster();-- const entry = roster.byId[userId];-- if (!entry) return null;-- const win = `-${STATS_WINDOW_DAYS} days`;-- let series = [];-- try {-- // One point per stream in the window — every value comes from the transcript / mic ledger of that stream.-- const rows = (await db.all(`-- SELECT s.id, s.title, s.started_at, s.duration_seconds,-- (SELECT COALESCE(SUM(COALESCE(e.end_sec, e.start_sec + 3) - e.start_sec), 0)::bigint FROM stream_timeline_events e WHERE e.stream_id = s.id AND e.kind = 'speech') AS speech_sec,-- (SELECT COALESCE(SUM(LENGTH(e.text) - LENGTH(REPLACE(e.text, ' ', '')) + 1), 0)::bigint FROM stream_timeline_events e WHERE e.stream_id = s.id AND e.kind = 'speech') AS words,-- (SELECT COUNT(*) FROM arena_mic_moments m WHERE m.stream_id = s.id) AS moments,-- (SELECT COALESCE(AVG(m.quality), 0)::float8 FROM arena_mic_moments m WHERE m.stream_id = s.id) AS avg_q,-- (SELECT COUNT(*) FROM arena_mic_moments m WHERE m.stream_id = s.id AND m.kind = 'beef_hit') AS hits,-- (SELECT COUNT(*) FROM arena_beefs b WHERE b.winner_user_id = s.user_id AND b.resolved_at BETWEEN s.started_at AND COALESCE(s.ended_at, s.started_at)) AS wins-- FROM streams s-- WHERE s.user_id = ? AND s.duration_seconds > 0 AND s.started_at >= datetime('now', ?) ORDER BY s.started_at DESC LIMIT 14`, [userId, win])).reverse();-- const per = {-- heat: r => Number((r.avg_q || 0).toFixed(1)),-- aim: r => Number(((r.moments || 0) / Math.max((r.speech_sec || 0) / 3600, 0.05)).toFixed(2)),-- kills: r => r.wins || 0,-- mouth: r => (r.duration_seconds ? Number(((r.speech_sec || 0) / r.duration_seconds * 100).toFixed(1)) : 0),-- clapback: r => r.hits || 0,-- stamina: r => Number(((r.speech_sec || 0) / 60).toFixed(1)),-- pace: r => Number(((r.words || 0) / Math.max((r.speech_sec || 0) / 60, 0.1)).toFixed(0)),-- };-- series = rows.map(r => ({ stream_id: r.id, title: r.title, date: r.started_at, value: per[stat](r) }));-- } catch { series = []; }-- const unit = { heat: 'avg judge score', aim: 'hits / mic hour', kills: 'beefs won', mouth: '% of stream talking', clapback: 'answer rate', stamina: 'minutes of speech', pace: 'words / minute' }[stat];-- const shown = (raw) => (METRIC_FOR_STAT[stat](raw) || 0);-- const ranked = roster.order.map(id => ({ id, value: METRIC_FOR_STAT[stat](roster.byId[id].raw) || 0, shown: shown(roster.byId[id].raw), rating: roster.byId[id].ratings[stat] })).sort((x, y) => y.value - x.value);-- const position = ranked.findIndex(r => r.id === userId) + 1;-- const top = (await Promise.all(ranked.slice(0, 3).map(async r => ({ user: roster.byId[r.id].user, fighter_name: (parseJson((await profileRow(r.id))?.persona_json) || fallbackPersona(roster.byId[r.id])).fighter_name, value: Number(Number(r.shown).toFixed(1)), rating: r.rating }))));-- return { stat, label: STAT_META[stat].label, desc: STAT_META[stat].desc, unit, rating: entry.ratings[stat], value: Number(Number(shown(entry.raw)).toFixed(2)), position, roster_size: roster.order.length, weight: STAT_WEIGHTS[stat], series, top, voice: ['mouth', 'stamina', 'pace'].includes(stat) ? entry.raw.voice : undefined, mic: entry.raw.mic };--}----/** Live fighters with what the transcript last heard — the "on the mic now" strip. */--async function liveFighters() {-- const roster = await loadRoster();-- let live = [];-- try { live = await db.getLiveStreams() || []; } catch { live = []; }-- const byUser = new Map();-- for (const s of live) { if (!roster.byId[s.user_id]) continue; const cur = byUser.get(s.user_id); if (!cur || (s.viewer_count || 0) > (cur.viewer_count || 0)) byUser.set(s.user_id, s); }-- let thumbs = null; try { thumbs = require('../media-proxy/live-thumbs'); } catch { /* */ }-- const mic = require('./mic'), beef = require('./beef');-- let listener = null; try { listener = require('./listener'); } catch { /* */ }-- return (await Promise.all([...byUser.values()].map(async s => {-- const c = await cardFor(s.user_id, roster, { includeRaw: false });-- let hotMic = null;-- try { const r = (await db.all(`SELECT text, start_sec, vod_id FROM stream_timeline_events WHERE stream_id = ? AND kind = 'speech' AND LENGTH(text) > 15 ORDER BY start_sec DESC LIMIT 5`, [s.id])).find(row => !isBannedText(row.text)); if (r) hotMic = { text: r.text, start_sec: Math.floor(r.start_sec), vod_id: r.vod_id || null }; } catch { /* */ }-- const transcribed = !!await db.get(`SELECT 1 FROM stream_timeline_events WHERE stream_id = ? AND kind = 'speech' AND created_at >= datetime('now', '-30 minutes') LIMIT 1`, [s.id]);-- let ears = null; try { const cs = listener ? await listener.consoleState(s.user_id) : null; if (cs && cs.listening) ears = { focus: cs.focus ? { target_id: cs.focus.target_id, target: cs.focus.target, hits: cs.focus.hits, lock_seconds_left: cs.focus.lock_seconds_left } : null, pending_words: cs.pending_mic_words + (cs.focus ? cs.focus.pending_words : 0) }; } catch { ears = null; }-- let lastMoment = null; try { lastMoment = await mic.latestFor(s.user_id); } catch { lastMoment = null; }-- return {-- user: c.user, rank: c.rank, ratings: c.ratings, record: c.record, image_url: c.image_url, level: c.level.level,-- persona: { fighter_name: c.persona.fighter_name, title: c.persona.title, class: c.persona.class, taunt: c.persona.taunt },-- stream: { id: s.id, title: s.title, category: s.category, viewer_count: s.viewer_count || 0, started_at: s.started_at, slug: s.managed_stream_slug || null, managed_stream_id: s.managed_stream_id || null },-- thumbnail_url: thumbs ? ((await thumbs.getCurrentLiveThumbnailUrl(s.id)) || null) : null,-- hot_mic: hotMic, transcribed, ears, last_moment: lastMoment, open_beefs: (await beef.openBeefsFor(s.user_id)).length,-- };-- }))).sort((x, y) => y.ratings.power - x.ratings.power);--}----async function voterKeyFor(req) {-- if (req.user && req.user.id) return `user:${req.user.id}`;-- const ip = String(req.ip || req.headers['x-forwarded-for'] || req.socket?.remoteAddress || '');-- const salt = String(await setting('arena_vote_salt', '') || process.env.JWT_SECRET || 'arena');-- return `anon:${crypto.createHash('sha256').update(ip + '|' + salt).digest('hex').slice(0, 24)}`;--}----async function status() {-- await ensureTables();-- const roster = await loadRoster();-- const counts = await db.get(`SELECT COUNT(*) FILTER (WHERE persona_json IS NOT NULL) AS personas, COUNT(*) FILTER (WHERE image_path IS NOT NULL) AS images, COUNT(*) FILTER (WHERE quotes_json IS NOT NULL) AS quotes FROM arena_profiles`) || {};-- let beefs = {}, moments = 0;-- try { beefs = await db.get(`SELECT COUNT(*) FILTER (WHERE status = 'open') AS open, COUNT(*) FILTER (WHERE status = 'resolved') AS resolved FROM arena_beefs`) || {}; moments = (await db.get(`SELECT COUNT(*) AS n FROM arena_mic_moments WHERE created_at >= datetime('now', '-1 day')`))?.n || 0; } catch { /* */ }-- return {-- mode: 'battle-cam', enabled: await arenaEnabled(), ai: await aiOn(), image_generation: imageGenAvailable(), image_model: null,-- roster: roster.order.length, with_voice_data: roster.order.filter(id => roster.byId[id].raw.voice.has_data).length,-- personas: counts.personas || 0, quotes: counts.quotes || 0, images: counts.images || 0,-- beefs_open: beefs.open || 0, beefs_resolved: beefs.resolved || 0, mic_moments_24h: moments, live_fighters: (await liveFighters()).length,-- listener: (() => { try { return require('./listener').TICK_MS; } catch { return null; } })(), active_days: ACTIVE_DAYS,-- };--}----module.exports = {-- ensureTables, arenaEnabled, aiOn, imageGenAvailable, loadRoster, listFighters, getFighter, getStatDetail, liveFighters,-- generatePersona, generateQuotes, generateImage, voterKeyFor, status, publicUser,-- getFighterImageUrl: async (userId) => imageUrlFor(await profileRow(userId)),-- STAT_KEYS, STAT_META, STAT_WEIGHTS, ARENA_DIR, TALK_BONUS_MAX, ACTIVE_DAYS, MIC_WINDOW_DAYS,-- _computeRatings: computeRatings, _fallbackPersona: fallbackPersona, _voiceStatsFor: voiceStatsFor, _quoteCandidates: quoteCandidates, _isBannedText: isBannedText, _talkBonus: talkBonus,--};ddeleted file mode 100644@@@ -1,132 +0,0 @@--/**-- * OpenVibe.Live — Arena backfill: judge what was ALREADY said on mic.-- *-- * The listener only hears streams that are live right now. Everything a fighter said before-- * (the transcripts already in stream_timeline_events) is judged here, so the Arena reflects-- * the shit talk on record the moment it ships, not weeks later:-- *-- * 1. every roster fighter's speech from the last DAYS days is read in stream order and cut-- * into ~60 s chunks (JUDGE_MIN_WORDS … CHUNK_MAX_WORDS, a gap > CHUNK_GAP_SEC starts a-- * new chunk);-- * 2. a chunk is only sent to a model if it looks like it could be shit talk (the SPICY-- * keyword pre-filter) or names another fighter — gameplay narration never costs a call;-- * 3. a name-drop chunk goes to the beef judge (no clocks / no beefs are opened for old-- * speech — it lands as a `callout` moment with the target attached); everything else-- * goes to the mic judge → `trash` moment. Both pay Trash Level XP.-- *-- * A per-fighter cursor (site setting `arena_backfill_cursor_<userId>` = last timeline row id-- * judged) makes every run incremental, so this also catches up anything the live listener-- * missed. Bounded per run (MAX_CALLS_PER_RUN model calls) so a big backlog drains over a few-- * runs instead of burning the budget at once.-- */--'use strict';----const db = require('../db/database');----const DAYS = 7;--const CHUNK_GAP_SEC = 25;--const CHUNK_MAX_WORDS = 110;--const MAX_CALLS_PER_RUN = 160;--const MAX_CHUNKS_PER_FIGHTER = 80;----function arena() { return require('./arena-service'); }--function listener() { return require('./listener'); }--function mic() { return require('./mic'); }--function words(t) { return String(t || '').split(/\s+/).filter(Boolean).length; }--function cursorKey(uid) { return `arena_backfill_cursor_${uid}`; }--async function getCursor(uid) { const v = parseInt(await db.getSetting(cursorKey(uid)), 10); return Number.isFinite(v) ? v : 0; }--async function setCursor(uid, id) { try { await db.setSetting(cursorKey(uid), String(id)); } catch { /* */ } }--function sqlToIso(ts) { return ts ? String(ts).replace(' ', 'T') + (String(ts).endsWith('Z') ? '' : 'Z') : null; }----/** Cut a fighter's speech rows into judge-sized chunks. */--function chunk(rows) {-- const out = [];-- let cur = null;-- for (const r of rows) {-- const w = words(r.text);-- if (!w) continue;-- const gap = cur && (r.stream_id !== cur.stream_id || r.start_sec - cur.end > CHUNK_GAP_SEC);-- if (!cur || gap || cur.words + w > CHUNK_MAX_WORDS) {-- if (cur && cur.words >= listener().JUDGE_MIN_WORDS) out.push(cur);-- cur = { stream_id: r.stream_id, vod_id: r.vod_id, started_at: r.started_at, start: r.start_sec, end: r.end_sec || r.start_sec + 3, lines: [], words: 0, lastId: r.id };-- }-- cur.lines.push({ t: String(r.text), s: Math.floor(r.start_sec), v: r.vod_id || null, id: r.id });-- cur.words += w; cur.end = r.end_sec || r.start_sec + 3; cur.lastId = r.id;-- }-- if (cur && cur.words >= listener().JUDGE_MIN_WORDS) out.push(cur);-- return out;--}----function saidAtFor(c, sec) {-- const base = c.started_at ? Date.parse(sqlToIso(c.started_at)) : NaN;-- if (!Number.isFinite(base)) return null;-- return new Date(base + Math.max(0, sec || 0) * 1000).toISOString().replace('T', ' ').slice(0, 19);--}----async function backfillFighter(uid, roster, budget) {-- const L = listener();-- const since = `-${DAYS} days`;-- const rows = await db.all(`SELECT e.id, e.stream_id, e.vod_id, e.start_sec, e.end_sec, e.text, s.started_at-- FROM stream_timeline_events e LEFT JOIN streams s ON s.id = e.stream_id-- WHERE e.user_id = ? AND e.kind = 'speech' AND e.id > ? AND e.created_at >= datetime('now', ?)-- AND NOT EXISTS (SELECT 1 FROM streams l WHERE l.id = e.stream_id AND l.is_live = 1)-- ORDER BY e.stream_id, e.start_sec LIMIT 4000`, [uid, await getCursor(uid), since]);-- if (!rows.length) return { judged: 0, moments: 0, skipped: 0 };-- const chunks = chunk(rows).slice(0, MAX_CHUNKS_PER_FIGHTER);-- let judged = 0, moments = 0, skipped = 0, lastId = await getCursor(uid);-- for (const c of chunks) {-- if (budget.calls >= MAX_CALLS_PER_RUN) break;-- const text = c.lines.map(l => l.t).join(' ').replace(/\s+/g, ' ').slice(-1400);-- const mentions = await L._mentionsDetailed(text, uid, roster);-- const spicy = L.SPICY.test(text.toLowerCase());-- lastId = Math.max(lastId, c.lastId);-- if (!mentions.length && !spicy) { skipped++; continue; }-- budget.calls++; judged++;-- const ref = (bestLine) => { const needle = String(bestLine || '').toLowerCase().slice(0, 30); const hit = (needle && c.lines.find(l => l.t.toLowerCase().includes(needle))) || c.lines[0]; return { vod_id: hit ? hit.v : null, sec: hit ? Math.max(0, hit.s - 2) : null }; };-- try {-- if (mentions.length) {-- const m = mentions[0];-- const j = await L._judgeBeef(uid, m.userId, text, roster, { context: null, named: true, how: m.how });-- if (j.aimed_at_target && j.quality >= (m.how === 'exact' ? 3 : 5)) {-- const r = ref(j.best_line);-- if (await mic().addMoment({ userId: uid, streamId: c.stream_id, vodId: r.vod_id, sec: r.sec, kind: 'callout', targetUserId: m.userId, aimedAt: await mic().nameOf(m.userId), text: j.best_line || text.slice(0, 220), about: j.about, quality: j.quality, announcer: j.announcer, saidAt: saidAtFor(c, r.sec) })) moments++;-- continue;-- }-- if (j.about_target) continue; // neutral mention — not shit talk, and not free talk either-- }-- const j = await L._judgeMic(uid, text);-- if (j.is_trash_talk && j.quality >= L.MIC_MIN_QUALITY && !await mic().isDuplicate(uid, j.best_line)) {-- const r = ref(j.best_line);-- // Nobody was named (that went to the beef judge above): a fighter the judge guesses at is never published.-- if (await mic().addMoment({ userId: uid, streamId: c.stream_id, vodId: r.vod_id, sec: r.sec, kind: 'trash', aimedAt: await L._aimedAtUnnamed(j.aimed_at, uid, roster), text: j.best_line || text.slice(0, 220), about: j.about, quality: j.quality, announcer: j.announcer, saidAt: saidAtFor(c, r.sec) })) moments++;-- }-- } catch (e) { console.warn(`[Arena] backfill judge (user ${uid}):`, e.message); }-- }-- await setCursor(uid, lastId);-- return { judged, moments, skipped, chunks: chunks.length };--}----let _busy = false;--/** Judge un-judged past speech for every roster fighter. Bounded; safe to call often. */--async function run({ force = false } = {}) {-- if (_busy) return { busy: true };-- if (!await arena().arenaEnabled()) return { disabled: true };-- _busy = true;-- const started = Date.now();-- const budget = { calls: 0 };-- const out = { fighters: 0, judged: 0, moments: 0, skipped: 0, calls: 0 };-- try {-- const roster = await arena().loadRoster(force);-- for (const uid of roster.order) {-- if (budget.calls >= MAX_CALLS_PER_RUN) break;-- const r = await backfillFighter(uid, roster, budget);-- out.fighters++; out.judged += r.judged; out.moments += r.moments; out.skipped += r.skipped;-- }-- out.calls = budget.calls;-- if (out.judged || out.moments) console.log(`[Arena] backfill: ${out.moments} moment(s) from ${out.judged} judged chunk(s) across ${out.fighters} fighter(s) (${out.skipped} skipped as not spicy, ${Math.round((Date.now() - started) / 1000)} s)`);-- if (out.moments) { try { await arena().loadRoster(true); } catch { /* */ } }-- return out;-- } finally { _busy = false; }--}----module.exports = { run, chunk, DAYS, MAX_CALLS_PER_RUN, _backfillFighter: backfillFighter };ddeleted file mode 100644@@@ -1,284 +0,0 @@--/**-- * OpenVibe.Live — Arena Beefs (speech-driven streamer vs streamer)-- *-- * Nobody clicks "battle". The listener (listener.js) hears a live fighter talking trash-- * about ANOTHER fighter by name; the judge confirms it was aimed at them; a beef opens:-- *-- * score → every judged hit aimed at the other side adds its quality (0–10) to-- * your side of the tug-of-war. Chat adds crowd points with `!hype` in-- * your chat (one per person per beef, max CROWD_MAX) — that is the ONLY-- * thing chat can do; every point of substance comes from the mic.-- * clock → after every hit the OTHER side is on the clock: RESPONSE_LIVE_MIN-- * minutes if they are live, RESPONSE_OFFLINE_HOURS hours if not (the-- * clock tightens to the live window the moment they go live). Miss the-- * clock and you ducked — forfeit.-- * end → a beef also hard-ends MAX_BEEF_HOURS after it opened: higher score-- * wins, equal is a draw. Records (W–L) come from here.-- * pending → a beef is public only once both sides have named each other. One streamer naming-- * another opens it `pending`: the named person is told privately, and nothing shows on-- * the Arena, in ARENA ALERT or in chat. Their answer on their own stream (a judged hit-- * naming the caller) makes it `open`. Unanswered, it ends `expired`: no forfeit, no-- * story, no record. One-sided matches were friendly greetings ("Maticus, our only viewer")-- * and transcripts ("the Adam family"), 2026-10-18.-- */--'use strict';----const db = require('../db/database');----const RESPONSE_LIVE_MIN = 15;--const RESPONSE_OFFLINE_HOURS = 24;--const MAX_BEEF_HOURS = 24;--const CROWD_MAX = 10;--const FEED_MAX = 40;--const XP_BEEF_HIT = 1.0; // × quality--const XP_BEEF_WIN = 40;--const XP_BEEF_OPEN = 5;------function parseJson(t, f = null) { try { return t ? JSON.parse(t) : f; } catch { return f; } }--function arena() { return require('./arena-service'); }--function mic() { return require('./mic'); }--async function isLive(userId) { return !!await db.get('SELECT 1 FROM streams WHERE user_id = ? AND is_live = 1 LIMIT 1', [userId]); }--function nowIso() { return new Date().toISOString(); }--async function nameOf(userId) { return await mic().nameOf(userId); }--async function headlineFor(kind, ctx) {-- try {-- const llm = require('../ai/llm');-- if (!(await llm.isEnabled() && await llm.withinBudget())) return null;-- // The headline rules are OpenVibe.AI's versioned template live.arena.headline (WS-O task 2).-- const event = kind === 'open' || kind === 'forfeit' ? kind : 'score'; // a draw or a points win is a score-- const out = await require('../ai/ai-service').structured('live.arena.headline', { kind: event, ...ctx }, { meter: { kind: 'arena_headline', role: 'chat', source: 'arena' } });-- if (!out || !out.headline) return null;-- // No accusation the plain headline does not make (drama.js addsAccusation; a quote never licenses one).-- if (require('./drama').addsAccusation(out.headline, templateHeadline(event, { ...ctx, line: null }))) return null;-- return String(out.headline).slice(0, 120);-- } catch { return null; }--}--function templateHeadline(kind, ctx) {-- if (kind === 'open') return `${ctx.a} has words for ${ctx.b}${ctx.rematch ? ' — REMATCH' : ''}${ctx.line ? `: “${String(ctx.line).slice(0, 60)}”` : ''}`;-- if (kind === 'forfeit') return `${ctx.loser} DUCKED. ${ctx.winner} wins by silence.`;-- if (kind === 'score') return ctx.winner ? `${ctx.winner} out-talks ${ctx.loser} ${ctx.score_a}–${ctx.score_b}${ctx.upset ? ' — UPSET' : ''}` : `${ctx.a} and ${ctx.b} talk themselves into a draw`;-- return null;--}--function sqlDate(ms) { return new Date(ms).toISOString().replace('T', ' ').slice(0, 19); }----// What everyone sees: a beef both sides are in, or one that ended. Pending, expired and void ones are no one's business.--const PUBLIC = "status IN ('open', 'resolved')";----/** The beef in play between two fighters, pending or open (recordHit's own view). */--async function activeBeefBetween(u1, u2) {-- return await db.get(`SELECT * FROM arena_beefs WHERE status IN ('pending', 'open') AND ((a_user_id = ? AND b_user_id = ?) OR (a_user_id = ? AND b_user_id = ?))`, [u1, u2, u2, u1]) || null;--}----async function openBeefBetween(u1, u2) {-- return await db.get(`SELECT * FROM arena_beefs WHERE status = 'open' AND ((a_user_id = ? AND b_user_id = ?) OR (a_user_id = ? AND b_user_id = ?))`, [u1, u2, u2, u1]) || null;--}--async function openBeefsFor(userId) {-- return await db.all(`SELECT * FROM arena_beefs WHERE status = 'open' AND (a_user_id = ? OR b_user_id = ?) ORDER BY opened_at DESC`, [userId, userId]);--}----async function clockFor(targetId, fromMs = Date.now()) {-- return sqlDate(fromMs + (await isLive(targetId) ? RESPONSE_LIVE_MIN * 60 * 1000 : RESPONSE_OFFLINE_HOURS * 3600 * 1000));--}----function pushFeed(beef, event) {-- const feed = parseJson(beef.feed_json, []);-- feed.push({ at: nowIso(), ...event });-- return JSON.stringify(feed.slice(-FEED_MAX));--}----/**-- * A judged hit: `speakerId` talked trash aimed at `targetId` (both on the roster).-- * Opens the beef if needed, scores it, and puts the other side on the clock.-- */--async function recordHit(speakerId, targetId, hit) {-- if (speakerId === targetId) return null;-- let beef = await activeBeefBetween(speakerId, targetId);-- let opened = false;-- if (!beef) {-- const endsAt = sqlDate(Date.now() + MAX_BEEF_HOURS * 3600 * 1000);-- const history = await rivalry(speakerId, targetId);-- await db.run(`INSERT INTO arena_beefs (a_user_id, b_user_id, status, on_clock, clock_until, ends_at, feed_json, opener_line, rematch, bounty_topic_id, headline) VALUES (?, ?, 'pending', 'b', ?, ?, '[]', ?, ?, NULL, ?)`,-- [speakerId, targetId, await clockFor(targetId), endsAt, String(hit.best_line || '').slice(0, 220), history.fights > 0 ? 1 : 0, templateHeadline('open', { a: await nameOf(speakerId), b: await nameOf(targetId), line: hit.best_line, rematch: history.fights > 0 })]);-- beef = await activeBeefBetween(speakerId, targetId);-- opened = true;-- console.log(`[Arena] beef #${beef.id} pending: user ${speakerId} named user ${targetId}${history.fights ? ' (rematch)' : ''}`);-- // Told privately: it becomes a beef only if they name the caller back on their own stream.-- try { require('./notify').arenaNotify(targetId, { type: 'beef_open', title: `${await nameOf(speakerId)} said your name on stream`, message: `${hit.best_line ? `“${String(hit.best_line).slice(0, 120)}” — ` : ''}say theirs back on your own stream within ${await isLive(targetId) ? `${RESPONSE_LIVE_MIN} min` : `${RESPONSE_OFFLINE_HOURS} h`} to start a beef.`, icon: '🥊', url: '/arena', key: `open:${beef.id}`, senderId: speakerId, senderName: await nameOf(speakerId) }); } catch { /* */ }-- }-- const side = beef.a_user_id === speakerId ? 'a' : 'b';-- const other = side === 'a' ? 'b' : 'a';-- const otherId = side === 'a' ? beef.b_user_id : beef.a_user_id;-- const quality = Math.max(0, Math.min(10, Number(hit.quality) || 0));-- const firstResponse = side === 'b' && !beef.responded;-- const pending = beef.status === 'pending';-- // The named person answered: the beef is on, and public from here.-- const goesOpen = pending && firstResponse;-- const feed = pushFeed(beef, { kind: opened ? 'open' : (firstResponse ? 'respond' : 'hit'), side, text: String(hit.best_line || '').slice(0, 220), quality, about: hit.about || null, announcer: hit.announcer || null, vod_id: hit.vod_id || null, sec: hit.sec ?? null });-- await db.run(`UPDATE arena_beefs SET score_${side} = score_${side} + ?, hits_${side} = hits_${side} + 1, last_${side}_at = ov_now(), responded = CASE WHEN ? THEN 1 ELSE responded END,-- status = CASE WHEN ? THEN 'open' ELSE status END, on_clock = ?, clock_until = ?, feed_json = ? WHERE id = ?`,-- [quality, side === 'b' ? 1 : 0, goesOpen ? 1 : 0, other, await clockFor(otherId), feed, beef.id]);-- if (pending && !goesOpen) return { beef: await db.get('SELECT * FROM arena_beefs WHERE id = ?', [beef.id]), opened, pending: true, side, first_response: false };-- if (goesOpen) {-- console.log(`[Arena] beef #${beef.id} on: user ${speakerId} answered user ${otherId}`);-- const aName = await nameOf(beef.a_user_id), bName = await nameOf(beef.b_user_id);-- await mic().addXp(beef.a_user_id, XP_BEEF_OPEN, 'beef_open', beef.id);-- require('./drama').beefStory('open', { id: beef.id, a_user_id: beef.a_user_id, b_user_id: beef.b_user_id }, { about: hit.about, opening_line: beef.opener_line }).catch(() => {});-- headlineFor('open', { a: aName, b: bName, opening_line: beef.opener_line, about: hit.about, rematch: !!beef.rematch, record_between: await rivalry(beef.a_user_id, beef.b_user_id) }).then(async h => { if (h) await db.run('UPDATE arena_beefs SET headline = ? WHERE id = ?', [h, beef.id]); }).catch(() => {});-- }-- await mic().addXp(speakerId, quality * XP_BEEF_HIT, 'beef_hit', beef.id, { beefHit: true, line: hit.best_line, lineScore: quality, lineVodId: hit.vod_id, lineSec: hit.sec });-- // The hit is also a line in the shit-talk feed (VOD-linked, with who it was aimed at).-- try { await mic().addMoment({ userId: speakerId, streamId: hit.stream_id || null, vodId: hit.vod_id || null, sec: hit.sec ?? null, kind: 'beef_hit', targetUserId: targetId, beefId: beef.id, aimedAt: await nameOf(targetId), text: hit.best_line || hit.about || '', about: hit.about || null, quality, announcer: hit.announcer || null }); } catch { /* */ }-- { try { require('./notify').arenaNotify(otherId, { type: firstResponse ? 'beef_answered' : 'beef_hit', title: `${await nameOf(speakerId)} ${firstResponse ? 'answered' : 'hit back'} (${quality}/10)`, message: `${hit.best_line ? `“${String(hit.best_line).slice(0, 120)}” — ` : ''}you're on the clock now.`, icon: '🔥', url: `/arena/beef/${beef.id}`, key: `hit:${beef.id}:${beef.hits_a + beef.hits_b}`, senderId: speakerId, senderName: await nameOf(speakerId) }); } catch { /* */ } }-- return { beef: await db.get('SELECT * FROM arena_beefs WHERE id = ?', [beef.id]), opened: goesOpen, side, first_response: firstResponse };--}----async function hype(beefId, side, voterKey) {-- const beef = await db.get('SELECT * FROM arena_beefs WHERE id = ? AND status = ?', [beefId, 'open']);-- if (!beef) throw new Error('No open beef with that id');-- if (!['a', 'b'].includes(side)) throw new Error('side must be a or b');-- const sideUser = side === 'a' ? beef.a_user_id : beef.b_user_id;-- if (voterKey === `user:${sideUser}`) throw new Error("You can't hype yourself");-- const ins = await db.run('INSERT INTO arena_beef_hype (beef_id, side, voter_key) VALUES (?, ?, ?) ON CONFLICT DO NOTHING', [beefId, side, voterKey]);-- const n = (await db.get('SELECT COUNT(*) AS n FROM arena_beef_hype WHERE beef_id = ? AND side = ?', [beefId, side]))?.n || 0;-- await db.run(`UPDATE arena_beefs SET crowd_${side} = ? WHERE id = ?`, [Math.min(CROWD_MAX, n), beefId]);-- if (ins.changes) await mic().addXp(sideUser, mic().XP_HYPE, 'hype', beefId);-- return { added: !!ins.changes, hypers: n, crowd: Math.min(CROWD_MAX, n) };--}----function totals(beef) {-- return { a: Number(((beef.score_a || 0) + (beef.crowd_a || 0)).toFixed(1)), b: Number(((beef.score_b || 0) + (beef.crowd_b || 0)).toFixed(1)) };--}----async function resolve(beef, resolution, winnerId) {-- const loserId = winnerId == null ? null : (winnerId === beef.a_user_id ? beef.b_user_id : beef.a_user_id);-- let upset = 0;-- try { const roster = await arena().loadRoster(); if (winnerId && loserId) { const rw = roster.order.indexOf(winnerId), rl = roster.order.indexOf(loserId); upset = rw >= 0 && rl >= 0 && rw - rl >= 4 ? 1 : 0; } } catch { /* */ }-- const t = totals(beef);-- const ctx = { a: await nameOf(beef.a_user_id), b: await nameOf(beef.b_user_id), winner: winnerId ? await nameOf(winnerId) : null, loser: loserId ? await nameOf(loserId) : null, score_a: t.a, score_b: t.b, upset: !!upset, streak: winnerId ? await streakFor(winnerId) + 1 : 0 };-- const feed = pushFeed(beef, { kind: resolution === 'forfeit' ? 'forfeit' : 'end', side: winnerId == null ? null : (winnerId === beef.a_user_id ? 'a' : 'b'), text: null, upset: !!upset });-- await db.run(`UPDATE arena_beefs SET status = 'resolved', resolution = ?, winner_user_id = ?, resolved_at = ov_now(), on_clock = NULL, clock_until = NULL, feed_json = ?, upset = ?, result_headline = ? WHERE id = ? AND status = 'open'`, [resolution, winnerId, feed, upset, templateHeadline(resolution === 'forfeit' ? 'forfeit' : 'score', ctx), beef.id]);-- if (winnerId) 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 { /* */ }--}----/** Current win streak (consecutive resolved beefs won). */--async function streakFor(userId) {-- let n = 0;-- for (const b of await db.all(`SELECT winner_user_id FROM arena_beefs WHERE status = 'resolved' AND (a_user_id = ? OR b_user_id = ?) ORDER BY resolved_at DESC LIMIT 20`, [userId, userId])) { if (b.winner_user_id === userId) n++; else break; }-- return n;--}----/** Rivalry between two fighters: fights, record, receipts (best lines from earlier beefs). */--async function rivalry(u1, u2) {-- const rows = await db.all(`SELECT * FROM arena_beefs WHERE status = 'resolved' AND ((a_user_id = ? AND b_user_id = ?) OR (a_user_id = ? AND b_user_id = ?)) ORDER BY resolved_at DESC LIMIT 10`, [u1, u2, u2, u1]);-- const wins1 = rows.filter(r => r.winner_user_id === u1).length, wins2 = rows.filter(r => r.winner_user_id === u2).length;-- const receipts = [];-- for (const r of rows.slice(0, 3)) for (const e of parseJson(r.feed_json, [])) if (e.text && e.quality >= 6) receipts.push({ beef_id: r.id, side_user_id: e.side === 'a' ? r.a_user_id : r.b_user_id, text: e.text, quality: e.quality, vod_id: e.vod_id, sec: e.sec, at: e.at });-- receipts.sort((x, y) => y.quality - x.quality);-- return { fights: rows.length, wins_1: wins1, wins_2: wins2, last: rows[0] ? { id: rows[0].id, winner_user_id: rows[0].winner_user_id, resolution: rows[0].resolution, at: rows[0].resolved_at } : null, receipts: receipts.slice(0, 4) };--}----async function rivalriesFor(userId, limit = 5) {-- const opps = await db.all(`SELECT CASE WHEN a_user_id = ? THEN b_user_id ELSE a_user_id END AS opp, COUNT(*) AS n FROM arena_beefs WHERE (a_user_id = ? OR b_user_id = ?) AND ${PUBLIC} GROUP BY 1 HAVING COUNT(*) >= 2 ORDER BY n DESC LIMIT ?`, [userId, userId, userId, limit]);-- const b = mic();-- const roster = await arena().loadRoster();-- return (await Promise.all(opps.map(async o => { const r = await rivalry(userId, o.opp); return { opponent: await b.fighterBrief(o.opp, roster), fights: r.fights + (await openBeefBetween(userId, o.opp) ? 1 : 0), wins: r.wins_1, losses: r.wins_2, open: !!await openBeefBetween(userId, o.opp), receipts: r.receipts.slice(0, 2) }; })));--}----/** Ticker: enforce clocks and hard ends; tighten an offline clock when the target goes live. */--async function tick() {-- const now = Date.now();-- // A callout nobody answered in time ends quietly: no forfeit, no story, no record.-- for (const beef of await db.all(`SELECT id, clock_until, ends_at FROM arena_beefs WHERE status = 'pending'`)) {-- const until = beef.clock_until ? Date.parse(beef.clock_until + 'Z') : 0;-- if (until <= now || (beef.ends_at && Date.parse(beef.ends_at + 'Z') <= now)) await db.run("UPDATE arena_beefs SET status = 'expired', on_clock = NULL, clock_until = NULL WHERE id = ? AND status = 'pending'", [beef.id]);-- }-- for (const beef of await db.all(`SELECT * FROM arena_beefs WHERE status = 'open'`)) {-- const t = totals(beef);-- if (beef.ends_at && Date.parse(beef.ends_at + 'Z') <= now) {-- await resolve(beef, 'score', t.a === t.b ? null : (t.a > t.b ? beef.a_user_id : beef.b_user_id));-- continue;-- }-- if (beef.on_clock && beef.clock_until) {-- const onId = beef.on_clock === 'a' ? beef.a_user_id : beef.b_user_id;-- const until = Date.parse(beef.clock_until + 'Z');-- // Tighten: if the side on the clock is live now, they get the live window at most.-- const liveCap = now + RESPONSE_LIVE_MIN * 60 * 1000;-- if (await isLive(onId) && until > liveCap) await db.run('UPDATE arena_beefs SET clock_until = ? WHERE id = ?', [sqlDate(liveCap), beef.id]);-- else if (until <= now) {-- const winner = beef.on_clock === 'a' ? beef.b_user_id : beef.a_user_id;-- await resolve(beef, 'forfeit', winner);-- }-- }-- }--}----// ── Records + views ──────────────────────────────────────────----async function recordFor(userId) {-- const r = await db.get(`SELECT COUNT(*) FILTER (WHERE winner_user_id = ?) AS wins,-- COUNT(*) FILTER (WHERE status = 'resolved' AND winner_user_id IS NOT NULL AND winner_user_id != ?) AS losses,-- COUNT(*) FILTER (WHERE status = 'resolved' AND winner_user_id IS NULL) AS draws-- FROM arena_beefs WHERE (a_user_id = ? OR b_user_id = ?) AND ${PUBLIC}`, [userId, userId, userId, userId]) || {};-- return { wins: r.wins || 0, losses: r.losses || 0, draws: r.draws || 0 };--}----async function recentWins(userId, days = 7) {-- return (await db.get(`SELECT COUNT(*) AS n FROM arena_beefs WHERE winner_user_id = ? AND resolved_at >= datetime('now', ?)`, [userId, `-${days} days`]))?.n || 0;--}----async function beefView(beef, roster) {-- const t = totals(beef);-- const b = mic();-- const clockMs = beef.clock_until ? Date.parse(beef.clock_until + 'Z') - Date.now() : null;-- return {-- id: beef.id, status: beef.status, resolution: beef.resolution,-- a: { ...await b.fighterBrief(beef.a_user_id, roster), score: Number((beef.score_a || 0).toFixed(1)), hits: beef.hits_a || 0, crowd: beef.crowd_a || 0, total: t.a, last_at: beef.last_a_at },-- b: { ...await b.fighterBrief(beef.b_user_id, roster), score: Number((beef.score_b || 0).toFixed(1)), hits: beef.hits_b || 0, crowd: beef.crowd_b || 0, total: t.b, last_at: beef.last_b_at },-- share_a: t.a + t.b > 0 ? Math.round((t.a / (t.a + t.b)) * 100) : 50,-- on_clock: beef.on_clock, clock_until: beef.clock_until ? new Date(beef.clock_until + 'Z').toISOString() : null, clock_seconds_left: clockMs == null ? null : Math.max(0, Math.round(clockMs / 1000)),-- clock_is_live_window: beef.on_clock ? await isLive(beef.on_clock === 'a' ? beef.a_user_id : beef.b_user_id) : false,-- responded: !!beef.responded,-- opener_line: beef.opener_line, headline: beef.headline, result_headline: beef.result_headline,-- rematch: !!beef.rematch, upset: !!beef.upset,-- history: await rivalry(beef.a_user_id, beef.b_user_id),-- streaks: { a: await streakFor(beef.a_user_id), b: await streakFor(beef.b_user_id) },-- feed: parseJson(beef.feed_json, []).slice(-14),-- winner_user_id: beef.winner_user_id,-- opened_at: beef.opened_at, ends_at: beef.ends_at ? new Date(beef.ends_at + 'Z').toISOString() : null, resolved_at: beef.resolved_at,-- rules: { response_live_min: RESPONSE_LIVE_MIN, response_offline_hours: RESPONSE_OFFLINE_HOURS, max_hours: MAX_BEEF_HOURS, crowd_max: CROWD_MAX },-- };--}----async function list({ limitResolved = 12 } = {}) {-- const roster = await arena().loadRoster();-- const open = (await Promise.all((await db.all(`SELECT * FROM arena_beefs WHERE status = 'open' ORDER BY COALESCE(last_b_at, last_a_at, opened_at) DESC`)).map(async b => await beefView(b, roster))));-- const resolved = (await Promise.all((await db.all(`SELECT * FROM arena_beefs WHERE status = 'resolved' ORDER BY resolved_at DESC LIMIT ?`, [limitResolved])).map(async b => await beefView(b, roster))));-- return { open, resolved };--}----async function get(id) {-- // Only a beef both sides are in, or one that ended (void, pending and expired ones are not shown).-- const b = await db.get(`SELECT * FROM arena_beefs WHERE id = ? AND ${PUBLIC}`, [id]);-- return b ? await beefView(b, await arena().loadRoster()) : null;--}----async function forUser(userId, limit = 8) {-- const roster = await arena().loadRoster();-- return (await Promise.all((await db.all(`SELECT * FROM arena_beefs WHERE (a_user_id = ? OR b_user_id = ?) AND ${PUBLIC} ORDER BY CASE WHEN status = 'open' THEN 0 ELSE 1 END, COALESCE(resolved_at, opened_at) DESC LIMIT ?`, [userId, userId, limit])).map(async b => await beefView(b, roster))));--}----module.exports = {-- recordHit, hype, tick, recordFor, recentWins, list, get, forUser, openBeefBetween, openBeefsFor, beefView, totals,-- streakFor, rivalry, rivalriesFor, nameOf,-- RESPONSE_LIVE_MIN, RESPONSE_OFFLINE_HOURS, MAX_BEEF_HOURS, CROWD_MAX, XP_BEEF_WIN, XP_BEEF_OPEN, XP_BEEF_HIT,--};ddeleted file mode 100644@@@ -1,466 +0,0 @@--/**-- * 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 3+ yells, screams or shouts heard within-- * 2 min (the sound timeline) AND a heated line of the streamer's own said around them, quoted-- * 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; the-- * template's plain account (`what`) goes with the facts so the model knows what the event means. A rewrite that-- * misspells a name, or accuses someone of more than the template does (a threat, an attack…), is dropped.-- * 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-- * announce(id) a 3+ alert story as a line in chat (the live stream's room, else global)-- */--'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']);--// The streamer's own heated words. The sound timeline hears a game's or a match's crowd as screams and groans too, so--// a meltdown needs one of these said beside the noise ("We are going to get the control." was a soccer stream's--// meltdown and "Thank you, thank you." a League stream's tears, 2026-10-18). No tears stories: crying from a speaker--// is not the streamer crying.--const RAGE_LINE = /\b(fuck\w*|shit\w*|bullshit|wtf|what the (?:fuck|hell|heck)|god ?damn\w*|i can'?t (?:take|do) (?:this|it)|i'?m (?:so )?done|i swear|rigged|kill me|are you (?:kidding|serious)|hate (?:this|my life|it))\b/i;--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],-- 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 { /* */ }-- }-- // The AI headline first (when it comes), then the chat hears about it.-- rewrite(id, { kind, streamer: name, other: fx.other || null, what: blurb, facts: fx, quote: cleanQuote }, userId).catch(() => {}).then(() => announce(id)).catch(() => {});-- return id;--}----// A story at alert 3+ is announced in chat: in the subject's stream room (mirrored to global chat) while they are live,--// else in global chat. At most ANNOUNCE_PER_HOUR an hour, one line per story (Chat dedupes by key).--const ANNOUNCE_PER_HOUR = 6;--let _announced = [];--async function announce(id) {-- const row = await db.get("SELECT id, user_id, headline, alert, status FROM arena_drama WHERE id = ?", [id]);-- if (!row || row.status !== 'live' || Number(row.alert) < 3) return false;-- const now = Date.now();-- _announced = _announced.filter((t) => now - t < 3600_000);-- if (_announced.length >= ANNOUNCE_PER_HOUR) return false;-- _announced.push(now);-- let base = 'https://openvibe.live';-- try { base = String(require('../config').baseUrl || base).replace(/\/$/, ''); } catch { /* */ }-- const live = await db.get('SELECT id FROM streams WHERE user_id = ? AND is_live = 1 ORDER BY started_at DESC LIMIT 1', [row.user_id]);-- const headline = String(row.headline).startsWith('🚨') ? row.headline : `🚨 ${row.headline}`;-- const line = { username: 'ARENA ALERT', user_id: null, message: `${headline} → ${base}/arena/drama/${row.id}`.slice(0, 480), message_type: 'system', key: `arena:${row.id}`,-- metadata: { arena_drama_id: row.id, url: `${base}/arena/drama/${row.id}` }, ...(live ? { stream_id: live.id, mirror: true } : { is_global: true }) };-- await require('../chat/chat-delivery').message(line);-- return true;--}----// Words that accuse a real person of something. An AI line may use one only when the template already does: the--// desk rewrote a beef built on a game's voice lines as "iZoR_One DECLARES DEATH THREATS ON STREAM" (2026-10-18).--const ACCUSING = /\b(threat\w*|death|deadly|kill\w*|murder\w*|abus\w*|assault\w*|attack\w*|violen\w*|harass\w*|stalk\w*|racis\w*|nazi\w*|crim(?:e|es|inal\w*)|illegal\w*|weapon\w*|groom\w*|predator\w*|pedo\w*|doxx?\w*|rap(?:e|es|ed|ist\w*)|scam\w*|fraud\w*|cheat\w*|steal\w*|stole\w*)\b/gi;--function addsAccusation(text, template) {-- const have = new Set((String(template || '').match(ACCUSING) || []).map((w) => w.toLowerCase()));-- return (String(text || '').match(ACCUSING) || []).some((w) => !have.has(w.toLowerCase()));--}----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;-- // A real person's name misspelled ("Goosey" for goosely) is worse than the template: the AI headline must carry-- // every name it was given, exactly (case aside).-- const names = [payload.streamer, payload.other].filter(Boolean).map((n) => String(n).toLowerCase());-- if (!names.every((n) => String(out.headline).toLowerCase().includes(n))) return;-- const row = await db.get('SELECT headline, blurb FROM arena_drama WHERE id = ?', [id]);-- if (addsAccusation(`${out.headline} ${out.blurb}`, row && `${row.headline} ${row.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: 3+ rage sounds within 2 minutes, and a heated line of the streamer's own around them (the quote). */--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]]) {-- 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, start_sec 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 = pickQuote(lines.filter((l) => RAGE_LINE.test(String(l.text || ''))), best.from.start_sec, best.to.start_sec);-- if (!quote) continue;-- 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;--}----// The line said around a meltdown that sounds like one: heat (swearing, exclamations, "why", "I can't") counts most,--// then how close it was to the shouting, then length; a mundane line far from it only when nothing else was said.--const HEAT = /\b(fuck\w*|shit\w*|damn\w*|bro|dude|wtf|why|what the|i can'?t|i'?m done|i swear|stop|rigged|trash|garbage|hate|kill me|are you (kidding|serious))\b|!/i;--function pickQuote(lines, fromSec, toSec) {-- const mid = (Number(fromSec) + Number(toSec)) / 2;-- const scored = lines.map((l) => ({ t: String(l.text || '').trim(), at: Number(l.start_sec) }))-- .filter((l) => l.t.split(/\s+/).length >= 4 && !arena()._isBannedText(l.t))-- .map((l) => ({ ...l, score: (HEAT.test(l.t) ? 10 : 0) - Math.abs(l.at - mid) / 6 + Math.min(l.t.length, 160) / 80 }));-- scored.sort((a, b) => b.score - a.score);-- return scored.length ? scored[0].t : null;--}----/** 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; }--}----async function liveDuring(userId, sinceTxt) {-- if (!sinceTxt) return false;-- return !!await db.get('SELECT 1 FROM streams WHERE user_id = ? AND (is_live = 1 OR ended_at >= ?) LIMIT 1', [userId, String(sinceTxt).replace('T', ' ').slice(0, 19)]);--}----/** 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() });-- // "Ducked" only when they were live while on the clock: someone who never streamed in that time never heard it.-- if (kind === 'forfeit' && ctx.loserId && ctx.winnerId && await liveDuring(ctx.loserId, beef.opened_at)) 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, announce, feed, story, react, lolcowIndex, cloutRankings, rapSheet, setOptOut, optedOut, hide, REACTIONS, TEMPLATES: T, addsAccusation, _test: { detectStreams, detectSnapshots, detectSounds, detectDaily, weekStart, toMs, fmt, pickQuote } };ddeleted file mode 100644@@@ -1,307 +0,0 @@--/**-- * OpenVibe.Live — Arena Listener (the ears) — Battle Cam mode-- *-- * Every TICK_MS, for every LIVE fighter whose stream is being transcribed, read the new speech-- * lines. Everything the Arena is comes from here; chat, votes and submissions do not exist.-- *-- * name-drop → a line that says another fighter's whole name, as it is spelled (names.js-- * wholeOnly: no sound-alikes, no pieces of a handle) LOCKS the ears on that fighter.-- * Everything said while locked goes to the BEEF JUDGE with the earlier context; a-- * judged hit in lines that say the name → beef.recordHit() (opens the beef, scores-- * it, puts the other side on the clock); unnamed lines are context only. See beef.js.-- * free talk → everything said while NOT locked pools for the MIC JUDGE: is this shit talk at-- * all (roast, rant, callout, brag, disrespect)? Who is it aimed at ("chat", "the-- * mods"; a fighter the judge guesses at is dropped, since nobody named them)? It-- * lands in the shit-talk feed as a `trash` moment and pays Trash Level XP (mic.js).-- *-- * Bounded: at most one judge call per stream per JUDGE_MIN_INTERVAL_MS; nothing happens for-- * streams nobody is talking on; the behaviour filter voids chunks before any model call.-- * State lives in memory (offsets are re-seeded from "now" on restart, so a restart never replays-- * old speech).-- */--'use strict';----const db = require('../db/database');--const llm = require('../ai/llm');--const aiService = require('../ai/ai-service');----const TICK_MS = 15 * 1000;--const JUDGE_MIN_WORDS = 20;--const JUDGE_MIN_INTERVAL_MS = 30 * 1000;--const MIC_MIN_QUALITY = 6; // free talk must be at least this good to land in the feed (bangers only)--const MIN_LINE_WORDS = 6; // a quotable line is a complete thought, not a fragment--const BUFFER_MAX_CHARS = 1400;----const state = new Map(); // streamId → { userId, lastOffset, lastJudgeAt, focus, mic: { lines } }----async function aiOn() { try { return await llm.isEnabled() && await llm.withinBudget(); } catch { return false; } }--function arena() { return require('./arena-service'); }--function beef() { return require('./beef'); }--function mic() { return require('./mic'); }--function parseJson(t, f = null) { try { return t ? JSON.parse(t) : f; } catch { return f; } }--function words(t) { return String(t || '').split(/\s+/).filter(Boolean).length; }--/** Tidy a quoted line: collapse whitespace, strip transcript markers, cap length, sentence case + period. */--function cleanLine(t) {-- let s = String(t || '').replace(/^\s*(?:>>|--?)\s*/, '').replace(/\s+/g, ' ').trim().slice(0, 220);-- if (!s) return '';-- s = s.charAt(0).toUpperCase() + s.slice(1);-- if (!/[.!?…"”']$/.test(s)) s += '.';-- return s;--}----// ── Aliases: who can be called out, by which names ───────────--const names = require('./names');--let _aliasCache = { at: 0, list: [] };--async function aliases(roster) {-- if (Date.now() - _aliasCache.at < 60 * 1000) return _aliasCache.list;-- const rows = roster.order.length ? await db.all('SELECT user_id, persona_json FROM arena_profiles WHERE user_id = ANY(?)', [roster.order]) : [];-- const personas = new Map(rows.map((r) => [r.user_id, parseJson(r.persona_json)]));-- const list = names.rosterEntries(roster, (id) => { const persona = personas.get(id); return [persona?.fighter_name, ...(Array.isArray(persona?.spoken_as) ? persona.spoken_as : [])]; });-- _aliasCache = { at: Date.now(), list };-- return list;--}--// Only a whole name said as it is spelled names someone: a beef is a public claim that one streamer went at another.--// Sound-alikes and pieces made "Minions" (a game's voice line) into mnss22741 and "fix it" into finditfixit, and 38 of--// the first 51 beefs had no name said at all (2026-10-18).--async function mentionsIn(text, speakerId, roster) { return (await mentionsDetailed(text, speakerId, roster)).map(m => m.userId); }--async function mentionsDetailed(text, speakerId, roster) { return names.findMentions(text, await aliases(roster), { excludeUserId: speakerId, wholeOnly: true }); }----// ── Judges ───────────────────────────────────────────────────----// The judges' prompts and JSON schemas are OpenVibe.AI's versioned templates live.arena.judge_beef and--// live.arena.judge_mic (roadmap WS-O task 2); Live sends what it heard and applies the house rules below.----const SPICY = /\b(clown|clowns|weak|scared|duck|ducking|ducked|trash|garbage|mid|washed|bum|bums|ratio|cook|cooked|better than|can't|cannot|never|nobody|beat|fraud|frauds|ass|bet|catch (these|this)|come see|pull up|fight me|square up|run it|talk (that|your)|cope|seethe|cry|loser|losers|bozo|bozos|goofy|fake|scam|dogshit|shit at|suck|sucks|pathetic|embarrassing|sit down|shut up|nobody cares|delusional|coward|cowards)\b/;--function heuristicBeef(text, targetNames, { named = true } = {}) {-- const t = text.toLowerCase();-- const spicy = SPICY.test(t);-- const pronouns = /\b(he|him|his|she|her|they|them|their|that (guy|dude|man|girl|streamer)|this (guy|dude|man|girl|streamer)|the (guy|dude|beef)|bro's|bros)\b/.test(t);-- const aboutTarget = named || pronouns;-- const excl = (text.match(/!/g) || []).length;-- const quality = Math.min(10, (spicy ? 5 : 1) + excl + (words(text) > 30 ? 1 : 0));-- const sentences = text.split(/(?<=[.!?])\s+/).filter(Boolean);-- const namedLines = sentences.filter(l => targetNames.some(n => l.toLowerCase().includes(n)));-- const pick = (namedLines.length ? namedLines : sentences).sort((a, b) => b.length - a.length)[0] || text;-- return { about_target: aboutTarget, aimed_at_target: aboutTarget && spicy, quality, best_line: pick.trim().slice(0, 200), about: text.split(/\s+/).slice(0, 8).join(' '), announcer: '', flagged: false, _fallback: true };--}--function heuristicMic(text) {-- const t = text.toLowerCase();-- const spicy = SPICY.test(t);-- const excl = (text.match(/!/g) || []).length;-- const aimed = /\b(chat|you guys|y'all|yall|all of you)\b/.test(t) ? 'chat' : /\bmods?\b/.test(t) ? 'the mods' : /\b(twitch|kick|youtube) streamers?\b/.test(t) ? 'other streamers' : '';-- const quality = spicy ? Math.min(10, 4 + excl + (words(text) > 30 ? 1 : 0) + (/\b(never|nobody|fraud|washed|cooked|ratio)\b/.test(t) ? 1 : 0)) : 0;-- const sentences = text.split(/(?<=[.!?])\s+/).filter(Boolean);-- const spicyLines = sentences.filter(l => SPICY.test(l.toLowerCase()));-- const pick = (spicyLines.length ? spicyLines : sentences).sort((a, b) => b.length - a.length)[0] || text;-- return { is_trash_talk: spicy && words(pick) >= MIN_LINE_WORDS, garbled: false, quality, best_line: pick.trim().slice(0, 200), about: text.split(/\s+/).slice(0, 8).join(' '), aimed_at: aimed, announcer: '', flagged: false, _fallback: true };--}----async function judgeBeef(speakerId, targetId, text, roster, { context = null, named = true, how = 'exact' } = {}) {-- if (arena()._isBannedText(text)) return { about_target: false, aimed_at_target: false, quality: 0, best_line: '', about: 'voided', announcer: '', flagged: true };-- const tf = roster.byId[targetId];-- const targetNames = [tf.user.username, tf.user.display_name, (parseJson((await db.get('SELECT persona_json FROM arena_profiles WHERE user_id = ?', [targetId]))?.persona_json) || {}).fighter_name].filter(Boolean);-- const spokenForms = [...new Set(targetNames.flatMap(n => names.variants(n)))].slice(0, 8);-- let j = null;-- if (await aiOn()) {-- try {-- const r = await aiService.structured('live.arena.judge_beef', {-- target_names: targetNames.map((n) => String(n).slice(0, 120)).slice(0, 8), target_as_transcribed: spokenForms.map((n) => String(n).slice(0, 120)),-- target_named_in_new_speech: !!named, how_the_name_was_matched: String(how || 'exact').slice(0, 40),-- what_speaker_already_said_about_target: context ? String(context).slice(0, 4000) : null, new_speech: String(text).slice(0, 6000),-- }, { meter: { kind: 'arena_beef_judge', role: 'chat', ownerUserId: speakerId, source: 'arena' } });-- if (r && typeof r.quality === 'number') j = r;-- } catch (e) { console.warn('[Arena] beef judge:', e.message); }-- }-- if (!j) j = heuristicBeef(text, spokenForms.length ? spokenForms : targetNames.map(n => n.toLowerCase()), { named });-- const about = (j.about_target !== false) && !j.flagged;-- const line = cleanLine(j.best_line);-- // A hit needs a quotable line; a beef fed by fragments reads like nonsense on the page.-- const aimed = about && !!j.aimed_at_target && words(line) >= MIN_LINE_WORDS;-- return { about_target: about, aimed_at_target: aimed, quality: aimed ? Math.max(0, Math.min(10, Math.round(Number(j.quality) || 0))) : 0, best_line: line, about: String(j.about || '').slice(0, 80), announcer: String(j.announcer || '').slice(0, 140), flagged: !!j.flagged, fallback: !!j._fallback };--}----async function judgeMic(speakerId, text) {-- if (arena()._isBannedText(text)) return { is_trash_talk: false, quality: 0, best_line: '', about: 'voided', aimed_at: '', announcer: '', flagged: true };-- let j = null;-- if (await aiOn()) {-- try {-- const r = await aiService.structured('live.arena.judge_mic', { speech: String(text).slice(0, 6000) }, { meter: { kind: 'arena_mic_judge', role: 'chat', ownerUserId: speakerId, source: 'arena' } });-- if (r && typeof r.quality === 'number') j = r;-- } catch (e) { console.warn('[Arena] mic judge:', e.message); }-- }-- if (!j) j = heuristicMic(text);-- const line = cleanLine(j.best_line);-- const ok = !!j.is_trash_talk && !j.flagged && !j.garbled && words(line) >= MIN_LINE_WORDS;-- return { is_trash_talk: ok, garbled: !!j.garbled, quality: ok ? Math.max(0, Math.min(10, Math.round(Number(j.quality) || 0))) : 0, best_line: line, about: String(j.about || '').slice(0, 80), aimed_at: String(j.aimed_at || '').toLowerCase().slice(0, 60), announcer: String(j.announcer || '').slice(0, 140), flagged: !!j.flagged, fallback: !!j._fallback };--}----// ── Tick ─────────────────────────────────────────────────────----async function liveTranscribedStreams(roster) {-- return (await db.all(`SELECT s.id, s.user_id, s.started_at FROM streams s WHERE s.is_live = 1 AND EXISTS (SELECT 1 FROM stream_timeline_events e WHERE e.stream_id = s.id AND e.kind = 'speech' AND e.created_at >= datetime('now', '-30 minutes'))`))-- .filter(s => roster.byId[s.user_id]);--}--function streamOffsetNow(stream) {-- const startedMs = stream.started_at ? Date.parse(String(stream.started_at).replace(' ', 'T') + 'Z') : Date.now();-- return Math.max(0, (Date.now() - startedMs) / 1000);--}--function bufferText(lines) { return lines.map(l => l.t.replace(/^\s*(?:>>|--?)\s*/, '').trim()).filter(Boolean).join(' ').replace(/\s+/g, ' ').slice(-BUFFER_MAX_CHARS); }--function lineRefFor(lines, bestLine) {-- const needle = String(bestLine || '').toLowerCase().slice(0, 30);-- const hit = (needle && lines.find(l => l.t.toLowerCase().includes(needle))) || lines[0];-- return hit ? { vod_id: hit.v || null, sec: Math.max(0, hit.s - 2) } : { vod_id: null, sec: null };--}----/**-- * Per-stream focus: after a fighter's name is said, the listener LOCKS ON to that fighter. Every-- * following line goes to the beef judge with the earlier context. Each judged hit extends the lock-- * (FOCUS_EXTEND_MS); two chunks in a row that are not about the target, or FOCUS_MAX_MS since the-- * last actual name-drop, drop it. A different name-drop switches focus.-- */--const FOCUS_TAIL_MS = 2 * 60 * 1000;--const FOCUS_EXTEND_MS = 3 * 60 * 1000;--const FOCUS_MAX_MS = 20 * 60 * 1000;--const FOCUS_MISSES_TO_DROP = 2;----function newFocus(targetId, now, how) { return { targetId, since: now, namedAt: now, lockUntil: now + FOCUS_TAIL_MS, lines: [], misses: 0, hits: 0, context: null, how }; }----async function judgeFocus(stream, roster, st, events, { reason }) {-- const f = st.focus;-- if (!f || !f.lines.length) return false;-- const text = bufferText(f.lines);-- const lines = f.lines; f.lines = [];-- const named = lines.some(l => l.named);-- st.lastJudgeAt = Date.now();-- const j = await judgeBeef(stream.user_id, f.targetId, text, roster, { context: f.context, named, how: f.how });-- const now = Date.now();-- const minQ = f.hits > 0 ? 3 : (f.how === 'exact' ? 3 : 5);-- // A hit needs the target's name in the lines judged: what follows a name-drop is context, never a hit by itself-- // (a League stream's voice lines after one transcribed "Adam" scored a 107–0 beef, 2026-10-18).-- if (j.aimed_at_target && j.quality >= minQ && named) {-- const ref = lineRefFor(lines, j.best_line);-- const res = await beef().recordHit(stream.user_id, f.targetId, { quality: j.quality, best_line: j.best_line, about: j.about, announcer: j.announcer, vod_id: ref.vod_id, sec: ref.sec, stream_id: stream.id });-- f.hits++; f.misses = 0; f.lockUntil = Math.min(now + FOCUS_EXTEND_MS, f.namedAt + FOCUS_MAX_MS);-- f.context = `${f.context ? f.context + ' | ' : ''}${j.about}${j.best_line ? ` ("${j.best_line.slice(0, 120)}")` : ''}`.slice(-600);-- st.lastBeefJudgement = { at: new Date().toISOString(), target_id: f.targetId, ...j, opened: res?.opened, named, reason };-- events.push({ kind: 'beef_hit', streamId: stream.id, speakerId: stream.user_id, targetId: f.targetId, opened: res?.opened, quality: j.quality, line: j.best_line, named, continued: !named });-- return true;-- }-- if (j.about_target) {-- f.misses = 0; f.lockUntil = Math.min(Math.max(f.lockUntil, now + FOCUS_TAIL_MS / 2), f.namedAt + FOCUS_MAX_MS);-- f.context = `${f.context ? f.context + ' | ' : ''}(neutral) ${j.about}`.slice(-600);-- st.lastBeefJudgement = { at: new Date().toISOString(), target_id: f.targetId, ...j, named, reason };-- events.push({ kind: 'beef_neutral', streamId: stream.id, speakerId: stream.user_id, targetId: f.targetId, about: j.about, named });-- return true;-- }-- f.misses++;-- st.lastBeefJudgement = { at: new Date().toISOString(), target_id: f.targetId, ...j, named, reason };-- events.push({ kind: 'beef_miss', streamId: stream.id, speakerId: stream.user_id, targetId: f.targetId, about: j.about, named });-- if (f.misses >= FOCUS_MISSES_TO_DROP) { events.push({ kind: 'focus_dropped', streamId: stream.id, targetId: f.targetId, hits: f.hits }); st.focus = null; }-- return true;--}----/**-- * The judge's "aimed at" for free talk, which named no fighter (a whole name said sends the line to the beef judge):-- * "chat" or "the mods" stays; a guess that is a fighter is dropped, because nobody named them.-- */--async function aimedAtUnnamed(aimedAt, speakerId, roster) {-- if (!aimedAt) return null;-- const guessed = names.findMentions(aimedAt, await aliases(roster), { excludeUserId: speakerId });-- return guessed.length ? null : aimedAt;--}----/** Free talk (not locked on anyone): is it shit talk at all, and at whom? */--async function judgeFreeTalk(stream, roster, st, events) {-- const lines = st.mic.lines; st.mic.lines = [];-- const text = bufferText(lines);-- st.lastJudgeAt = Date.now();-- const j = await judgeMic(stream.user_id, text);-- st.lastMicJudgement = { at: new Date().toISOString(), ...j };-- if (!j.is_trash_talk || j.quality < MIC_MIN_QUALITY) { events.push({ kind: 'mic_miss', streamId: stream.id, speakerId: stream.user_id, about: j.about }); return; }-- const ref = lineRefFor(lines, j.best_line);-- if (await mic().isDuplicate(stream.user_id, j.best_line)) { events.push({ kind: 'mic_dupe', streamId: stream.id, speakerId: stream.user_id }); return; }-- const aimedAt = await aimedAtUnnamed(j.aimed_at, stream.user_id, roster);-- const m = await mic().addMoment({ userId: stream.user_id, streamId: stream.id, vodId: ref.vod_id, sec: ref.sec, kind: 'trash', aimedAt, text: j.best_line || text.slice(0, 220), about: j.about, quality: j.quality, announcer: j.announcer });-- if (m) events.push({ kind: 'mic_hit', streamId: stream.id, speakerId: stream.user_id, quality: j.quality, line: j.best_line, aimedAt: j.aimed_at, momentId: m.id });--}----async function tickStream(stream, roster, events) {-- let st = state.get(stream.id);-- if (!st) { st = { userId: stream.user_id, lastOffset: streamOffsetNow(stream) - 20, lastJudgeAt: 0, focus: null, mic: { lines: [] } }; state.set(stream.id, st); }-- const rows = await db.all(`SELECT text, start_sec, vod_id FROM stream_timeline_events WHERE stream_id = ? AND kind = 'speech' AND start_sec > ? ORDER BY start_sec ASC LIMIT 100`, [stream.id, st.lastOffset]);-- if (rows.length) st.lastOffset = rows[rows.length - 1].start_sec;-- const now = Date.now();-- if (st.focus && now > st.focus.lockUntil) {-- if (st.focus.lines.length && st.focus.lines.reduce((n, l) => n + words(l.t), 0) >= JUDGE_MIN_WORDS && now - st.lastJudgeAt >= JUDGE_MIN_INTERVAL_MS) await judgeFocus(stream, roster, st, events, { reason: 'lock expired' });-- if (st.focus) { events.push({ kind: 'focus_dropped', streamId: stream.id, targetId: st.focus.targetId, hits: st.focus.hits, why: 'timeout' }); st.focus = null; }-- }-- for (const r of rows) {-- const line = { t: String(r.text || ''), s: Math.floor(r.start_sec), v: r.vod_id || null, named: false };-- const mentions = await mentionsDetailed(line.t, stream.user_id, roster);-- if (mentions.length) {-- const m = mentions[0];-- line.named = true;-- if (st.focus && st.focus.targetId !== m.userId) {-- if (st.focus.lines.reduce((n, l) => n + words(l.t), 0) >= JUDGE_MIN_WORDS) await judgeFocus(stream, roster, st, events, { reason: 'target switch' });-- st.focus = null;-- }-- if (!st.focus) { st.focus = newFocus(m.userId, now, m.how); events.push({ kind: 'focus', streamId: stream.id, speakerId: stream.user_id, targetId: m.userId, how: m.how, hit: m.hit }); }-- else { st.focus.namedAt = now; st.focus.lockUntil = Math.max(st.focus.lockUntil, now + FOCUS_TAIL_MS); st.focus.misses = 0; }-- st.focus.lines.push(line);-- } else if (st.focus) {-- st.focus.lines.push(line);-- } else {-- st.mic.lines.push(line);-- }-- }-- if (st.focus && st.focus.lines.length > 80) st.focus.lines = st.focus.lines.slice(-80);-- if (st.mic.lines.length > 60) st.mic.lines = st.mic.lines.slice(-60);---- if (now - st.lastJudgeAt < JUDGE_MIN_INTERVAL_MS) return;---- // 1) Locked on a fighter → the beef judge (a callout is the interesting thing).-- if (st.focus && st.focus.lines.reduce((n, l) => n + words(l.t), 0) >= JUDGE_MIN_WORDS) {-- await judgeFocus(stream, roster, st, events, { reason: st.focus.lines.some(l => l.named) ? 'name-drop' : 'continuation' });-- return; // one judge call per stream per tick-- }-- // 2) Free talk → the mic judge.-- if (!st.focus && st.mic.lines.reduce((n, l) => n + words(l.t), 0) >= JUDGE_MIN_WORDS) await judgeFreeTalk(stream, roster, st, events);--}----let _timer = null, _busy = false;--async function tick() {-- if (_busy) return [];-- _busy = true;-- const events = [];-- try {-- const roster = await arena().loadRoster();-- const streams = await liveTranscribedStreams(roster);-- for (const s of streams) { try { await tickStream(s, roster, events); } catch (e) { console.warn(`[Arena] listener stream ${s.id}:`, e.message); } }-- for (const id of [...state.keys()]) if (!streams.find(s => s.id === id)) state.delete(id);-- try { await beef().tick(); } catch (e) { console.warn('[Arena] beef tick:', e.message); }-- } finally { _busy = false; }-- return events;--}----async function consoleState(userId) {-- for (const [streamId, st] of state) if (st.userId === userId) {-- const f = st.focus;-- return {-- stream_id: streamId, listening: true,-- focus: f ? { target_id: f.targetId, target: await mic().nameOf(f.targetId).catch(() => null), how: f.how, since: new Date(f.since).toISOString(), lock_seconds_left: Math.max(0, Math.round((f.lockUntil - Date.now()) / 1000)), hits: f.hits, misses: f.misses, pending_words: f.lines.reduce((n, l) => n + words(l.t), 0), context: f.context } : null,-- pending_mic_words: st.mic.lines.reduce((n, l) => n + words(l.t), 0),-- last_mic_judgement: st.lastMicJudgement || null, last_beef_judgement: st.lastBeefJudgement || null, last_judge_at: st.lastJudgeAt ? new Date(st.lastJudgeAt).toISOString() : null,-- };-- }-- return { listening: false };--}----function start() {-- if (_timer) return;-- _timer = setInterval(() => tick().catch(e => console.warn('[Arena] listener:', e.message)), TICK_MS);-- if (_timer.unref) _timer.unref();-- console.log('[Arena] listener started (every 15 s — name-drops → beef judge, free talk → mic judge)');--}--function stop() { if (_timer) { clearInterval(_timer); _timer = null; } }----module.exports = { start, stop, tick, consoleState, TICK_MS, JUDGE_MIN_WORDS, JUDGE_MIN_INTERVAL_MS, MIC_MIN_QUALITY, MIN_LINE_WORDS, cleanLine, FOCUS_TAIL_MS, FOCUS_EXTEND_MS, FOCUS_MAX_MS, SPICY, _mentionsIn: mentionsIn, _mentionsDetailed: mentionsDetailed, _aimedAtUnnamed: aimedAtUnnamed, _aliases: aliases, _heuristicBeef: heuristicBeef, _heuristicMic: heuristicMic, _judgeBeef: judgeBeef, _judgeMic: judgeMic, _state: state };ddeleted file mode 100644@@@ -1,187 +0,0 @@--/**-- * OpenVibe.Live — Arena mic ledger (Battle Cam mode)-- *-- * The Arena runs on ONE input: what fighters say on mic (stream_timeline_events speech,-- * judged by listener.js). This module is the ledger for that:-- *-- * moments → every judged piece of shit talk lands here: a callout that opened/fed a-- * beef (`beef_hit`), or free-standing trash talk aimed at chat / a group / a-- * name that is not on the roster (`trash`). VOD-linked so every line can be-- * replayed at the second it was said.-- * XP → moment quality → Trash Level (arena_trash_levels / arena_xp_log). No other-- * source of XP exists: not chat, not votes, not check-ins.-- * mic stats → per-fighter aggregates the roster ratings are built from (HEAT, AIM,-- * CLAPBACK, KILLS…) — see arena-service.js.-- * feed → the live "shit talk feed": newest judged lines across every live cam.-- *-- * The behaviour filter (threats / minors / doxxing — never vocabulary) is applied before any-- * line is stored; see arena-service.isBannedText.-- */--'use strict';----const db = require('../db/database');----const XP_PER_LEVEL = 50;--const XP_MOMENT = 0.8; // × quality--const XP_HYPE = 1;------function parseJson(t, f = null) { try { return t ? JSON.parse(t) : f; } catch { return f; } }--function arena() { return require('./arena-service'); }--function levelFor(xp) { return 1 + Math.floor((Number(xp) || 0) / XP_PER_LEVEL); }--async function levelRow(userId) {-- return await db.get('SELECT * FROM arena_trash_levels WHERE user_id = ?', [userId]) || { user_id: userId, xp: 0, level: 1, beef_hits: 0, mic_moments: 0, best_line: null, best_line_score: 0 };--}----// ── XP / Trash Level ─────────────────────────────────────────----async function addXp(userId, amount, reason, refId = null, extra = {}) {-- amount = Math.round(Number(amount) || 0);-- if (amount <= 0) return await levelRow(userId);-- const before = await levelRow(userId);-- await db.run(`INSERT INTO arena_trash_levels (user_id, xp, level) VALUES (?, ?, ?)-- ON CONFLICT(user_id) DO UPDATE SET xp = arena_trash_levels.xp + excluded.xp, level = ?, updated_at = ov_now()`,-- [userId, amount, levelFor(amount), levelFor((before.xp || 0) + amount)]);-- await db.run('INSERT INTO arena_xp_log (user_id, amount, reason, ref_id) VALUES (?, ?, ?, ?)', [userId, amount, reason, refId]);-- const sets = [];-- if (extra.moment) sets.push('mic_moments = mic_moments + 1');-- if (extra.beefHit) sets.push('beef_hits = beef_hits + 1');-- if (sets.length) await db.run(`UPDATE arena_trash_levels SET ${sets.join(', ')} WHERE user_id = ?`, [userId]);-- if (extra.line && (extra.lineScore || 0) > (before.best_line_score || 0)) {-- await db.run('UPDATE arena_trash_levels SET best_line = ?, best_line_vod_id = ?, best_line_sec = ?, best_line_score = ? WHERE user_id = ?', [String(extra.line).slice(0, 220), extra.lineVodId || null, extra.lineSec ?? null, extra.lineScore, userId]);-- }-- const after = await levelRow(userId);-- if (after.level > before.level) {-- console.log(`[Arena] user ${userId} → Trash Level ${after.level}`);-- try { require('./notify').arenaNotify(userId, { type: 'level', title: `Trash Level ${after.level}`, message: `${after.xp} XP from pure mic. Keep talking.`, icon: '🎙️', url: '/arena', key: `level:${after.level}` }); } catch { /* */ }-- }-- try { await arena().loadRoster(true); } catch { /* */ }-- return { ...after, leveled_up: after.level > before.level, gained: amount };--}----async function recentXp(userId, days = 7) {-- return (await db.get(`SELECT COALESCE(SUM(amount), 0)::bigint AS xp FROM arena_xp_log WHERE user_id = ? AND created_at >= datetime('now', ?)`, [userId, `-${days} days`]))?.xp || 0;--}----async function levelView(userId) {-- const r = await levelRow(userId);-- const xp = r.xp || 0;-- return {-- level: levelFor(xp), xp, xp_into_level: xp - (levelFor(xp) - 1) * XP_PER_LEVEL, xp_per_level: XP_PER_LEVEL, next_level_xp: levelFor(xp) * XP_PER_LEVEL,-- mic_moments: r.mic_moments || 0, beef_hits: r.beef_hits || 0,-- recent_xp: await recentXp(userId),-- best_line: r.best_line ? { text: r.best_line, vod_id: r.best_line_vod_id, sec: r.best_line_sec, score: r.best_line_score } : null,-- };--}----async function levelsLeaderboard(limit = 10) {-- const roster = await arena().loadRoster();-- return (await Promise.all((await db.all('SELECT * FROM arena_trash_levels WHERE xp > 0 ORDER BY xp DESC LIMIT ?', [limit]))-- .map(async r => ({ ...await fighterBrief(r.user_id, roster), xp: r.xp, level: levelFor(r.xp), beef_hits: r.beef_hits || 0, mic_moments: r.mic_moments || 0, best_line: r.best_line ? { text: r.best_line, vod_id: r.best_line_vod_id, sec: r.best_line_sec, score: r.best_line_score } : null }))));--}----// ── Names + briefs ───────────────────────────────────────────----// Real names only — no AI ring names. The fighter IS the streamer.--async function nameOf(userId) {-- const u = await db.getUserById(userId);-- return u?.display_name || u?.username || `user${userId}`;--}----async function fighterBrief(userId, roster) {-- const f = roster && roster.byId ? roster.byId[userId] : null;-- return {-- user: f ? f.user : (await db.getUserById(userId) ? arena().publicUser(await db.getUserById(userId)) : { id: userId, username: `user${userId}`, display_name: `user${userId}` }),-- fighter_name: await nameOf(userId),-- rank: f ? roster.order.indexOf(userId) + 1 : null,-- image_url: await (async () => { try { return await arena().getFighterImageUrl(userId); } catch { return null; } })(),-- live: !!await db.get('SELECT 1 FROM streams WHERE user_id = ? AND is_live = 1 LIMIT 1', [userId]),-- level: levelFor((await levelRow(userId)).xp || 0),-- };--}----// ── Moments ──────────────────────────────────────────────────----/**-- * Store a judged mic line. kind: 'trash' (aimed at chat / a group / a non-roster name),-- * 'beef_hit' (fed a beef; target_user_id + beef_id set). Pays XP for 'trash' here; beef hits-- * are paid by beef.recordHit. Returns the row or null when the line is behaviour-filtered.-- */--async function addMoment({ userId, streamId = null, vodId = null, sec = null, kind = 'trash', targetUserId = null, beefId = null, aimedAt = null, text, about = null, quality = 0, announcer = null, saidAt = null }) {-- const t = String(text || '').replace(/\s+/g, ' ').trim().slice(0, 240);-- if (!t) return null;-- try { if (arena()._isBannedText(t)) return null; } catch { /* */ }-- const q = Math.max(0, Math.min(10, Number(quality) || 0));-- const r = await db.run(`INSERT INTO arena_mic_moments (user_id, stream_id, vod_id, sec, kind, target_user_id, beef_id, aimed_at, text, about, quality, announcer, said_at)-- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE(?::text, ov_now())) RETURNING id`,-- [userId, streamId, vodId, sec == null ? null : Math.max(0, Math.floor(sec)), kind, targetUserId, beefId, aimedAt ? String(aimedAt).slice(0, 80) : null, t, about ? String(about).slice(0, 80) : null, q, announcer ? String(announcer).slice(0, 140) : null, saidAt]);-- const id = Number(r.lastInsertRowid);-- if (kind === 'trash' || kind === 'callout') await addXp(userId, q * XP_MOMENT, kind === 'callout' ? 'mic_callout' : 'mic_trash', id, { moment: true, line: t, lineScore: q, lineVodId: vodId, lineSec: sec });-- return await db.get('SELECT * FROM arena_mic_moments WHERE id = ?', [id]);--}----/** Same line (or near enough) from the same fighter in the last 6 h → don't file it twice. */--async function isDuplicate(userId, text) {-- const norm = String(text || '').toLowerCase().replace(/[^a-z0-9 ]/g, '').replace(/\s+/g, ' ').trim();-- if (!norm) return false;-- const rows = await db.all(`SELECT text FROM arena_mic_moments WHERE user_id = ? AND created_at >= datetime('now', '-6 hours') ORDER BY id DESC LIMIT 40`, [userId]);-- return rows.some(r => { const t = String(r.text || '').toLowerCase().replace(/[^a-z0-9 ]/g, '').replace(/\s+/g, ' ').trim(); return t === norm || (t.length > 30 && (t.includes(norm) || norm.includes(t))); });--}----async function momentView(m, roster) {-- const brief = await fighterBrief(m.user_id, roster);-- return {-- id: m.id, kind: m.kind, user: brief.user, fighter_name: brief.fighter_name, rank: brief.rank, image_url: brief.image_url, level: brief.level, live: brief.live,-- target: m.target_user_id ? await (async () => { const b = await fighterBrief(m.target_user_id, roster); return { user: b.user, fighter_name: b.fighter_name, rank: b.rank }; })() : null,-- beef_id: m.beef_id || null, aimed_at: m.aimed_at || null, text: m.text, about: m.about, quality: Number(m.quality) || 0, announcer: m.announcer || null,-- stream_id: m.stream_id, vod_id: m.vod_id, sec: m.sec, at: m.said_at || m.created_at,-- };--}----/** The shit-talk feed: newest judged lines across the roster. */--async function feed({ limit = 40, since = null, userId = null } = {}) {-- const roster = await arena().loadRoster();-- const params = [];-- // The feed shows bangers only; weak lines still count for stats/XP but never headline the page.-- let where = 'quality >= 5';-- if (userId) { where += ' AND user_id = ?'; params.push(userId); }-- if (since) { where += ' AND id > ?'; params.push(Number(since) || 0); }-- params.push(Math.min(200, Math.max(1, limit)));-- return (await Promise.all((await db.all(`SELECT * FROM arena_mic_moments WHERE ${where} ORDER BY COALESCE(said_at, created_at) DESC, id DESC LIMIT ?`, params)).map(async m => await momentView(m, roster))));--}--async function momentsFor(userId, limit = 12) { return await feed({ limit, userId }); }--async function bestLines(userId, limit = 5) {-- const roster = await arena().loadRoster();-- return (await Promise.all((await db.all('SELECT * FROM arena_mic_moments WHERE user_id = ? AND quality >= 5 ORDER BY quality DESC, id DESC LIMIT ?', [userId, limit])).map(async m => await momentView(m, roster))));--}--async function latestFor(userId) {-- const m = await db.get('SELECT * FROM arena_mic_moments WHERE user_id = ? ORDER BY COALESCE(said_at, created_at) DESC, id DESC LIMIT 1', [userId]);-- return m ? await momentView(m, await arena().loadRoster()) : null;--}----/** Aggregates the ratings are built from. All from the mic ledger + beefs — nothing else. */--async function micStats(userId, days = 30) {-- const win = `-${days} days`;-- const m = await db.get(`SELECT COUNT(*) AS n, COALESCE(AVG(quality), 0)::float8 AS avg_q, COALESCE(MAX(quality), 0) AS best_q,-- COUNT(*) FILTER (WHERE kind IN ('beef_hit', 'callout')) AS beef_hits, COUNT(*) FILTER (WHERE kind = 'trash') AS trash,-- COUNT(*) FILTER (WHERE quality >= 7) AS bangers-- FROM arena_mic_moments WHERE user_id = ? AND created_at >= datetime('now', ?)`, [userId, win]) || {};-- let b = {};-- try { b = await db.get(`SELECT COUNT(*) FILTER (WHERE b_user_id = ?) AS targeted, COUNT(*) FILTER (WHERE b_user_id = ? AND responded = 1) AS answered, COUNT(*) FILTER (WHERE winner_user_id = ?) AS wins, COUNT(*) FILTER (WHERE status = 'resolved' AND winner_user_id IS NOT NULL AND winner_user_id != ?) AS losses, COUNT(*) FILTER (WHERE status = 'resolved' AND resolution = 'forfeit' AND winner_user_id != ? AND winner_user_id IS NOT NULL) AS ducked FROM arena_beefs WHERE (a_user_id = ? OR b_user_id = ?) AND status IN ('open', 'resolved')`, [userId, userId, userId, userId, userId, userId, userId]) || {}; } catch { b = {}; }-- const speechMin = await (async () => { try { return ((await db.get(`SELECT COALESCE(SUM(COALESCE(end_sec, start_sec + 3) - start_sec), 0)::bigint AS s FROM stream_timeline_events WHERE user_id = ? AND kind = 'speech' AND created_at >= datetime('now', ?)`, [userId, win]))?.s || 0) / 60; } catch { return 0; } })();-- const hours = Math.max(speechMin / 60, 0.05);-- const targeted = b.targeted || 0;-- return {-- window_days: days, moments: m.n || 0, trash: m.trash || 0, beef_hits: m.beef_hits || 0, bangers: m.bangers || 0,-- avg_quality: Number((m.avg_q || 0).toFixed(2)), best_quality: Number(m.best_q || 0),-- hits_per_mic_hour: Number((((m.beef_hits || 0) + (m.trash || 0)) / hours).toFixed(2)),-- targeted, answered: b.answered || 0, clapback_rate: targeted ? Number(((b.answered || 0) / targeted).toFixed(2)) : 0,-- wins: b.wins || 0, losses: b.losses || 0, ducked: b.ducked || 0, speech_minutes: Number(speechMin.toFixed(1)),-- };--}----module.exports = {-- addXp, recentXp, levelView, levelFor, levelRow, levelsLeaderboard, nameOf, fighterBrief,-- addMoment, momentView, feed, momentsFor, bestLines, latestFor, micStats, isDuplicate,-- XP_PER_LEVEL, XP_MOMENT, XP_HYPE,--};ddeleted file mode 100644@@@ -1,203 +0,0 @@--/**-- * names.js — how a streamer's name comes out of a speech-to-text engine, and how to find it.-- *-- * "JapaneseOldGuy" is never transcribed as one token: whisper hears "Japanese old guy",-- * "japanese-old-guy", "Japanese Old Guy's stream". "lofi_dan99" → "lofi dan". "Maticus" →-- * "Matticus" / "Mattie cuss". "x_Goosely_TV" → "Goosely". This module turns a name into every-- * spoken form we can predict, then matches transcript lines against them with:-- *-- * 1. exact/loose-separator matches ("japanese old guy", "japanese-old-guy", "japaneseoldguy")-- * 2. fuzzy matches on n-grams of the line (edit distance scaled by length)-- * 3. phonetic matches (a compact metaphone-ish key: "goosely" ≈ "goose lee" ≈ "goosley")-- *-- * Guard rails: single-word aliases must be ≥ 4 letters and not a common English word, so a-- * fighter called "Guy" or "Old" cannot be "mentioned" by every sentence; multi-word aliases only-- * match as the whole phrase; the speaker never matches themselves; longest alias wins.-- */--'use strict';----const COMMON = new Set(('about after again also always another anyone anything around because before being between both bring build built came cant cannot chat check come could does doing done down each else even ever every first from game games getting give going gone good great group guy guys have having here home into just keep kind know last later least life like little live look made make many maybe mean might more most much must name need never next nice night nobody nothing okay only other over people place play player point pretty probably really right same says school seen shit should show since some something sorry stay still stop stream streamer stuff sure take talk tell than thank that their them then there these they thing think this those three time today told took trying under until very want watch week well went were what when where which while whole will with without work would year years young your live love hate lord king queen dude bro bruh fam yeah yes okay old new big small guy girl boy man men women woman baby dan dave mike john tom jack ben sam max alex chris '-- + 'matter meter motor water later letter little level lever mother master mister monster minute minutes money moment mountain market maybe major magic medic metal middle mirror mission model modern moving music mute native nature never number often order other paper party power pretty price quick quiet radio ready river robot round sauce scary second server seven shadow silver simple sister sixty slower smaller smoke solid sound south space speak speed spend spirit sport square start state steam story street strong stuff sugar summer super sweet table taken teach teeth thank thick thing third thought throw tiger timer title toast total touch tower track trade train travel treat truck truly trust truth turned twenty under upper usual value video village voice waste water wheel where white whole winter wonder world worry worse worth would write wrong yellow young about above across actor adult again agent ahead alarm album alien alive allow alone along alter among angle animal answer anyway apple armor arrow attack aware awful bacon badge basic battle beach beast begin being below bench birth black blade blame blank blast blind block blood board boost boots bottle bottom bounce bounty brain brand brave bread break brick bridge bright broken brush bucket buddy budget bullet button cable camera candy cannon cargo carry castle catch cause chain chair chance change charge chase cheap cheese chest chief child choice church claim class clean clear click climb clock close cloud coach coast coffee color combo comic coming crazy crash cream credit crime cross crowd crown cycle daily damage dance dark death decent delay demon depth desert device diamond dinner direct dirty double dozen dragon drama dream dress drink drive dungeon early earth eight empty enemy energy engine enter entire equal error event exact extra faith false fancy farm fast fatal fault favor feast fence field fight figure final finish fire flame flash flesh floor flower focus force forest forge frame fresh front frozen fruit funny giant glass glory gold grand grass green guard guess guide happy heart heavy hello hidden honor horse hotel house human hunt image inside iron island jacket joint judge juice jungle knife knight label ladder large laser latest laugh layer leader legend lemon light limit liquid local lucky lunch magic manual march match medal melee metal minor mobile moon mouse mouth movie nasty needle noise north notice number nurse ocean offer olive onion orange order outer oven owner paint panel panic paper patch peace pearl pedal pencil phone photo piano piece pilot pizza plain plane plant plate plush pocket poison police potato pound press prime print prize proof pulse punch puzzle queen quest rabbit radar raid rain range ratio razor reach recent relax reload rescue retro rifle rival rocket rough route royal rubber ruler rusty sadly safety salad sample sandwich scale scene score scout screen script scroll search secret select shape share sharp shelf shield shirt shock shoot shore short shoulder shovel signal silent skill skull slate sleep slice slide slime smart smell smile snake sneak snipe snow sock solar solo sorry sound spare spark spawn spear spell spice spider spike spoon spray staff stage stair stamp stand star steel step stick stone store storm story stove strange strike stupid sudden sunny sword tackle talent tank taste tavern temple tennis thief thirty ticket tired toilet token tomato tooth torch tough toxic trail trap trash treasure trick troll trophy trouble tunnel turtle twist uncle unique unit upset urban valley vault vendor virus vision vocal volume wagon waiting wallet weapon weird whale wheat window wire wizard wolf worker yard zombie zone'--).split(/\s+/));----const SUFFIXES = /(?:_|-)?(?:tv|ttv|live|official|yt|twitch|gaming|stream|streams|vods|irl|hd|4k)$/i;----const LEET = { '0': 'o', '1': 'i', '3': 'e', '4': 'a', '5': 's', '7': 't', '8': 'b', '@': 'a', '$': 's', '!': 'i', '|': 'l' };----function clean(s) { return String(s || '').trim(); }--function words(s) { return String(s || '').toLowerCase().split(/[^a-z0-9]+/).filter(Boolean); }----/** Split a handle the way it is SAID: camelCase, snake_case, kebab, letters|digits. */--function splitHandle(name) {-- let s = clean(name);-- if (!s) return [];-- s = s.replace(/[_.\-+]+/g, ' ')-- .replace(/([a-z])([A-Z])/g, '$1 $2') // JapaneseOldGuy → Japanese Old Guy-- .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2') // XXGooselyXX → XX Goosely XX-- .replace(/([a-zA-Z])(\d)/g, '$1 $2') // dan99 → dan 99-- .replace(/(\d)([a-zA-Z])/g, '$1 $2');-- return s.toLowerCase().split(/\s+/).filter(Boolean);--}----function deleet(w) { w = String(w); if (/^\d+$/.test(w)) return w; /* 151 stays a number, never 'isi' */ return /[a-z]/i.test(w) ? w.replace(/[0134578@$!|]/g, c => LEET[c] || c) : w; }----/**-- * All predicted spoken forms of one name (lowercase word arrays, deduped, most specific first).-- * JapaneseOldGuy → ["japanese old guy"], ["japaneseoldguy"]-- * lofi_dan99 → ["lofi dan 99"], ["lofi dan"], ["lofidan"]-- * x_Goosely_TV → ["x goosely tv"], ["goosely"], ["x goosely"]-- * M4ticus → ["m4ticus"], ["maticus"]-- */--function variants(name) {-- const out = new Set();-- const add = (ws) => { const v = ws.filter(Boolean).join(' ').trim(); if (v.length >= 3) out.add(v); };-- const raw = clean(name);-- if (!raw) return [];-- const base = splitHandle(raw);-- add(base);-- add(base.map(deleet));-- // Leet inside a token (M4ticus, l33t_dan) → un-leet the whole handle first, then split.-- if (/[a-z][0134578@$!|]+[a-z]/i.test(raw)) { const b0 = splitHandle(raw.replace(/[0134578@$!|]/g, c => LEET[c] || c)); add(b0); add(b0.filter(w => !/^\d+$/.test(w))); }-- add(base.filter(w => !/^\d+$/.test(w))); // drop pure numbers: "lofi dan 99" → "lofi dan"-- add(base.map(deleet).filter(w => !/^\d+$/.test(w)));-- const noSuffix = raw.replace(SUFFIXES, '');-- if (noSuffix !== raw) { const b2 = splitHandle(noSuffix); add(b2); add(b2.filter(w => !/^\d+$/.test(w))); }-- // Decorations people put around handles: xX_name_Xx, __name__, TheName → name-- const stripped = raw.replace(/^(?:xx+|x_|_+|the)/i, '').replace(/(?:xx+|_x|_+)$/i, '');-- if (stripped !== raw && stripped.length >= 3) { const b3 = splitHandle(stripped); add(b3); add(b3.filter(w => !/^\d+$/.test(w))); }-- // Glued form for names that ARE a phrase (a transcriber may glue or split them).-- for (const v of [...out]) { const ws = v.split(' '); if (ws.length > 1 && ws.every(w => /^[a-z]+$/.test(w))) out.add(ws.join('')); }-- // Drop unsafe single common words (would match every sentence).-- return [...out].filter(v => v.split(' ').length > 1 || (v.length >= 4 && !COMMON.has(v) && !/^\d+$/.test(v))).sort((a, b) => b.length - a.length);--}----// ── Fuzzy + phonetic ────────────────────────────────────────----function levenshtein(a, b) {-- if (a === b) return 0;-- const m = a.length, n = b.length;-- if (!m) return n; if (!n) return m;-- let prev = Array.from({ length: n + 1 }, (_, i) => i);-- for (let i = 1; i <= m; i++) {-- const cur = [i];-- for (let j = 1; j <= n; j++) cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));-- prev = cur;-- }-- return prev[n];--}----/** Compact phonetic key: "goosely" → "gsl", "goose lee" → "gsl", "matticus" → "mtks", "japanese old guy" → "jpns ld g". */--function phonetic(s) {-- let t = String(s || '').toLowerCase().replace(/[^a-z ]/g, '');-- t = t.replace(/ph/g, 'f').replace(/ck/g, 'k').replace(/qu/g, 'kw').replace(/x/g, 'ks').replace(/z/g, 's').replace(/wh/g, 'w')-- .replace(/c(?=[eiy])/g, 's').replace(/c/g, 'k').replace(/g(?=[eiy])/g, 'j').replace(/dg/g, 'j').replace(/tio/g, 'sho').replace(/y/g, 'i');-- return t.split(' ').map(w => {-- if (!w) return '';-- const first = w[0];-- let rest = w.slice(1).replace(/[aeiouhw]/g, '');-- rest = rest.replace(/(.)\1+/g, '$1');-- return (first + rest).replace(/(.)\1+/g, '$1');-- }).filter(Boolean).join(' ');--}--function ratioThreshold(len) { return len <= 4 ? 0 : len <= 7 ? 1 : len <= 11 ? 2 : 3; }----// ── Matching ────────────────────────────────────────────────----/** Regex for one variant: words joined by any separator, optional 's / plural, no letters glued around. */--function variantRegex(v) {-- const parts = v.split(' ').map(w => w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));-- return new RegExp(`(?:^|[^a-z0-9])@?${parts.join('[\\s\\-_.]*')}(?:'?s)?(?![a-z0-9])`, 'i');--}----/** Every letter of a name, in the spellings it can be said in whole ("finditfixit", "mnss22741", "maticus" for M4ticus). */--function wholeLetters(name) {-- const raw = clean(name);-- const base = splitHandle(raw);-- return new Set([base, base.map(deleet), splitHandle(raw.replace(/[0134578@$!|]/g, c => LEET[c] || c))].map(ws => ws.join('')).filter(Boolean));--}----/**-- * Build an alias entry: { userId, name, words, re, key, whole } for each variant of each name. `whole` is a variant-- * that says the entire name, not a piece of it: "find it fix it" is finditfixit, "fix it" is not; "mnss 22741" is-- * mnss22741, "mnss" is not. `{ whole: false }` marks every entry as a piece (nicknames, persona names).-- */--function aliasEntries(userId, names, { whole = true } = {}) {-- const seen = new Map();-- const out = [];-- for (const n of names.filter(Boolean)) {-- const full = whole ? wholeLetters(n) : new Set();-- for (const v of variants(n)) {-- const letters = v.replace(/\s+/g, '');-- if (seen.has(v)) { if (full.has(letters)) seen.get(v).whole = true; continue; }-- const e = { userId, name: v, words: v.split(' '), re: variantRegex(v), key: phonetic(v), letters, whole: full.has(letters) };-- seen.set(v, e);-- out.push(e);-- }-- }-- return out;--}----/**-- * Which alias entries occur in `text`. Returns [{ userId, name, how: 'exact'|'fuzzy'|'phonetic', hit }].-- * `excludeUserId` = the speaker (never mentions themselves). `wholeOnly`: only a whole name said as it is spelled-- * (no pieces, no sound-alikes): what a public claim that someone named someone needs.-- */--function findMentions(text, entries, { excludeUserId = null, fuzzy = true, wholeOnly = false } = {}) {-- const found = new Map(); // userId → best hit-- const raw = String(text || '');-- if (raw.length < 3) return [];-- if (wholeOnly) { entries = entries.filter(e => e.whole); fuzzy = false; }-- const consider = (e, how, hit) => { if (e.userId === excludeUserId) return; const cur = found.get(e.userId); if (!cur || cur.rank < (how === 'exact' ? 3 : how === 'fuzzy' ? 2 : 1)) found.set(e.userId, { userId: e.userId, name: e.name, how, hit, rank: how === 'exact' ? 3 : how === 'fuzzy' ? 2 : 1 }); };-- // 1) exact / loose separators-- for (const e of entries) { const m = raw.match(e.re); if (m) consider(e, 'exact', m[0].trim()); }-- if (!fuzzy) return [...found.values()].map(({ rank, ...r }) => r);-- // 2) fuzzy + phonetic on n-grams (1..4 words) of the line-- const toks = words(raw).map(deleet);-- if (!toks.length) return [...found.values()].map(({ rank, ...r }) => r);-- for (const e of entries) {-- if (found.has(e.userId) && found.get(e.userId).rank === 3) continue;-- const n = e.words.length;-- const target = e.letters;-- if (target.length < 6) continue; // "mater" ≈ matter/meter/water — too short to fuzzy-match safely-- if (n === 1 && COMMON.has(e.name)) continue;-- const maxD = ratioThreshold(target.length);-- // n-grams of n words, and of n±1 words (a name can be split or glued by the transcriber)-- for (const span of [n, n + 1, n - 1].filter(x => x >= 1 && x <= 4)) {-- for (let i = 0; i + span <= toks.length; i++) {-- const gram = toks.slice(i, i + span);-- const glued = gram.join('');-- if (Math.abs(glued.length - target.length) > maxD + 4) continue;-- // Fuzzy (typo-level) only on the same number of words: "matticus" ~ "maticus", "japanese old gai" ~-- // "japanese old guy" — but never "japanese guy" ~ "japanese old guy" (a different phrase).-- if (gram.filter(w => COMMON.has(w)).length * 2 > gram.length) continue; // plain English words are never a mangled name ("water is" ≠ mater)-- if (span === n && maxD > 0 && Math.abs(glued.length - target.length) <= maxD + 1 && levenshtein(glued, target) <= maxD) { consider(e, 'fuzzy', gram.join(' ')); break; }-- // "mattie cuss" → mtks == maticus → mtks; "goose lee" → gsl == goosely → gsl-- if (glued.length >= 6 && phonetic(gram.join(' ')).replace(/ /g, '') === e.key.replace(/ /g, '')) { consider(e, 'phonetic', gram.join(' ')); break; }-- }-- }-- }-- return [...found.values()].sort((a, b) => b.rank - a.rank).map(({ rank, ...r }) => r);--}----/**-- * Alias entries for a whole roster, roster-aware: besides every spoken form of each name, a handle's-- * FIRST token ("grizzly" for grizzly_bear, "japanese" is NOT for JapaneseOldGuy — common word) becomes-- * an alias when it is ≥ 5 letters, not a common word, and no other fighter starts with it.-- * `extra(id)` may add persona names / spoken_as forms per fighter.-- */--function rosterEntries(roster, extra = null) {-- const firsts = new Map();-- for (const id of roster.order) { const f = splitHandle(roster.byId[id].user.username)[0]; if (f && f.length >= 5 && !COMMON.has(f) && !/^\d+$/.test(f)) firsts.set(f, (firsts.get(f) || 0) + 1); }-- const list = [];-- for (const id of roster.order) {-- const u = roster.byId[id].user;-- const own = aliasEntries(id, [u.username, u.display_name]);-- const nick = [...(extra ? (extra(id) || []) : [])];-- const first = splitHandle(u.username)[0];-- if (first && firsts.get(first) === 1 && !variants(u.username).includes(first)) nick.push(first);-- list.push(...own, ...aliasEntries(id, nick, { whole: false }).filter(e => !own.some(o => o.name === e.name)));-- }-- return list.sort((a, b) => b.name.length - a.name.length);--}----module.exports = { variants, splitHandle, deleet, phonetic, levenshtein, aliasEntries, rosterEntries, findMentions, variantRegex, COMMON };ddeleted file mode 100644@@@ -1,19 +0,0 @@--/**-- * notify.js — Arena events → the site notification bell (openvibe.network store, via utils/notify).-- * Fire-and-forget, deduped per (user, type, key) for 10 min so clock ticks never spam.-- */--'use strict';----const { pushNotification } = require('../utils/notify');--let base = 'https://openvibe.live'; try { base = String(require('../config').baseUrl || base).replace(/\/$/, ''); } catch { /* */ }--const _recent = new Map();--function arenaNotify(userId, { type, title, message, icon = '🥊', url = '/arena', key = null, senderId = null, senderName = null }) {-- if (!userId) return;-- const k = `${userId}:${type}:${key || url}`;-- const now = Date.now();-- if (now - (_recent.get(k) || 0) < 10 * 60 * 1000) return;-- _recent.set(k, now);-- if (_recent.size > 5000) for (const [kk, t] of _recent) if (now - t > 10 * 60 * 1000) _recent.delete(kk);-- try { pushNotification({ user_id: userId, type: `ARENA_${String(type).toUpperCase()}`, title: String(title).slice(0, 120), message: String(message || '').slice(0, 240), icon, url: url.startsWith('http') ? url : `${base}${url}`, sender_id: senderId || undefined, sender_name: senderName || undefined }); } catch { /* */ }--}--module.exports = { arenaNotify };ddeleted file mode 100644@@@ -1,196 +0,0 @@--/**-- * OpenVibe.Live — Arena API (mounted at /api/arena) — Battle Cam mode-- *-- * GET /status · /fighters · /fighters/:user · /fighters/:user/stat/:stat · /live-- * GET /feed?limit&since the shit-talk feed: newest judged mic lines across every cam-- * POST /fighters/:user/refresh admin: regenerate persona (+ portrait)-- * GET /console/:user what the ears hear for a live fighter (focus lock, judgements)-- * GET /beefs · /beefs/:id · POST /beefs/:id/hype {side}-- * 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';----const express = require('express');--const { requireAuth, optionalAuth } = require('../auth/auth');--const permissions = require('../auth/permissions');--const db = require('../db/database');--const arena = require('./arena-service');--const mic = require('./mic');--const beef = require('./beef');--const listener = require('./listener');----const router = express.Router();----router.use(async (req, res, next) => { if (!await arena.arenaEnabled()) return res.status(404).json({ error: 'Arena is disabled' }); next(); });--const fail = (res, err, msg) => { console.error('[Arena]', msg, err.message); res.status(500).json({ error: msg }); };--const userFrom = async (param) => (/^\d+$/.test(String(param)) ? await db.getUserById(Number(param)) : await db.get('SELECT * FROM users WHERE lower(username) = lower(?)', [String(param).replace(/^@/, '')]));----router.get('/status', async (req, res) => { try { res.json(await arena.status()); } catch (err) { fail(res, err, 'Arena unavailable'); } });----router.get('/fighters', async (req, res) => {-- try { res.set('Cache-Control', 'public, max-age=30'); res.json({ fighters: await arena.listFighters(), ai: await arena.aiOn(), image_generation: arena.imageGenAvailable(), stats: arena.STAT_KEYS, stat_meta: arena.STAT_META }); }-- catch (err) { fail(res, err, 'Failed to load the roster'); }--});--router.get('/live', async (req, res) => { try { res.set('Cache-Control', 'no-store'); res.json({ live: await arena.liveFighters() }); } catch (err) { fail(res, err, 'Failed to load live fighters'); } });--router.get('/feed', async (req, res) => {-- try {-- res.set('Cache-Control', 'no-store');-- const limit = Math.min(100, Math.max(1, parseInt(req.query.limit, 10) || 40));-- const since = req.query.since ? Number(req.query.since) : null;-- res.json({ feed: await mic.feed({ limit, since }), ai: await arena.aiOn() });-- } catch (err) { fail(res, err, 'Failed to load the feed'); }--});----router.get('/fighters/:user/stat/:stat', async (req, res) => {-- try {-- const user = await userFrom(req.params.user);-- if (!user || !(await arena.loadRoster()).byId[user.id]) return res.status(404).json({ error: 'No such fighter on the roster' });-- const detail = await arena.getStatDetail(user.id, String(req.params.stat));-- if (!detail) return res.status(404).json({ error: 'Unknown stat' });-- res.set('Cache-Control', 'public, max-age=120');-- res.json(detail);-- } catch (err) { fail(res, err, 'Failed to load stat detail'); }--});--router.get('/fighters/:user', async (req, res) => {-- try {-- const card = await arena.getFighter(req.params.user, { generate: req.query.generate !== '0' });-- if (!card) return res.status(404).json({ error: 'No such fighter' });-- if (!card.not_on_roster) {-- try { card.rivalries = await beef.rivalriesFor(card.user.id); } catch { card.rivalries = []; }-- try { card.moments = await mic.momentsFor(card.user.id, 14); } catch { card.moments = []; }-- try { card.best_lines = await mic.bestLines(card.user.id, 5); } catch { card.best_lines = []; }-- }-- res.set('Cache-Control', 'no-store');-- res.json(card);-- } catch (err) { fail(res, err, 'Failed to load fighter'); }--});--router.post('/fighters/:user/refresh', requireAuth, permissions.requireAdmin, async (req, res) => {-- try {-- const card = await arena.getFighter(req.params.user, { generate: false });-- if (!card || card.not_on_roster) return res.status(404).json({ error: 'No such fighter on the roster' });-- const persona = await arena.generatePersona(card.user.id, { force: true });-- const image = req.body?.image !== false && arena.imageGenAvailable() ? await arena.generateImage(card.user.id, { force: true }) : null;-- res.json({ ok: true, persona, image_url: image });-- } catch (err) { fail(res, err, 'Refresh failed'); }--});----// Admin: judge past speech now (bounded per call; the job also does this every 20 min).--router.post('/backfill', requireAuth, permissions.requireAdmin, async (req, res) => {-- try { res.json(await require('./backfill').run({ force: true })); } catch (err) { fail(res, err, 'Backfill failed'); }--});----// ── Live console: what the ears hear for one fighter ──--router.get('/console/:user', async (req, res) => {-- try {-- const user = await userFrom(req.params.user);-- if (!user) return res.status(404).json({ error: 'No such user' });-- const roster = await arena.loadRoster();-- const brief = await mic.fighterBrief(user.id, roster);-- const liveStream = await db.get('SELECT id, title, started_at, viewer_count FROM streams WHERE user_id = ? AND is_live = 1 ORDER BY started_at DESC LIMIT 1', [user.id]);-- let lines = [];-- if (liveStream) {-- lines = (await db.all(`SELECT text, start_sec, vod_id FROM stream_timeline_events WHERE stream_id = ? AND kind = 'speech' ORDER BY start_sec DESC LIMIT 14`, [liveStream.id])).reverse()-- .filter(l => !arena._isBannedText(l.text)).map(l => ({ text: l.text, sec: Math.floor(l.start_sec), vod_id: l.vod_id }));-- }-- res.set('Cache-Control', 'no-store');-- res.json({-- fighter: brief, on_roster: !!roster.byId[user.id], live: !!liveStream, stream: liveStream || null,-- transcribed: liveStream ? !!await db.get(`SELECT 1 FROM stream_timeline_events WHERE stream_id = ? AND kind = 'speech' AND created_at >= datetime('now', '-30 minutes') LIMIT 1`, [liveStream.id]) : false,-- listener: await listener.consoleState(user.id),-- level: await mic.levelView(user.id),-- mic: await mic.micStats(user.id),-- open_beefs: (await Promise.all((await beef.openBeefsFor(user.id)).map(async b => await beef.beefView(b, roster)))),-- hot_mic: lines,-- recent_moments: await mic.momentsFor(user.id, 8),-- });-- } catch (err) { fail(res, err, 'Failed to load console'); }--});----// ── Beefs ──--router.get('/beefs', async (req, res) => { try { res.set('Cache-Control', 'no-store'); res.json(await beef.list()); } catch (err) { fail(res, err, 'Failed to load beefs'); } });--router.get('/beefs/:id', async (req, res) => {-- try { const b = await beef.get(Number(req.params.id)); if (!b) return res.status(404).json({ error: 'No such beef' }); res.set('Cache-Control', 'no-store'); res.json(b); }-- catch (err) { fail(res, err, 'Failed to load beef'); }--});--router.post('/beefs/:id/hype', optionalAuth, async (req, res) => {-- try { res.json(await beef.hype(Number(req.params.id), String(req.body?.side || ''), await arena.voterKeyFor(req))); } catch (err) { res.status(400).json({ error: err.message }); }--});----// ── Hear it in their voice ──--router.get('/voice/:user', optionalAuth, async (req, res) => {-- try {-- const voice = require('./voice');-- if (!voice.allow(req.ip, !!req.user)) return res.status(429).json({ error: 'Slow down — too many voice requests' });-- const text = voice.cleanText(req.query.t || req.query.text || '');-- if (text.length < 2) return res.status(400).json({ error: 'Nothing to say' });-- const who = String(req.params.user || '').toLowerCase();-- const user = who === 'announcer' ? null : await userFrom(who);-- if (who !== 'announcer' && !user) return res.status(404).json({ error: 'No such user' });-- const out = await voice.speak({ user, text });-- res.set({ 'Content-Type': out.mimeType, 'Cache-Control': 'public, max-age=604800, immutable', 'X-Arena-Voice': out.voice, 'X-Cache': out.cached ? 'HIT' : 'MISS' });-- require('stream').pipeline(require('fs').createReadStream(out.path), res, () => {}); // closes the file when the listener leaves-- } catch (err) { res.status(err.message && /budget|hoarse/.test(err.message) ? 429 : 500).json({ error: err.message || 'Voice failed' }); }--});----// ── Your Arena: the signed-in fighter's own state ──--router.get('/me', requireAuth, async (req, res) => {-- try {-- const roster = await arena.loadRoster();-- const onRoster = !!roster.byId[req.user.id];-- const fighter = onRoster ? { ...await mic.fighterBrief(req.user.id, roster), level: await mic.levelView(req.user.id), record: await beef.recordFor(req.user.id), power: roster.byId[req.user.id].ratings.power, mic: await mic.micStats(req.user.id) } : null;-- const beefs = onRoster ? (await Promise.all((await beef.openBeefsFor(req.user.id)).map(async b => await beef.beefView(b, roster)))) : [];-- const onClock = beefs.filter(b => (b.on_clock === 'a' ? b.a.user.id : b.b.user.id) === req.user.id);-- res.set('Cache-Control', 'no-store');-- res.json({ on_roster: onRoster, fighter, beefs, on_clock: onClock, moments: onRoster ? await mic.momentsFor(req.user.id, 6) : [], live: onRoster ? !!await db.get('SELECT 1 FROM streams WHERE user_id = ? AND is_live = 1 LIMIT 1', [req.user.id]) : false });-- } 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;ddeleted file mode 100644@@@ -1,136 +0,0 @@--/**-- * voice.js — "hear it in their voice": Arena lines (taunts, quotes, headlines, ringside calls) read-- * out in the streamer's own OpenVibe chat TTS voice — the cosmetic voice they equipped, else the-- * per-identity auto voice chat already gives them (same `user:<username>` key OpenVibe.Chat uses).-- *-- * Every (voice, text) pair is synthesized ONCE and kept on disk (`data/tts-cache/<hash>.<ext>`),-- * served with a week-long HTTP cache — repeat clicks cost nothing, and a changed voice simply-- * hashes to a new file. Cloud engines are metered into ai_usage (kind 'tts'); a daily cap and a-- * per-IP limit keep the endpoint from becoming a free TTS API.-- */--'use strict';----const fs = require('fs');--const path = require('path');--const crypto = require('crypto');--const db = require('../db/database');----const CACHE_DIR = require('../paths').dir('TTS_CACHE_PATH', 'tts-cache');--const MAX_TEXT = 240;--const MAX_CACHE_MB = 300;--const MAX_AGE_MS = 60 * 24 * 3600_000;--const DAILY_MAX = async () => Number(await db.getSetting('arena_voice_daily_max') || 2000);--const ANNOUNCER_VOICE = async () => String(await db.getSetting('arena_announcer_voice') || await db.getSetting('tts_default_voice') || 'gary');--const CLOUD_COST_PER_CHAR = { google: 16 / 1_000_000, polly: 16 / 1_000_000 };----let _tts = null;--function tts() { if (!_tts) _tts = require('../chat/tts-engine'); return _tts; }--let _synthOverride = null; // tests----function ensureDir() { try { fs.mkdirSync(CACHE_DIR, { recursive: true }); } catch { /* */ } }--function cleanText(text) { return String(text || '').replace(/\s+/g, ' ').replace(/[<>]/g, '').trim().slice(0, MAX_TEXT); }----/** How this person sounds in chat: equipped cosmetic voice, else the derived auto voice. */--async function voiceFor(user) {-- if (!user) return { kind: 'announcer', voiceId: await ANNOUNCER_VOICE(), sig: `announcer:${await ANNOUNCER_VOICE()}` };-- try {-- const cosmetics = require('../monetization/cosmetics');-- const prof = await cosmetics.getCosmeticProfile(user.id);-- const itemId = prof && prof.voiceFX && prof.voiceFX.itemId;-- if (itemId && tts().VOICE_CATALOG && tts().VOICE_CATALOG[itemId]) return { kind: 'equipped', voiceId: itemId, sig: `equipped:${itemId}` };-- } catch { /* cosmetics optional */ }-- const key = `user:${String(user.username).toLowerCase()}`;-- let params = null;-- try { params = tts().deriveUserVoiceParams ? tts().deriveUserVoiceParams(key) : null; } catch { /* */ }-- return { kind: 'auto', identityKey: key, params, sig: `auto:${key}:${params ? [params.voice, params.pitch, params.speed, params.gap].join('/') : 'default'}` };--}----function cacheKey(sig, text) { return crypto.createHash('sha256').update(`${sig}\n${text}`).digest('hex').slice(0, 32); }--function cachedFile(key) {-- for (const ext of ['mp3', 'wav']) { const p = path.join(CACHE_DIR, `${key}.${ext}`); if (fs.existsSync(p)) return { path: p, mimeType: ext === 'mp3' ? 'audio/mpeg' : 'audio/wav' }; }-- return null;--}----async function todayCount() { try { return (await db.get(`SELECT COUNT(*) AS n FROM ai_usage WHERE kind = 'tts' AND created_at >= to_char(ov_ts(ov_now()), 'YYYY-MM-DD')`))?.n || 0; } catch { return 0; } }----async function synth(voice, text, username) {-- if (_synthOverride) return _synthOverride(voice, text, username);-- const T = tts();-- if (voice.kind === 'auto' && T.synthesizeUserVoice) return T.synthesizeUserVoice(text, voice.identityKey, username, MAX_TEXT);-- return T.synthesize(text, voice.voiceId, username || 'Arena', MAX_TEXT);--}----/**-- * Speak `text` as `user` (a users row) or the announcer (null). Returns { path, mimeType, cached, voice }.-- * Throws with a friendly message on limits.-- */--async function speak({ user = null, text }) {-- ensureDir();-- const clean = cleanText(text);-- if (clean.length < 2) throw new Error('Nothing to say');-- const voice = await voiceFor(user);-- const key = cacheKey(voice.sig, clean);-- const hit = cachedFile(key);-- if (hit) { try { fs.utimesSync(hit.path, new Date(), new Date()); } catch { /* */ } return { ...hit, cached: true, voice: voice.sig }; }-- if (await todayCount() >= await DAILY_MAX()) throw new Error('The announcer is hoarse — daily voice budget reached, try tomorrow');-- const started = Date.now();-- const out = await synth(voice, clean, user?.username);-- if (!out || !out.audio) throw new Error('Voice engine unavailable');-- const ext = /wav/i.test(out.mimeType || '') ? 'wav' : 'mp3';-- const file = path.join(CACHE_DIR, `${key}.${ext}`);-- const tmp = `${file}.tmp`;-- fs.writeFileSync(tmp, Buffer.from(out.audio, 'base64'));-- fs.renameSync(tmp, file);-- const engine = String(out.engine || '').toLowerCase();-- const cloud = /google/.test(engine) ? 'google' : /polly|aws|amazon/.test(engine) ? 'polly' : null;-- try { await db.recordAiUsage({ kind: 'tts', model: `${engine || 'espeak'}:${out.voiceId || voice.voiceId || 'auto'}`, input_tokens: clean.length, output_tokens: 0, cost_usd: cloud ? clean.length * CLOUD_COST_PER_CHAR[cloud] : 0, owner_user_id: user?.id || null, source: 'arena', role: 'tts', provider: engine || 'espeak', latency_ms: Date.now() - started }); } catch { /* */ }-- purge();-- return { path: file, mimeType: ext === 'wav' ? 'audio/wav' : 'audio/mpeg', cached: false, voice: voice.sig, engine };--}----let _lastPurge = 0;--function purge() {-- if (Date.now() - _lastPurge < 10 * 60 * 1000) return;-- _lastPurge = Date.now();-- try {-- const files = fs.readdirSync(CACHE_DIR).filter(f => /\.(mp3|wav)$/.test(f)).map(f => { const p = path.join(CACHE_DIR, f); const st = fs.statSync(p); return { p, size: st.size, at: st.mtimeMs }; });-- const now = Date.now();-- let total = 0;-- for (const f of files) { if (now - f.at > MAX_AGE_MS) { try { fs.unlinkSync(f.p); } catch { /* */ } } else total += f.size; }-- if (total > MAX_CACHE_MB * 1024 * 1024) {-- for (const f of files.filter(x => now - x.at <= MAX_AGE_MS).sort((a, b) => a.at - b.at)) { if (total <= MAX_CACHE_MB * 1024 * 1024 * 0.8) break; try { fs.unlinkSync(f.p); total -= f.size; } catch { /* */ } }-- }-- } catch { /* */ }--}----// Per-IP limiter: anonymous 8/min, signed-in 30/min (cache hits are still counted so a scraper can't loop).--const _hits = new Map();--function allow(ip, signedIn) {-- const now = Date.now();-- const lim = signedIn ? 30 : 8;-- const arr = (_hits.get(ip) || []).filter(t => now - t < 60_000);-- if (arr.length >= lim) { _hits.set(ip, arr); return false; }-- arr.push(now); _hits.set(ip, arr);-- if (_hits.size > 5000) { for (const [k, v] of _hits) if (!v.length || now - v[v.length - 1] > 60_000) _hits.delete(k); }-- return true;--}----/** Park a one-off synthesized clip (admin test / mod preview) in the cache → safe same-origin filename. */--function stash(audioBase64, mimeType) {-- ensureDir();-- const buf = Buffer.from(String(audioBase64 || ''), 'base64');-- if (!buf.length) return null;-- const ext = /wav/i.test(mimeType || '') ? 'wav' : 'mp3';-- const file = `${crypto.createHash('sha256').update(buf).digest('hex').slice(0, 32)}.${ext}`;-- const full = path.join(CACHE_DIR, file);-- if (!fs.existsSync(full)) { fs.writeFileSync(`${full}.tmp`, buf); fs.renameSync(`${full}.tmp`, full); purge(); }-- return { file, mimeType: ext === 'wav' ? 'audio/wav' : 'audio/mpeg' };--}--/** Resolve a cache filename (strict shape, no traversal) → { path, mimeType } or null. */--function cachedByName(file) {-- if (!/^[a-f0-9]{32}\.(mp3|wav)$/.test(String(file || ''))) return null;-- const full = path.join(CACHE_DIR, file);-- return fs.existsSync(full) ? { path: full, mimeType: file.endsWith('.wav') ? 'audio/wav' : 'audio/mpeg' } : null;--}----module.exports = { speak, voiceFor, cacheKey, cleanText, allow, purge, stash, cachedByName, CACHE_DIR, MAX_TEXT, _setSynth: (fn) => { _synthOverride = fn; } };@@@ -5,7 +5,7 @@* live.profile channel_url, followers, is_streamer, last_live_at (public), stream_minutes_30d* live.stats streams_30d, stream_minutes_30d, peak_viewers_30d (public), avg_viewers_30d,* new_followers_30d, computed_at-- * live.loyalty channel_points_total, channels (top ten by points), arena_level, arena_xp,++ * live.loyalty channel_points_total, channels (top ten by points) (the Arena's level and XP went with the Arena),* computed_at; private (contracts 0.56.0, WS-K task 9). Loyalty, never money.** Summaries only: streams and follows stay Live's truth. Written with Live's service token (grants@@@ -72,7 +72,7 @@ async function summarize(userId, { now = Date.now() } = {}) {const LOYALTY_MIN_INTERVAL_MS = 30 * 60 * 1000; // points move while people watch: at most one write per half hour--/** live.loyalty for one account: channel points (top ten channels) and the Arena level, or null when++/** live.loyalty for one account: channel points (top ten channels), or null when* there is nothing to say. Once a record was written, an account whose points are gone gets zeros. */async function loyaltyOf(userId) {const d = db.getDb();@@@ -80,15 +80,11 @@ async function loyaltyOf(userId) {WHERE cp.user_id = ? AND cp.balance > 0 ORDER BY cp.balance DESC, u.username LIMIT 10`).all(userId)).map((r) => ({ channel: String(r.channel).slice(0, 64), points: r.points }));const total = (await d.prepare('SELECT COALESCE(SUM(balance), 0)::bigint AS n FROM channel_points WHERE user_id = ? AND balance > 0').get(userId)).n;-- let arena = null;-- try { arena = await d.prepare('SELECT xp, level FROM arena_trash_levels WHERE user_id = ?').get(userId) || null; } catch { /* the arena tables do not exist yet */ }-- if (!total && !arena) {++ if (!total) {const had = await d.prepare("SELECT 1 FROM module_summary_pushes WHERE user_id = ? AND namespace = 'live.loyalty'").get(userId);return had ? { channel_points_total: 0, channels: [] } : null;}-- const out = { channel_points_total: total, channels };-- if (arena) { out.arena_level = Math.max(1, Number(arena.level) || 1); out.arena_xp = Math.max(0, Number(arena.xp) || 0); }-- return out;++ return { channel_points_total: total, channels };}function client() {@@@ -138,9 +134,6 @@ async function push(userId, { modules = client(), now = Date.now(), force = falsreturn written;}--const tableExists = async (name) => Boolean(await db.getDb().prepare("SELECT 1 FROM information_schema.tables WHERE table_schema = current_schema() AND table_name = ?").get(name));--const arenaLog = async () => await tableExists('arena_xp_log');--const arenaLevels = async () => await tableExists('arena_trash_levels');let lastScan = null;/** People whose stream ended, or who gained a follower, since the previous scan. */@@@ -151,8 +144,7 @@ async function scan({ modules = client(), now = Date.now() } = {}) {const ids = (await db.getDb().prepare(`SELECT user_id AS id FROM streams WHERE ended_at >= ? AND ended_at < ?UNION SELECT streamer_id AS id FROM follows WHERE created_at >= ? AND created_at < ?UNION SELECT user_id AS id FROM streams WHERE started_at >= ? AND started_at < ?-- UNION SELECT user_id AS id FROM channel_points WHERE updated_at >= ? AND updated_at < ?${await arenaLog() ? `-- UNION SELECT user_id AS id FROM arena_xp_log WHERE created_at >= ? AND created_at < ?` : ''}`).all(...Array(await arenaLog() ? 5 : 4).fill([from, to]).flat())).map((r) => r.id);++ UNION SELECT user_id AS id FROM channel_points WHERE updated_at >= ? AND updated_at < ?`).all(...Array(4).fill([from, to]).flat())).map((r) => r.id);for (const id of ids) await push(id, { modules, now });lastScan = to;stats.lastScanAt = new Date(now).toISOString();@@@ -166,8 +158,7 @@ async function refresh({ modules = client(), now = Date.now() } = {}) {const ids = (await db.getDb().prepare(`SELECT DISTINCT user_id AS id FROM streams WHERE started_at >= ?UNION SELECT DISTINCT streamer_id AS id FROM followsUNION SELECT DISTINCT user_id AS id FROM channel_points WHERE balance > 0-- UNION SELECT user_id AS id FROM module_summary_pushes WHERE namespace = 'live.loyalty'${await arenaLevels() ? `-- UNION SELECT user_id AS id FROM arena_trash_levels` : ''}`).all(since)).map((r) => r.id);++ UNION SELECT user_id AS id FROM module_summary_pushes WHERE namespace = 'live.loyalty'`).all(since)).map((r) => r.id);for (const id of ids) await push(id, { modules, now, force: true });return ids.length;}@@@ -240,7 +240,7 @@ contextRouter.get('/anon-first-seen', async (req, res) => {contextRouter.get('/tts-audio/:file', (req, res) => {try {-- const hit = require('../arena/voice').cachedByName(req.params.file);++ const hit = require('../rundown/voice').cachedByName(req.params.file);if (!hit) return fail(res, 404, 'No such clip');res.set({ 'Content-Type': hit.mimeType });require('stream').pipeline(require('fs').createReadStream(hit.path), res, () => {}); // closes the file when the listener leaves@@@ -450,7 +450,7 @@ effectsRouter.post('/arena-command', async (req, res) => {track: (p) => { work = p; },};let handled = false;-- try { handled = await require('../arena/arena-chat').handle(shim, null, client, String(req.body.cmd || ''), Array.isArray(req.body.parts) ? req.body.parts : []); } catch (e) { console.warn('[Arena] chat command:', e.message); }++ try { handled = await require('../rundown/chat-commands').handle(shim, null, client, String(req.body.cmd || ''), Array.isArray(req.body.parts) ? req.body.parts : []); } catch (e) { console.warn('[Arena] chat command:', e.message); }if (work) { try { await work; } catch { /* the command answers its own errors */ } }res.json(replies.length ? { handled: !!handled, replies } : { handled: !!handled });});@@@ -670,12 +670,6 @@ app.use('/data/ai-moments', express.static(paths.dir('AI_MOMENTS_PATH', 'ai-momemaxAge: '30d', immutable: true, index: false, dotfiles: 'deny',}));--// Arena portraits (AI-generated fighting-game character art, see server/arena)--app.use('/data/arena', express.static(paths.dir('ARENA_IMAGE_PATH', 'arena'), {-- maxAge: '7d', immutable: true, index: false, dotfiles: 'deny',-- setHeaders: (res) => res.setHeader('Content-Type', 'image/png'),--}));--// OpenVibe.Media → Live outcomes (vod.ready / clip.ready …): the OpenVibe.Events delivery of// media.vod.* / media.clip.* / media.storage.* (MEDIA_EVENTS_SECRET), applied once per outcome// (server/media-proxy/outcomes.js). Mounted BEFORE the /internal router because it authenticates@@@ -771,7 +765,6 @@ app.use('/api/vods', searchMedia.afterChange('vod'), vodRoutes);app.use('/api/clips', searchMedia.afterChange('clip'), clipRoutes);app.use('/api/chat-ai', require('./ai/chat-ai-routes'));app.use('/api/easter-egg', require('./ai/easter-egg-routes'));--app.use('/api/arena', require('./arena/routes')); // the old Arena's reads, until its code is removed (docs/rundown.md)app.use('/api/rundown', require('./rundown/routes')); // what streamers talked about, written up (docs/rundown.md)app.use('/api/recap', require('./recap/routes')); // after-show reports (docs/recap.md)app.use('/api/social', require('./social/routes').router); // channel social links and their previews (server/social/)nnew file mode 100644@@@ -0,0 +1,59 @@++/**++ * OpenVibe.Live — The Rundown's chat commands (the Arena's three, which Chat still forwards to++ * /internal/chat-effects/arena-command; the Arena became The Rundown on 2026-10-19).++ *++ * !arena [user] what the streamer (or user) has been talking about on The Rundown + a link++ * !hype, !beef beefs are gone: a pointer to The Rundown++ *++ * Called from ChatServer.handleBangCommand; returns true when the command was handled. Replies go to++ * the sender as a system line.++ */++'use strict';++++const db = require('../db/database');++++const COMMANDS = ['!hype', '!beef', '!arena'];++const RATE_MS = 4000;++const _last = new Map(); // voterKey → ms++++function voterKey(client) {++ if (client.user?.id) return `user:${client.user.id}`;++ if (client.anonId) return `anon:${client.anonId}`;++ return `ip:${String(client.ip || '')}`;++}++function limited(map, key, ms) {++ const now = Date.now();++ if (now - (map.get(key) || 0) < ms) return true;++ map.set(key, now);++ return false;++}++function base() { try { const c = require('../config'); return String(c.baseUrl || '').replace(/\/$/, ''); } catch { return ''; } }++++async function handle(chat, ws, client, cmd, parts) {++ if (!COMMANDS.includes(cmd)) return false;++ const reply = (message) => chat.sendTo(ws, { type: 'system', message });++ if (limited(_last, voterKey(client), RATE_MS)) { reply('Easy — one command every few seconds.'); return true; }++ const work = (async () => {++ try {++ if (cmd !== '!arena') return reply(`Beefs are gone. The Rundown writes up what streamers talk about instead → ${base()}/rundown`);++ const stream = client.streamId ? await db.getStreamById(client.streamId) : null;++ const target = parts[1] ? await db.get('SELECT id, username, display_name FROM users WHERE lower(username) = lower(?)', [String(parts[1]).replace(/^@/, '')])++ : (stream ? await db.get('SELECT id, username, display_name FROM users WHERE id = ?', [stream.user_id]) : null);++ if (!target) return reply(parts[1] ? `Nobody called ${String(parts[1]).slice(0, 30)} here.` : `What streamers talked about, written up → ${base()}/rundown`);++ const read = require('./read');++ const page = await read.streamer(target.username);++ const name = target.display_name || target.username;++ if (!page || page.opted_out) return reply(`${name} keeps themselves out of The Rundown.`);++ const ep = page.episodes[0];++ const topics = page.topics.slice(0, 3).map((t) => t.title);++ if (!ep && !topics.length) return reply(`Nothing on The Rundown about ${name} yet: it takes talk on a transcribed stream → ${base()}/rundown`);++ return reply(`📰 ${name}${ep ? ` — latest episode: “${ep.title}”` : ''}${topics.length ? ` · talks about ${topics.join(', ')}` : ''} → ${base()}/rundown/${encodeURIComponent(target.username)}`);++ } catch (err) {++ reply(`The Rundown: ${err.message}`);++ }++ })();++ if (typeof chat.track === 'function') chat.track(work);++ return true;++}++++module.exports = { handle, COMMANDS, _voterKey: voterKey };@@@ -17,7 +17,7 @@* is in the transcript word for word; a headline, account or take that uses an accusing word the transcript never used* drops the segment (a quip, only the quip); every text passes db.noLooks(). Streamers in arena_opt_outs (the Arena's* opt-out, kept for the Rundown) are never read. A streamer named in a segment is named by their whole username or-- * display name in the segment's own lines (server/arena/names.js, wholeOnly), never a sound-alike.++ * display name in the segment's own lines (server/rundown/names.js, wholeOnly), never a sound-alike.*/const db = require('../db/database');const filters = require('./filters');@@@ -78,20 +78,20 @@ const clean = (t, n) => {// ── Who can be named ─────────────────────────────────────────let _people = { at: 0, entries: [] };/**-- * Name entries for every streamer who streamed in the last 60 days. Only a whole name counts (server/arena/names.js),++ * Name entries for every streamer who streamed in the last 60 days. Only a whole name counts (server/rundown/names.js),* and never a name that is an everyday word or shorter than 5 letters: "you rubes" names nobody.*/async function nameEntries() {if (Date.now() - _people.at < 10 * 60 * 1000) return _people.entries;const rows = await db.all('SELECT u.id, u.username, u.display_name FROM users u WHERE u.id IN (SELECT DISTINCT user_id FROM streams WHERE started_at >= ?)', [daysAgo(60)]);-- const names = require('../arena/names');++ const names = require('./names');const roster = { order: rows.map((r) => r.id), byId: Object.fromEntries(rows.map((r) => [r.id, { user: r }])) };const entries = names.rosterEntries(roster).filter((e) => e.whole && String(e.name).replace(/[^a-z0-9]/gi, '').length >= 5 && !names.COMMON.has(String(e.name).toLowerCase()));_people = { at: Date.now(), entries };return _people.entries;}async function mentionsIn(text, speakerId) {-- const found = require('../arena/names').findMentions(text, await nameEntries(), { excludeUserId: speakerId, wholeOnly: true });++ const found = require('./names').findMentions(text, await nameEntries(), { excludeUserId: speakerId, wholeOnly: true });return [...new Set(found.map((m) => Number(m.userId)).filter(Boolean))];}nnew file mode 100644@@@ -0,0 +1,203 @@++/**++ * names.js — how a streamer's name comes out of a speech-to-text engine, and how to find it.++ *++ * "JapaneseOldGuy" is never transcribed as one token: whisper hears "Japanese old guy",++ * "japanese-old-guy", "Japanese Old Guy's stream". "lofi_dan99" → "lofi dan". "Maticus" →++ * "Matticus" / "Mattie cuss". "x_Goosely_TV" → "Goosely". This module turns a name into every++ * spoken form we can predict, then matches transcript lines against them with:++ *++ * 1. exact/loose-separator matches ("japanese old guy", "japanese-old-guy", "japaneseoldguy")++ * 2. fuzzy matches on n-grams of the line (edit distance scaled by length)++ * 3. phonetic matches (a compact metaphone-ish key: "goosely" ≈ "goose lee" ≈ "goosley")++ *++ * Guard rails: single-word aliases must be ≥ 4 letters and not a common English word, so a++ * fighter called "Guy" or "Old" cannot be "mentioned" by every sentence; multi-word aliases only++ * match as the whole phrase; the speaker never matches themselves; longest alias wins.++ */++'use strict';++++const COMMON = new Set(('about after again also always another anyone anything around because before being between both bring build built came cant cannot chat check come could does doing done down each else even ever every first from game games getting give going gone good great group guy guys have having here home into just keep kind know last later least life like little live look made make many maybe mean might more most much must name need never next nice night nobody nothing okay only other over people place play player point pretty probably really right same says school seen shit should show since some something sorry stay still stop stream streamer stuff sure take talk tell than thank that their them then there these they thing think this those three time today told took trying under until very want watch week well went were what when where which while whole will with without work would year years young your live love hate lord king queen dude bro bruh fam yeah yes okay old new big small guy girl boy man men women woman baby dan dave mike john tom jack ben sam max alex chris '++ + 'matter meter motor water later letter little level lever mother master mister monster minute minutes money moment mountain market maybe major magic medic metal middle mirror mission model modern moving music mute native nature never number often order other paper party power pretty price quick quiet radio ready river robot round sauce scary second server seven shadow silver simple sister sixty slower smaller smoke solid sound south space speak speed spend spirit sport square start state steam story street strong stuff sugar summer super sweet table taken teach teeth thank thick thing third thought throw tiger timer title toast total touch tower track trade train travel treat truck truly trust truth turned twenty under upper usual value video village voice waste water wheel where white whole winter wonder world worry worse worth would write wrong yellow young about above across actor adult again agent ahead alarm album alien alive allow alone along alter among angle animal answer anyway apple armor arrow attack aware awful bacon badge basic battle beach beast begin being below bench birth black blade blame blank blast blind block blood board boost boots bottle bottom bounce bounty brain brand brave bread break brick bridge bright broken brush bucket buddy budget bullet button cable camera candy cannon cargo carry castle catch cause chain chair chance change charge chase cheap cheese chest chief child choice church claim class clean clear click climb clock close cloud coach coast coffee color combo comic coming crazy crash cream credit crime cross crowd crown cycle daily damage dance dark death decent delay demon depth desert device diamond dinner direct dirty double dozen dragon drama dream dress drink drive dungeon early earth eight empty enemy energy engine enter entire equal error event exact extra faith false fancy farm fast fatal fault favor feast fence field fight figure final finish fire flame flash flesh floor flower focus force forest forge frame fresh front frozen fruit funny giant glass glory gold grand grass green guard guess guide happy heart heavy hello hidden honor horse hotel house human hunt image inside iron island jacket joint judge juice jungle knife knight label ladder large laser latest laugh layer leader legend lemon light limit liquid local lucky lunch magic manual march match medal melee metal minor mobile moon mouse mouth movie nasty needle noise north notice number nurse ocean offer olive onion orange order outer oven owner paint panel panic paper patch peace pearl pedal pencil phone photo piano piece pilot pizza plain plane plant plate plush pocket poison police potato pound press prime print prize proof pulse punch puzzle queen quest rabbit radar raid rain range ratio razor reach recent relax reload rescue retro rifle rival rocket rough route royal rubber ruler rusty sadly safety salad sample sandwich scale scene score scout screen script scroll search secret select shape share sharp shelf shield shirt shock shoot shore short shoulder shovel signal silent skill skull slate sleep slice slide slime smart smell smile snake sneak snipe snow sock solar solo sorry sound spare spark spawn spear spell spice spider spike spoon spray staff stage stair stamp stand star steel step stick stone store storm story stove strange strike stupid sudden sunny sword tackle talent tank taste tavern temple tennis thief thirty ticket tired toilet token tomato tooth torch tough toxic trail trap trash treasure trick troll trophy trouble tunnel turtle twist uncle unique unit upset urban valley vault vendor virus vision vocal volume wagon waiting wallet weapon weird whale wheat window wire wizard wolf worker yard zombie zone'++).split(/\s+/));++++const SUFFIXES = /(?:_|-)?(?:tv|ttv|live|official|yt|twitch|gaming|stream|streams|vods|irl|hd|4k)$/i;++++const LEET = { '0': 'o', '1': 'i', '3': 'e', '4': 'a', '5': 's', '7': 't', '8': 'b', '@': 'a', '$': 's', '!': 'i', '|': 'l' };++++function clean(s) { return String(s || '').trim(); }++function words(s) { return String(s || '').toLowerCase().split(/[^a-z0-9]+/).filter(Boolean); }++++/** Split a handle the way it is SAID: camelCase, snake_case, kebab, letters|digits. */++function splitHandle(name) {++ let s = clean(name);++ if (!s) return [];++ s = s.replace(/[_.\-+]+/g, ' ')++ .replace(/([a-z])([A-Z])/g, '$1 $2') // JapaneseOldGuy → Japanese Old Guy++ .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2') // XXGooselyXX → XX Goosely XX++ .replace(/([a-zA-Z])(\d)/g, '$1 $2') // dan99 → dan 99++ .replace(/(\d)([a-zA-Z])/g, '$1 $2');++ return s.toLowerCase().split(/\s+/).filter(Boolean);++}++++function deleet(w) { w = String(w); if (/^\d+$/.test(w)) return w; /* 151 stays a number, never 'isi' */ return /[a-z]/i.test(w) ? w.replace(/[0134578@$!|]/g, c => LEET[c] || c) : w; }++++/**++ * All predicted spoken forms of one name (lowercase word arrays, deduped, most specific first).++ * JapaneseOldGuy → ["japanese old guy"], ["japaneseoldguy"]++ * lofi_dan99 → ["lofi dan 99"], ["lofi dan"], ["lofidan"]++ * x_Goosely_TV → ["x goosely tv"], ["goosely"], ["x goosely"]++ * M4ticus → ["m4ticus"], ["maticus"]++ */++function variants(name) {++ const out = new Set();++ const add = (ws) => { const v = ws.filter(Boolean).join(' ').trim(); if (v.length >= 3) out.add(v); };++ const raw = clean(name);++ if (!raw) return [];++ const base = splitHandle(raw);++ add(base);++ add(base.map(deleet));++ // Leet inside a token (M4ticus, l33t_dan) → un-leet the whole handle first, then split.++ if (/[a-z][0134578@$!|]+[a-z]/i.test(raw)) { const b0 = splitHandle(raw.replace(/[0134578@$!|]/g, c => LEET[c] || c)); add(b0); add(b0.filter(w => !/^\d+$/.test(w))); }++ add(base.filter(w => !/^\d+$/.test(w))); // drop pure numbers: "lofi dan 99" → "lofi dan"++ add(base.map(deleet).filter(w => !/^\d+$/.test(w)));++ const noSuffix = raw.replace(SUFFIXES, '');++ if (noSuffix !== raw) { const b2 = splitHandle(noSuffix); add(b2); add(b2.filter(w => !/^\d+$/.test(w))); }++ // Decorations people put around handles: xX_name_Xx, __name__, TheName → name++ const stripped = raw.replace(/^(?:xx+|x_|_+|the)/i, '').replace(/(?:xx+|_x|_+)$/i, '');++ if (stripped !== raw && stripped.length >= 3) { const b3 = splitHandle(stripped); add(b3); add(b3.filter(w => !/^\d+$/.test(w))); }++ // Glued form for names that ARE a phrase (a transcriber may glue or split them).++ for (const v of [...out]) { const ws = v.split(' '); if (ws.length > 1 && ws.every(w => /^[a-z]+$/.test(w))) out.add(ws.join('')); }++ // Drop unsafe single common words (would match every sentence).++ return [...out].filter(v => v.split(' ').length > 1 || (v.length >= 4 && !COMMON.has(v) && !/^\d+$/.test(v))).sort((a, b) => b.length - a.length);++}++++// ── Fuzzy + phonetic ────────────────────────────────────────++++function levenshtein(a, b) {++ if (a === b) return 0;++ const m = a.length, n = b.length;++ if (!m) return n; if (!n) return m;++ let prev = Array.from({ length: n + 1 }, (_, i) => i);++ for (let i = 1; i <= m; i++) {++ const cur = [i];++ for (let j = 1; j <= n; j++) cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));++ prev = cur;++ }++ return prev[n];++}++++/** Compact phonetic key: "goosely" → "gsl", "goose lee" → "gsl", "matticus" → "mtks", "japanese old guy" → "jpns ld g". */++function phonetic(s) {++ let t = String(s || '').toLowerCase().replace(/[^a-z ]/g, '');++ t = t.replace(/ph/g, 'f').replace(/ck/g, 'k').replace(/qu/g, 'kw').replace(/x/g, 'ks').replace(/z/g, 's').replace(/wh/g, 'w')++ .replace(/c(?=[eiy])/g, 's').replace(/c/g, 'k').replace(/g(?=[eiy])/g, 'j').replace(/dg/g, 'j').replace(/tio/g, 'sho').replace(/y/g, 'i');++ return t.split(' ').map(w => {++ if (!w) return '';++ const first = w[0];++ let rest = w.slice(1).replace(/[aeiouhw]/g, '');++ rest = rest.replace(/(.)\1+/g, '$1');++ return (first + rest).replace(/(.)\1+/g, '$1');++ }).filter(Boolean).join(' ');++}++function ratioThreshold(len) { return len <= 4 ? 0 : len <= 7 ? 1 : len <= 11 ? 2 : 3; }++++// ── Matching ────────────────────────────────────────────────++++/** Regex for one variant: words joined by any separator, optional 's / plural, no letters glued around. */++function variantRegex(v) {++ const parts = v.split(' ').map(w => w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));++ return new RegExp(`(?:^|[^a-z0-9])@?${parts.join('[\\s\\-_.]*')}(?:'?s)?(?![a-z0-9])`, 'i');++}++++/** Every letter of a name, in the spellings it can be said in whole ("finditfixit", "mnss22741", "maticus" for M4ticus). */++function wholeLetters(name) {++ const raw = clean(name);++ const base = splitHandle(raw);++ return new Set([base, base.map(deleet), splitHandle(raw.replace(/[0134578@$!|]/g, c => LEET[c] || c))].map(ws => ws.join('')).filter(Boolean));++}++++/**++ * Build an alias entry: { userId, name, words, re, key, whole } for each variant of each name. `whole` is a variant++ * that says the entire name, not a piece of it: "find it fix it" is finditfixit, "fix it" is not; "mnss 22741" is++ * mnss22741, "mnss" is not. `{ whole: false }` marks every entry as a piece (nicknames, persona names).++ */++function aliasEntries(userId, names, { whole = true } = {}) {++ const seen = new Map();++ const out = [];++ for (const n of names.filter(Boolean)) {++ const full = whole ? wholeLetters(n) : new Set();++ for (const v of variants(n)) {++ const letters = v.replace(/\s+/g, '');++ if (seen.has(v)) { if (full.has(letters)) seen.get(v).whole = true; continue; }++ const e = { userId, name: v, words: v.split(' '), re: variantRegex(v), key: phonetic(v), letters, whole: full.has(letters) };++ seen.set(v, e);++ out.push(e);++ }++ }++ return out;++}++++/**++ * Which alias entries occur in `text`. Returns [{ userId, name, how: 'exact'|'fuzzy'|'phonetic', hit }].++ * `excludeUserId` = the speaker (never mentions themselves). `wholeOnly`: only a whole name said as it is spelled++ * (no pieces, no sound-alikes): what a public claim that someone named someone needs.++ */++function findMentions(text, entries, { excludeUserId = null, fuzzy = true, wholeOnly = false } = {}) {++ const found = new Map(); // userId → best hit++ const raw = String(text || '');++ if (raw.length < 3) return [];++ if (wholeOnly) { entries = entries.filter(e => e.whole); fuzzy = false; }++ const consider = (e, how, hit) => { if (e.userId === excludeUserId) return; const cur = found.get(e.userId); if (!cur || cur.rank < (how === 'exact' ? 3 : how === 'fuzzy' ? 2 : 1)) found.set(e.userId, { userId: e.userId, name: e.name, how, hit, rank: how === 'exact' ? 3 : how === 'fuzzy' ? 2 : 1 }); };++ // 1) exact / loose separators++ for (const e of entries) { const m = raw.match(e.re); if (m) consider(e, 'exact', m[0].trim()); }++ if (!fuzzy) return [...found.values()].map(({ rank, ...r }) => r);++ // 2) fuzzy + phonetic on n-grams (1..4 words) of the line++ const toks = words(raw).map(deleet);++ if (!toks.length) return [...found.values()].map(({ rank, ...r }) => r);++ for (const e of entries) {++ if (found.has(e.userId) && found.get(e.userId).rank === 3) continue;++ const n = e.words.length;++ const target = e.letters;++ if (target.length < 6) continue; // "mater" ≈ matter/meter/water — too short to fuzzy-match safely++ if (n === 1 && COMMON.has(e.name)) continue;++ const maxD = ratioThreshold(target.length);++ // n-grams of n words, and of n±1 words (a name can be split or glued by the transcriber)++ for (const span of [n, n + 1, n - 1].filter(x => x >= 1 && x <= 4)) {++ for (let i = 0; i + span <= toks.length; i++) {++ const gram = toks.slice(i, i + span);++ const glued = gram.join('');++ if (Math.abs(glued.length - target.length) > maxD + 4) continue;++ // Fuzzy (typo-level) only on the same number of words: "matticus" ~ "maticus", "japanese old gai" ~++ // "japanese old guy" — but never "japanese guy" ~ "japanese old guy" (a different phrase).++ if (gram.filter(w => COMMON.has(w)).length * 2 > gram.length) continue; // plain English words are never a mangled name ("water is" ≠ mater)++ if (span === n && maxD > 0 && Math.abs(glued.length - target.length) <= maxD + 1 && levenshtein(glued, target) <= maxD) { consider(e, 'fuzzy', gram.join(' ')); break; }++ // "mattie cuss" → mtks == maticus → mtks; "goose lee" → gsl == goosely → gsl++ if (glued.length >= 6 && phonetic(gram.join(' ')).replace(/ /g, '') === e.key.replace(/ /g, '')) { consider(e, 'phonetic', gram.join(' ')); break; }++ }++ }++ }++ return [...found.values()].sort((a, b) => b.rank - a.rank).map(({ rank, ...r }) => r);++}++++/**++ * Alias entries for a whole roster, roster-aware: besides every spoken form of each name, a handle's++ * FIRST token ("grizzly" for grizzly_bear, "japanese" is NOT for JapaneseOldGuy — common word) becomes++ * an alias when it is ≥ 5 letters, not a common word, and no other fighter starts with it.++ * `extra(id)` may add persona names / spoken_as forms per fighter.++ */++function rosterEntries(roster, extra = null) {++ const firsts = new Map();++ for (const id of roster.order) { const f = splitHandle(roster.byId[id].user.username)[0]; if (f && f.length >= 5 && !COMMON.has(f) && !/^\d+$/.test(f)) firsts.set(f, (firsts.get(f) || 0) + 1); }++ const list = [];++ for (const id of roster.order) {++ const u = roster.byId[id].user;++ const own = aliasEntries(id, [u.username, u.display_name]);++ const nick = [...(extra ? (extra(id) || []) : [])];++ const first = splitHandle(u.username)[0];++ if (first && firsts.get(first) === 1 && !variants(u.username).includes(first)) nick.push(first);++ list.push(...own, ...aliasEntries(id, nick, { whole: false }).filter(e => !own.some(o => o.name === e.name)));++ }++ return list.sort((a, b) => b.name.length - a.name.length);++}++++module.exports = { variants, splitHandle, deleet, phonetic, levenshtein, aliasEntries, rosterEntries, findMentions, variantRegex, COMMON };@@@ -11,7 +11,7 @@* POST /react {type, id, reaction} one per reader per item (fire, lol, dead, cap, facts); the same again takes it back* POST /hide {type, id} staff: take a segment, episode or topic down* GET|PUT /opt-out {out} signed in: keep yourself out of the Rundown (or come back)-- * GET /voice/:user?t= a line in that streamer's chat voice (server/arena/voice.js)++ * GET /voice/:user?t= a line in that streamer's chat voice (server/rundown/voice.js)*/const crypto = require('crypto');const express = require('express');@@@ -78,7 +78,7 @@ router.put('/opt-out', requireAuth, async (req, res) => {});router.get('/voice/:user', optionalAuth, async (req, res) => {try {-- const voice = require('../arena/voice');++ const voice = require('./voice');if (!voice.allow(req.ip, !!req.user)) return res.status(429).json({ error: 'Slow down — too many voice requests' });const text = voice.cleanText(req.query.t || '');if (text.length < 2) return res.status(400).json({ error: 'Nothing to say' });nnew file mode 100644@@@ -0,0 +1,136 @@++/**++ * voice.js — "hear it in their voice": Arena lines (taunts, quotes, headlines, ringside calls) read++ * out in the streamer's own OpenVibe chat TTS voice — the cosmetic voice they equipped, else the++ * per-identity auto voice chat already gives them (same `user:<username>` key OpenVibe.Chat uses).++ *++ * Every (voice, text) pair is synthesized ONCE and kept on disk (`data/tts-cache/<hash>.<ext>`),++ * served with a week-long HTTP cache — repeat clicks cost nothing, and a changed voice simply++ * hashes to a new file. Cloud engines are metered into ai_usage (kind 'tts'); a daily cap and a++ * per-IP limit keep the endpoint from becoming a free TTS API.++ */++'use strict';++++const fs = require('fs');++const path = require('path');++const crypto = require('crypto');++const db = require('../db/database');++++const CACHE_DIR = require('../paths').dir('TTS_CACHE_PATH', 'tts-cache');++const MAX_TEXT = 240;++const MAX_CACHE_MB = 300;++const MAX_AGE_MS = 60 * 24 * 3600_000;++const DAILY_MAX = async () => Number(await db.getSetting('arena_voice_daily_max') || 2000);++const ANNOUNCER_VOICE = async () => String(await db.getSetting('arena_announcer_voice') || await db.getSetting('tts_default_voice') || 'gary');++const CLOUD_COST_PER_CHAR = { google: 16 / 1_000_000, polly: 16 / 1_000_000 };++++let _tts = null;++function tts() { if (!_tts) _tts = require('../chat/tts-engine'); return _tts; }++let _synthOverride = null; // tests++++function ensureDir() { try { fs.mkdirSync(CACHE_DIR, { recursive: true }); } catch { /* */ } }++function cleanText(text) { return String(text || '').replace(/\s+/g, ' ').replace(/[<>]/g, '').trim().slice(0, MAX_TEXT); }++++/** How this person sounds in chat: equipped cosmetic voice, else the derived auto voice. */++async function voiceFor(user) {++ if (!user) return { kind: 'announcer', voiceId: await ANNOUNCER_VOICE(), sig: `announcer:${await ANNOUNCER_VOICE()}` };++ try {++ const cosmetics = require('../monetization/cosmetics');++ const prof = await cosmetics.getCosmeticProfile(user.id);++ const itemId = prof && prof.voiceFX && prof.voiceFX.itemId;++ if (itemId && tts().VOICE_CATALOG && tts().VOICE_CATALOG[itemId]) return { kind: 'equipped', voiceId: itemId, sig: `equipped:${itemId}` };++ } catch { /* cosmetics optional */ }++ const key = `user:${String(user.username).toLowerCase()}`;++ let params = null;++ try { params = tts().deriveUserVoiceParams ? tts().deriveUserVoiceParams(key) : null; } catch { /* */ }++ return { kind: 'auto', identityKey: key, params, sig: `auto:${key}:${params ? [params.voice, params.pitch, params.speed, params.gap].join('/') : 'default'}` };++}++++function cacheKey(sig, text) { return crypto.createHash('sha256').update(`${sig}\n${text}`).digest('hex').slice(0, 32); }++function cachedFile(key) {++ for (const ext of ['mp3', 'wav']) { const p = path.join(CACHE_DIR, `${key}.${ext}`); if (fs.existsSync(p)) return { path: p, mimeType: ext === 'mp3' ? 'audio/mpeg' : 'audio/wav' }; }++ return null;++}++++async function todayCount() { try { return (await db.get(`SELECT COUNT(*) AS n FROM ai_usage WHERE kind = 'tts' AND created_at >= to_char(ov_ts(ov_now()), 'YYYY-MM-DD')`))?.n || 0; } catch { return 0; } }++++async function synth(voice, text, username) {++ if (_synthOverride) return _synthOverride(voice, text, username);++ const T = tts();++ if (voice.kind === 'auto' && T.synthesizeUserVoice) return T.synthesizeUserVoice(text, voice.identityKey, username, MAX_TEXT);++ return T.synthesize(text, voice.voiceId, username || 'Arena', MAX_TEXT);++}++++/**++ * Speak `text` as `user` (a users row) or the announcer (null). Returns { path, mimeType, cached, voice }.++ * Throws with a friendly message on limits.++ */++async function speak({ user = null, text }) {++ ensureDir();++ const clean = cleanText(text);++ if (clean.length < 2) throw new Error('Nothing to say');++ const voice = await voiceFor(user);++ const key = cacheKey(voice.sig, clean);++ const hit = cachedFile(key);++ if (hit) { try { fs.utimesSync(hit.path, new Date(), new Date()); } catch { /* */ } return { ...hit, cached: true, voice: voice.sig }; }++ if (await todayCount() >= await DAILY_MAX()) throw new Error('The announcer is hoarse — daily voice budget reached, try tomorrow');++ const started = Date.now();++ const out = await synth(voice, clean, user?.username);++ if (!out || !out.audio) throw new Error('Voice engine unavailable');++ const ext = /wav/i.test(out.mimeType || '') ? 'wav' : 'mp3';++ const file = path.join(CACHE_DIR, `${key}.${ext}`);++ const tmp = `${file}.tmp`;++ fs.writeFileSync(tmp, Buffer.from(out.audio, 'base64'));++ fs.renameSync(tmp, file);++ const engine = String(out.engine || '').toLowerCase();++ const cloud = /google/.test(engine) ? 'google' : /polly|aws|amazon/.test(engine) ? 'polly' : null;++ try { await db.recordAiUsage({ kind: 'tts', model: `${engine || 'espeak'}:${out.voiceId || voice.voiceId || 'auto'}`, input_tokens: clean.length, output_tokens: 0, cost_usd: cloud ? clean.length * CLOUD_COST_PER_CHAR[cloud] : 0, owner_user_id: user?.id || null, source: 'arena', role: 'tts', provider: engine || 'espeak', latency_ms: Date.now() - started }); } catch { /* */ }++ purge();++ return { path: file, mimeType: ext === 'wav' ? 'audio/wav' : 'audio/mpeg', cached: false, voice: voice.sig, engine };++}++++let _lastPurge = 0;++function purge() {++ if (Date.now() - _lastPurge < 10 * 60 * 1000) return;++ _lastPurge = Date.now();++ try {++ const files = fs.readdirSync(CACHE_DIR).filter(f => /\.(mp3|wav)$/.test(f)).map(f => { const p = path.join(CACHE_DIR, f); const st = fs.statSync(p); return { p, size: st.size, at: st.mtimeMs }; });++ const now = Date.now();++ let total = 0;++ for (const f of files) { if (now - f.at > MAX_AGE_MS) { try { fs.unlinkSync(f.p); } catch { /* */ } } else total += f.size; }++ if (total > MAX_CACHE_MB * 1024 * 1024) {++ for (const f of files.filter(x => now - x.at <= MAX_AGE_MS).sort((a, b) => a.at - b.at)) { if (total <= MAX_CACHE_MB * 1024 * 1024 * 0.8) break; try { fs.unlinkSync(f.p); total -= f.size; } catch { /* */ } }++ }++ } catch { /* */ }++}++++// Per-IP limiter: anonymous 8/min, signed-in 30/min (cache hits are still counted so a scraper can't loop).++const _hits = new Map();++function allow(ip, signedIn) {++ const now = Date.now();++ const lim = signedIn ? 30 : 8;++ const arr = (_hits.get(ip) || []).filter(t => now - t < 60_000);++ if (arr.length >= lim) { _hits.set(ip, arr); return false; }++ arr.push(now); _hits.set(ip, arr);++ if (_hits.size > 5000) { for (const [k, v] of _hits) if (!v.length || now - v[v.length - 1] > 60_000) _hits.delete(k); }++ return true;++}++++/** Park a one-off synthesized clip (admin test / mod preview) in the cache → safe same-origin filename. */++function stash(audioBase64, mimeType) {++ ensureDir();++ const buf = Buffer.from(String(audioBase64 || ''), 'base64');++ if (!buf.length) return null;++ const ext = /wav/i.test(mimeType || '') ? 'wav' : 'mp3';++ const file = `${crypto.createHash('sha256').update(buf).digest('hex').slice(0, 32)}.${ext}`;++ const full = path.join(CACHE_DIR, file);++ if (!fs.existsSync(full)) { fs.writeFileSync(`${full}.tmp`, buf); fs.renameSync(`${full}.tmp`, full); purge(); }++ return { file, mimeType: ext === 'wav' ? 'audio/wav' : 'audio/mpeg' };++}++/** Resolve a cache filename (strict shape, no traversal) → { path, mimeType } or null. */++function cachedByName(file) {++ if (!/^[a-f0-9]{32}\.(mp3|wav)$/.test(String(file || ''))) return null;++ const full = path.join(CACHE_DIR, file);++ return fs.existsSync(full) ? { path: full, mimeType: file.endsWith('.wav') ? 'audio/wav' : 'audio/mpeg' } : null;++}++++module.exports = { speak, voiceFor, cacheKey, cleanText, allow, purge, stash, cachedByName, CACHE_DIR, MAX_TEXT, _setSynth: (fn) => { _synthOverride = fn; } };@@@ -96,7 +96,6 @@ async function check(name, fn) {const translate = require('../server/i18n/translate');const recap = require('../server/recap/recap');const budget = require('../server/ai/viewers/budget');-- const arena = require('../server/arena/arena-service');const media = require('../server/media-client');media.listVods = async () => ({ vods: [] });media.listClips = async () => ({ clips: [] });@@@ -156,8 +155,6 @@ async function check(name, fn) {const r = await recap.buildRecap(sid);assert.ok(r && r.write && r.write.headline, 'the template report still ships');assert.strictEqual(r.ai, false);-- assert.strictEqual(arena.imageGenAvailable(), false);-- assert.strictEqual(await arena.generateImage(40), null, 'no portrait is drawn');if (label === 'AI_SERVICE=off') {// A streamer's key in OpenVibe.AI is not used either while the service is off.assert.strictEqual(await llm.complete({ role: 'chat', user: 'hi', provider: { credentialSubject: SUBJECT } }), null);ddeleted file mode 100644@@@ -1,170 +0,0 @@--'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'), fan = await mk('fan');---- // 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 },-- ]);-- // fan: a match on stream; the crowd's screams and groans and "crying", and nothing heated said by the streamer.-- const fs2 = await stream(fan, { startMin: 20, peak: 1 });-- await db.addTimelineEvents([-- ...[100, 120, 140].map((t) => ({ stream_id: fs2, user_id: fan, vod_id: null, kind: 'sound', start_sec: t, end_sec: t + 1, text: null, label: 'Screaming', confidence: 0.8 })),-- ...[200, 210, 220].map((t) => ({ stream_id: fs2, user_id: fan, vod_id: null, kind: 'sound', start_sec: t, end_sec: t + 1, text: null, label: 'Crying, sobbing', confidence: 0.8 })),-- { stream_id: fs2, user_id: fan, vod_id: null, kind: 'speech', start_sec: 125, end_sec: 130, text: 'We are going to get the control of this game now!', label: null, confidence: 0.9 },-- { stream_id: fs2, user_id: fan, vod_id: null, kind: 'speech', start_sec: 205, end_sec: 210, text: 'Thank you, thank you, thank you all.', 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' });---- // Chat announcements: captured instead of sent.-- const delivery = require('../server/chat/chat-delivery');-- const sent = [];-- delivery.message = async (line) => { sent.push(line); return sent.length; };-- const first = await drama.detect({ now: NOW });-- await new Promise((r) => setTimeout(r, 100));-- 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 } });-- assert.ok(!kinds.includes(`meltdown:${fan}`), 'crowd noise with nothing heated said is no meltdown');-- assert.ok(!kinds.some((k) => k.startsWith('tears:')), 'no tears stories');-- 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]);---- // Stories at alert 3+ reach chat once each: the live subject's stream room (mirrored), else global chat.-- assert.ok(sent.length >= 2 && sent.every((l) => l.message_type === 'system' && l.username === 'ARENA ALERT' && /^arena:\d+$/.test(l.key)));-- const rqLine = sent.find((l) => l.key === `arena:${rq.id}`);-- assert.ok(rqLine && rqLine.is_global && !rqLine.stream_id && /\/arena\/drama\/\d+$/.test(rqLine.message), 'an ended stream: global chat');-- const mdLine = sent.find((l) => l.key === `arena:${md.id}`);-- assert.deepStrictEqual([mdLine.stream_id, mdLine.mirror], [ss, true], 'a live subject: their stream room, mirrored');-- assert.ok(!sent.some((l) => l.message.includes('GHOST')), 'alert 1-2 stories stay off chat');---- // 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']);---- // "Ducked": only someone who was live while on the clock (crasher is live; ghost has not streamed since).-- assert.strictEqual(await drama.beefStory('forfeit', { id: 993, opened_at: ago(10) }, { loserId: ghost, winnerId: crasher }), null, 'never live on the clock: no story');-- assert.ok(await drama.beefStory('forfeit', { id: 994, opened_at: ago(10) }, { loserId: crasher, winnerId: 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 }));---- // The AI rewrite: taken when it names everyone exactly (case aside), dropped when it misspells a name.-- const arenaSvc = require('../server/arena/arena-service');-- const aiSvc = require('../server/ai/ai-service');-- arenaSvc.aiOn = async () => true;-- let answer = { headline: '🚨 GHOST STREAMS 90 MINUTES TO NOBODY', blurb: 'Peak 0. Chat 0. Brutal.', alert: 2 };-- aiSvc.structured = async (workflow, input) => { assert.strictEqual(workflow, 'live.arena.drama'); assert.strictEqual(input.streamer, 'ghost'); assert.match(input.what, /^Peak: 0 viewers\. Chat messages: 0\.$/); return answer; };-- const g1 = await drama.createStory({ kind: 'ghost_town', userId: ghost, dedupeKey: 'ai:1', facts: { minutes: 90, peak: 0, messages: 0 }, happenedAt: NOW });-- await new Promise((r) => setTimeout(r, 50));-- assert.deepStrictEqual(await db.get('SELECT headline, ai FROM arena_drama WHERE id = ?', [g1]), { headline: '🚨 GHOST STREAMS 90 MINUTES TO NOBODY', ai: 1 });-- answer = { headline: '🚨 GOSTY STREAMS TO NOBODY', blurb: 'x', alert: 2 };-- const g2 = await drama.createStory({ kind: 'ghost_town', userId: ghost, dedupeKey: 'ai:2', facts: { minutes: 90, peak: 0, messages: 0 }, happenedAt: NOW });-- await new Promise((r) => setTimeout(r, 50));-- assert.deepStrictEqual(await db.get('SELECT headline, ai FROM arena_drama WHERE id = ?', [g2]), { headline: '👻 ghost STREAMED 90 MINUTES TO AN EMPTY ROOM', ai: 0 }, 'a misspelled name keeps the template');---- // An accusation the template does not make keeps the template (a beef of game voice lines became "DECLARES DEATH-- // THREATS", 2026-10-18); one the template already makes may stay.-- answer = { headline: '🚨 ghost THREATENS AN EMPTY ROOM', blurb: 'Peak 0.', alert: 3 };-- const g3 = await drama.createStory({ kind: 'ghost_town', userId: ghost, dedupeKey: 'ai:3', facts: { minutes: 90, peak: 0, messages: 0 }, happenedAt: NOW });-- await new Promise((r) => setTimeout(r, 50));-- assert.strictEqual((await db.get('SELECT ai FROM arena_drama WHERE id = ?', [g3])).ai, 0, 'an added accusation keeps the template');-- assert.strictEqual(drama.addsAccusation('🚨 A DECLARES DEATH THREATS', '🥩 BEEF: A vs B. A came for B on mic: I kill for this crown'), true);-- assert.strictEqual(drama.addsAccusation('🚨 A KILLS IT AGAIN', 'A came for B on mic: I kill for this crown'), true, 'another form of the word is another word');-- assert.strictEqual(drama.addsAccusation('🚨 RAGE QUIT after 6 minutes', 'rager RAGE QUIT? Stream over after 6 minutes'), false);---- // The meltdown quote: the heated line near the shouting beats a longer mundane one.-- const { pickQuote } = drama._test;-- assert.strictEqual(pickQuote([-- { text: 'so the charge on each capacitor of the panel decides what you see on the screen today', start_sec: 95 },-- { text: 'bro why does this keep happening to me', start_sec: 128 },-- { text: 'ok', start_sec: 130 },-- ], 100, 160), 'bro why does this keep happening to me');-- assert.strictEqual(pickQuote([{ text: 'ok', start_sec: 1 }], 0, 10), null, 'too short to quote');---- console.log('arena-drama: all checks passed');-- process.exit(0);--})().catch((e) => { console.error(e); process.exit(1); });ddeleted file mode 100644@@@ -1,77 +0,0 @@--'use strict';--// The Arena judges are OpenVibe.AI templates (roadmap WS-O task 2): against a stub Network (token endpoint) and a--// stub AI, judgeMic and judgeBeef send live.arena.judge_mic / judge_beef with what Live heard (never a prompt), attributed--// to the speaker; the answer goes through Live's house rules (quotable line, clamp); no answer falls back to the--// heuristic.--// node test/arena-judge-ai.test.js--const assert = require('assert');--const http = require('http');--const crypto = require('crypto');--const { serviceAuth } = require('openvibe-contracts');----process.env.NODE_ENV = 'test';--process.env.OV_OAUTH_CLIENT_ID = 'live';--process.env.OV_OAUTH_CLIENT_SECRET = 'live-secret';--const quiet = console.log;--console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };--console.warn = () => {};----const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });--const runs = [];--let answer = null;--const network = http.createServer((req, res) => {-- let raw = ''; req.on('data', (c) => { raw += c; });-- req.on('end', () => {-- const f = new URLSearchParams(raw); const now = Math.floor(Date.now() / 1000);-- res.setHeader('Content-Type', 'application/json');-- res.end(JSON.stringify({ access_token: serviceAuth.signServiceToken({ iss: 'https://openvibe.network', sub: 'svc:live', actor_type: 'service', aud: [f.get('audience')], cap: ['ai.run.create'], iat: now, exp: now + 300, jti: `tok_${crypto.randomBytes(6).toString('hex')}` }, keys.privateKey), token_type: 'Bearer', expires_in: 300 }));-- });--});--const ai = http.createServer((req, res) => {-- let raw = ''; req.on('data', (c) => { raw += c; });-- req.on('end', () => {-- const body = raw ? JSON.parse(raw) : null;-- runs.push(body);-- res.setHeader('Content-Type', 'application/json');-- if (!answer) { res.statusCode = 502; return res.end(JSON.stringify({ code: 'provider.unavailable' })); }-- res.statusCode = 201;-- res.end(JSON.stringify({ run: { id: 'run_01JAB2C3D4E5F6G7H8J9K0MNPA', status: 'succeeded', synthetic: false, workflow: { key: body.workflow }, output: answer, usage: { tokens_in: 10, tokens_out: 5, cost_usd: 0.0001 }, provenance: { model: 'm' } } }));-- });--});----(async () => {-- await new Promise((r) => network.listen(0, '127.0.0.1', r));-- await new Promise((r) => ai.listen(0, '127.0.0.1', r));-- process.env.OV_NETWORK_INTERNAL_URL = `http://127.0.0.1:${network.address().port}`;-- process.env.OV_AI_INTERNAL_URL = `http://127.0.0.1:${ai.address().port}`;-- const db = require('../server/db/database');-- await db.initDb();-- await db.setSetting('ai_enabled', 'true');-- const listener = require('../server/arena/listener');-- try {-- answer = { is_trash_talk: true, garbled: false, quality: 12, best_line: 'chat you are the worst mods i have ever seen', about: 'the mods', aimed_at: 'The Mods', announcer: 'OH!', flagged: false };-- const mic = await listener._judgeMic(7, 'chat you are the worst mods i have ever seen');-- assert.deepStrictEqual([runs[0].workflow, runs[0].input, runs[0].attribution], ['live.arena.judge_mic', { speech: 'chat you are the worst mods i have ever seen' }, { service: 'live', type: 'user', id: '7' }]);-- assert.ok(!JSON.stringify(runs[0]).includes('You judge'), 'no prompt is sent');-- assert.deepStrictEqual([mic.is_trash_talk, mic.quality, mic.best_line, mic.aimed_at, mic.fallback], [true, 10, 'Chat you are the worst mods i have ever seen.', 'the mods', false]);-- answer = { ...answer, best_line: 'too short' };-- assert.strictEqual((await listener._judgeMic(7, 'too short a line')).is_trash_talk, false, 'the quotable-line rule still applies');-- answer = null;-- const fallback = await listener._judgeMic(7, 'you are all clowns and nobody in chat can beat me');-- assert.strictEqual(fallback.fallback, true, 'no answer: the heuristic');---- await db.getDb().prepare("INSERT INTO users (id, username, display_name, password_hash) OVERRIDING SYSTEM VALUE VALUES (8, 'ann', 'Ann', '$sso$')").run();-- const roster = { byId: { 8: { user: { username: 'ann', display_name: 'Ann' } } } };-- answer = { about_target: true, aimed_at_target: true, quality: 7, best_line: 'ann your stream is so boring even your bots left', about: 'her stream', announcer: 'Ouch', flagged: false };-- const beef = await listener._judgeBeef(7, 8, 'ann your stream is so boring even your bots left', roster, { context: 'called her washed', named: true, how: 'exact' });-- const b = runs[runs.length - 1];-- assert.strictEqual(b.workflow, 'live.arena.judge_beef');-- assert.deepStrictEqual(Object.keys(b.input), ['target_names', 'target_as_transcribed', 'target_named_in_new_speech', 'how_the_name_was_matched', 'what_speaker_already_said_about_target', 'new_speech']);-- assert.deepStrictEqual([b.input.target_names, b.input.what_speaker_already_said_about_target], [['ann', 'Ann'], 'called her washed']);-- assert.deepStrictEqual([beef.aimed_at_target, beef.quality, beef.fallback], [true, 7, false]);-- } finally {-- network.close(); ai.close();-- }-- console.log('arena judge via AI: all checks passed');-- process.exit(0);--})().catch((e) => { console.error(e); process.exit(1); });ddeleted file mode 100644@@@ -1,258 +0,0 @@--'use strict';----// Arena v3 (Battle Cam) — pure mic. The roster is whoever has been heard, ratings come from the--// transcripts + the mic ledger, beefs open only when a fighter's whole name is said (never from the--// mic judge's guess at "aimed_at", a nickname or a sound-alike),--// free-standing shit talk lands in the feed and pays Trash Level XP, and chat can only hype.--// Runs end-to-end on a temp DB with no AI configured (heuristic judges, 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-arena3-'));--process.env.ARENA_IMAGE_PATH = path.join(tmp, 'arena');----const db = require('../server/db/database');--(async () => {--await db.initDb();--const arena = require('../server/arena/arena-service');--const mic = require('../server/arena/mic');--const beef = require('../server/arena/beef');--const listener = require('../server/arena/listener');--const chat = require('../server/arena/arena-chat');----const mk = async (username) => { await db.createUser({ username, email: `${username}@x`, password_hash: 'x', display_name: username[0].toUpperCase() + username.slice(1), stream_key: username.padEnd(32, '0') }); return (await db.get('SELECT id FROM users WHERE username = ?', [username])).id; };--const u1 = await mk('nova'), u2 = await mk('grizzly_bear'), u3 = await mk('pixelqueen'), quiet = await mk('quietguy'), viewer = await mk('viewer');--for (const uid of [u1, u2, u3, quiet, viewer]) await db.ensureChannel(uid);--// Streams: everyone streamed, but the roster only cares who was HEARD.--const stream = async (uid, hours, daysAgo, peak = 500) => {-- const id = Number((await db.createStream({ user_id: uid, title: `${uid} stream`, category: 'irl', protocol: 'rtmp' })).lastInsertRowid);-- await db.run(`UPDATE streams SET is_live = 0, started_at = datetime('now', ?), ended_at = datetime('now', ?), duration_seconds = ?, peak_viewers = ? WHERE id = ?`, [`-${daysAgo} days`, `-${daysAgo} days`, Math.round(hours * 3600), peak, id]);-- return id;--};--const s1 = await stream(u1, 2, 2), s2 = await stream(u2, 2, 1), s3 = await stream(u3, 1, 3);--await stream(quiet, 40, 1, 9000); // huge audience, never says a word → NOT on the roster--const speak = async (streamId, uid, n, mk) => await db.addTimelineEvents(Array.from({ length: n }, (_, i) => ({ stream_id: streamId, user_id: uid, vod_id: null, kind: 'speech', start_sec: i * 30, end_sec: i * 30 + 8, text: mk(i), label: null, confidence: 0.9 })));--await speak(s1, u1, 60, i => `Chat listen, this is line ${i}, we are cooking today, nobody can touch this, bet!`);--await speak(s2, u2, 30, i => `Okay so line ${i}, let me check the map and farm this boss.`);--await speak(s3, u3, 10, i => `Line ${i} hello everyone welcome in.`);----const roster = await arena.loadRoster(true);--assert.deepStrictEqual([...roster.order].sort(), [u1, u2, u3].sort(), 'roster = everyone with speech; the silent 9000-viewer streamer is not a fighter');--assert.ok(!roster.byId[quiet], 'audience size buys nothing');--assert.deepStrictEqual(arena.STAT_KEYS, ['heat', 'aim', 'kills', 'mouth', 'clapback', 'stamina', 'pace']);--assert.ok(Math.abs(Object.values(arena.STAT_WEIGHTS).reduce((a, b) => a + b, 0) - 1) < 1e-9, 'stat weights sum to 1');--const f1 = roster.byId[u1];--assert.ok(f1.raw.mic && f1.raw.mic.window_days === 30 && f1.raw.mic.moments === 0, 'mic stats present, nothing judged yet');--assert.ok(!('peak_viewers' in f1.raw) && !('followers' in f1.raw) && !('messages_per_hour' in f1.raw), 'no audience numbers anywhere in the raw stats');--assert.strictEqual(f1.ratings.stamina, 99, 'the most speech = top STAMINA');--assert.strictEqual(roster.byId[u3].ratings.stamina, 40);--assert.strictEqual(f1.ratings.heat, roster.byId[u3].ratings.heat, 'nobody judged yet → HEAT ties');--console.log('✅ roster + ratings are pure mic');----{-- // ── Mic ledger: moments pay XP, best line sticks, feed lists newest first ──-- const m1 = await mic.addMoment({ userId: u1, streamId: s1, vodId: 501, sec: 40, kind: 'trash', aimedAt: 'chat', text: 'Chat you are all clowns, every one of you, bet', about: 'chat is clowns', quality: 7 });-- assert.ok(m1 && m1.id, 'moment stored');-- assert.strictEqual(await mic.addMoment({ userId: u1, kind: 'trash', text: 'kys buddy', quality: 9 }), null, 'threats never land in the feed');-- const lv = await mic.levelView(u1);-- assert.strictEqual(lv.xp, Math.round(7 * mic.XP_MOMENT), `trash moment pays quality × ${mic.XP_MOMENT} XP: ${lv.xp}`);-- assert.strictEqual(lv.mic_moments, 1); assert.strictEqual(lv.best_line.text, 'Chat you are all clowns, every one of you, bet');-- await mic.addMoment({ userId: u1, streamId: s1, kind: 'trash', aimedAt: 'the mods', text: 'the mods are asleep again, useless', about: 'mods', quality: 5 });-- const feed = await mic.feed({ limit: 10 });-- assert.strictEqual(feed.length, 2); assert.strictEqual(feed[0].aimed_at, 'the mods'); assert.strictEqual(feed[1].vod_id, 501);-- assert.strictEqual(feed[0].fighter_name, 'Nova');-- const ms = await mic.micStats(u1);-- assert.strictEqual(ms.moments, 2); assert.strictEqual(ms.avg_quality, 6); assert.strictEqual(ms.bangers, 1);-- assert.strictEqual((await arena.loadRoster(true)).byId[u1].ratings.heat, 99, 'judged moments lift HEAT');-- console.log('✅ mic ledger: moments, XP, best line, feed, stats');---- // ── Beefs: a hit logs into the feed too; clocks flip; forfeit; KILLS ──-- const h1 = await beef.recordHit(u1, u2, { quality: 7, best_line: 'Grizzly streams to 12 people and 9 are his alts', about: 'alts', announcer: 'Nova swings first!', stream_id: s1, vod_id: 501, sec: 90 });-- assert.strictEqual(h1.opened, true); assert.strictEqual(h1.pending, true); assert.strictEqual(h1.beef.on_clock, 'b');-- assert.ok(!('bounty' in h1), 'bounties are gone');-- // One-sided, it is nobody's business yet: no public moment, no XP, no beef page, no open beef (2026-10-18).-- assert.ok(!(await mic.feed({ limit: 5 })).some((m) => m.beef_id === h1.beef.id), 'a pending callout is not in the feed');-- assert.strictEqual(await beef.get(h1.beef.id), null, 'nor on its own page');-- assert.strictEqual((await beef.openBeefsFor(u1)).length, 0);-- const h2 = await beef.recordHit(u2, u1, { quality: 9, best_line: 'Nova needed a bit to get views, I just exist', about: 'clout', stream_id: s2, vod_id: 502, sec: 40 });-- assert.strictEqual(h2.first_response, true); assert.strictEqual(h2.opened, true, 'the answer opens it'); assert.strictEqual(h2.beef.status, 'open'); assert.strictEqual(h2.beef.on_clock, 'a');-- const fm = (await mic.feed({ limit: 1 }))[0];-- assert.strictEqual(fm.kind, 'beef_hit'); assert.strictEqual(fm.target.user.id, u1); assert.strictEqual(fm.beef_id, h1.beef.id); assert.strictEqual(fm.sec, 40);-- assert.strictEqual((await mic.levelView(u2)).beef_hits, 1);-- assert.deepStrictEqual(await beef.hype(h1.beef.id, 'a', 'anon:x'), { added: true, hypers: 1, crowd: 1 });-- await assert.rejects(beef.hype(h1.beef.id, 'a', `user:${u1}`), /yourself/);-- await db.run(`UPDATE arena_beefs SET clock_until = datetime('now', '-1 minute') WHERE id = ?`, [h1.beef.id]);-- await beef.tick();-- const v = await beef.get(h1.beef.id);-- assert.strictEqual(v.resolution, 'forfeit'); assert.strictEqual(v.winner_user_id, u2, 'nova went silent on the clock → grizzly wins');-- assert.ok(!('bounty' in v), 'no bounty field on the view');-- assert.deepStrictEqual(await beef.recordFor(u2), { wins: 1, losses: 0, draws: 0 });-- const r2 = await arena.loadRoster(true);-- assert.strictEqual(r2.byId[u2].ratings.kills, 99, 'a win is a KILL');-- assert.strictEqual(r2.byId[u2].raw.mic.answered, 1, 'answering on the clock counts for CLAPBACK');-- assert.ok(r2.byId[u2].ratings.talk_bonus > 0, 'recent win → mouth bonus');-- console.log('✅ beefs feed the ledger; forfeit; KILLS + CLAPBACK from the mic ledger');---- // ── Heuristic judges (AI off) ──-- const hm = listener._heuristicMic('Chat, listen, all of you are clowns, absolute bums, nobody in this chat could beat me at anything, bet!');-- assert.strictEqual(hm.is_trash_talk, true); assert.ok(hm.quality >= 5, `quality ${hm.quality}`); assert.strictEqual(hm.aimed_at, 'chat');-- assert.strictEqual(listener._heuristicMic('okay so we need to farm this boss and then go to the shop, let me check the map').is_trash_talk, false);-- assert.strictEqual(listener._heuristicBeef('thanks grizzly for the raid, love you', ['grizzly']).aimed_at_target, false);-- console.log('✅ heuristic judges');---- // ── Listener over a live stream: free talk → feed; a name-drop → beef; a callout by nickname → beef ──-- const liveId = Number((await db.createStream({ user_id: u3, title: 'live', category: 'irl', protocol: 'rtmp' })).lastInsertRowid);-- await db.run(`UPDATE streams SET is_live = 1, started_at = datetime('now', '-600 seconds') WHERE id = ?`, [liveId]);-- const xpBefore = (await mic.levelView(u3)).xp;-- await db.addTimelineEvents([-- { stream_id: liveId, user_id: u3, vod_id: null, kind: 'speech', start_sec: 586, end_sec: 590, text: 'Chat, every single one of you is a clown, you bums could never do what I do, nobody in here has a job, bet!', label: null, confidence: 0.9 },-- { stream_id: liveId, user_id: u3, vod_id: null, kind: 'speech', start_sec: 591, end_sec: 596, text: 'Sit down, all of you, absolute losers, I am better than this whole chat combined and everyone knows it!', label: null, confidence: 0.9 },-- ]);-- let ev = await listener.tick();-- assert.ok(ev.some(e => e.kind === 'mic_hit' && e.speakerId === u3), `free shit talk lands in the feed: ${JSON.stringify(ev)}`);-- assert.ok((await mic.levelView(u3)).xp > xpBefore, 'and pays XP');-- assert.strictEqual((await mic.feed({ limit: 1 }))[0].user.id, u3);-- const cs = await listener.consoleState(u3);-- assert.ok(cs.listening && cs.last_mic_judgement && cs.last_mic_judgement.is_trash_talk && cs.focus === null, 'console shows the mic judgement, no lock');-- assert.strictEqual((await arena.liveFighters())[0].user.id, u3);-- const camThumb = (await arena.liveFighters())[0].thumbnail_url;-- assert.ok(camThumb === null || typeof camThumb === 'string', `a live cam's frame is a URL or null, never ${JSON.stringify(camThumb)} (an un-awaited lookup made it {}, and the page asked for /[object Object])`);-- assert.ok((await arena.liveFighters())[0].last_moment && (await arena.liveFighters())[0].last_moment.aimed_at === 'chat', 'live cam carries the last judged line');-- // Name-drop → focus lock → beef.-- await db.run(`UPDATE streams SET started_at = datetime('now', '-700 seconds') WHERE id = ?`, [liveId]);-- await db.addTimelineEvents([-- { stream_id: liveId, user_id: u3, vod_id: null, kind: 'speech', start_sec: 686, end_sec: 690, text: 'And grizzly bear? Grizzly bear is trash, washed, a fraud, he could never beat me, he is scared of the smoke, bet!', label: null, confidence: 0.9 },-- { stream_id: liveId, user_id: u3, vod_id: null, kind: 'speech', start_sec: 691, end_sec: 696, text: 'Nobody watches that stream, it is mid, it is garbage, I would cook him in a second!', label: null, confidence: 0.9 },-- ]);-- listener._state.get(liveId).lastJudgeAt = 0;-- ev = await listener.tick();-- assert.ok(ev.some(e => e.kind === 'beef_hit' && e.targetId === u2 && e.opened), `name-drop opened the beef: ${JSON.stringify(ev)}`);-- assert.ok((await listener.consoleState(u3)).focus && (await listener.consoleState(u3)).focus.target_id === u2, 'locked on grizzly');-- assert.strictEqual((await beef.openBeefsFor(u3)).length, 0, 'one-sided: pending, not open');-- assert.strictEqual(await db.get("SELECT status FROM arena_beefs WHERE a_user_id = ? AND b_user_id = ?", [u3, u2]).then((r) => r && r.status), 'pending');-- assert.strictEqual((await arena.liveFighters())[0].ears.focus.target_id, u2, 'live cam shows the lock');-- // Unnamed trash while locked is context, never a hit (a League stream's voice lines after one transcribed "Adam"-- // scored a 107–0 beef, 2026-10-18).-- await db.run(`UPDATE streams SET started_at = datetime('now', '-760 seconds') WHERE id = ?`, [liveId]);-- await db.addTimelineEvents([{ stream_id: liveId, user_id: u3, vod_id: null, kind: 'speech', start_sec: 746, end_sec: 752, text: 'He is trash, washed, a fraud, scared of the smoke, nobody watches that garbage stream, I would cook him, bet!', label: null, confidence: 0.9 }]);-- listener._state.get(liveId).lastJudgeAt = 0;-- ev = await listener.tick();-- assert.ok(!ev.some(e => e.kind === 'beef_hit'), `unnamed lines never score: ${JSON.stringify(ev)}`);-- // Drop the lock (two off-target chunks).-- for (let k = 0; k < 2; k++) {-- await db.run(`UPDATE streams SET started_at = datetime('now', ?) WHERE id = ?`, [`-${800 + k * 100} seconds`, liveId]);-- await db.addTimelineEvents([{ stream_id: liveId, user_id: u3, vod_id: null, kind: 'speech', start_sec: 786 + k * 100, end_sec: 792 + k * 100, text: 'Okay chat back to the game, we need to farm this boss and then do the quest line for the sword upgrade, let me check the map real quick.', label: null, confidence: 0.9 }]);-- listener._state.get(liveId).lastJudgeAt = 0;-- await listener.tick();-- }-- assert.strictEqual((await listener.consoleState(u3)).focus, null, 'moved on → lock dropped');-- // A piece of a name or a sound-alike names nobody: no lock, no beef (a League stream's "Minions" locked onto-- // mnss22741 and opened three beefs of game voice lines, 2026-10-18).-- await db.run(`UPDATE streams SET started_at = datetime('now', '-1100 seconds') WHERE id = ?`, [liveId]);-- await db.addTimelineEvents([{ stream_id: liveId, user_id: u3, vod_id: null, kind: 'speech', start_sec: 1086, end_sec: 1092, text: 'Grizzly? Grizzly is trash, washed, a fraud, scared of the smoke, I would cook that grizly bare in a second, bet!', label: null, confidence: 0.9 }]);-- listener._state.get(liveId).lastJudgeAt = 0;-- ev = await listener.tick();-- assert.ok(!ev.some(e => e.kind === 'focus' || e.kind === 'beef_hit'), `a nickname or a misspelling locks nothing: ${JSON.stringify(ev)}`);-- assert.strictEqual((await listener.consoleState(u3)).focus, null);-- assert.strictEqual(await db.get("SELECT COUNT(*) AS n FROM arena_beefs WHERE a_user_id = ? AND status = 'pending'", [u3]).then((r) => Number(r.n)), 1, 'still the one callout a whole name started');-- // Grizzly answers, naming her on his own stream: now it is a beef, and public.-- const answer = await beef.recordHit(u2, u3, { quality: 6, best_line: 'pixelqueen talks big for someone who streams to her cat', about: 'answer' });-- assert.strictEqual(answer.opened, true);-- assert.strictEqual((await beef.openBeefsFor(u3)).length, 1);-- // An unanswered callout ends quietly: no forfeit, no record, no story.-- const lone = await beef.recordHit(u1, u3, { quality: 8, best_line: 'pixelqueen could not win a race against a parked car', about: 'race' });-- await db.run("UPDATE arena_beefs SET clock_until = datetime('now', '-1 minute') WHERE id = ?", [lone.beef.id]);-- await beef.tick();-- assert.strictEqual((await db.get('SELECT status, winner_user_id FROM arena_beefs WHERE id = ?', [lone.beef.id])).status, 'expired');-- assert.deepStrictEqual(await beef.recordFor(u1), { wins: 0, losses: 1, draws: 0 }, 'the expired callout counts for nobody (the loss is the forfeit above)');-- assert.strictEqual(await listener._aimedAtUnnamed('grizzly bear', u3, await arena.loadRoster()), null, "the judge's guess at a fighter is dropped");-- assert.strictEqual(await listener._aimedAtUnnamed('chat', u3, await arena.loadRoster()), 'chat');-- const names = require('../server/arena/names');-- const fake = { order: [41, 55], byId: { 41: { user: { username: 'mnss22741', display_name: 'mnss22741' } }, 55: { user: { username: 'finditfixit', display_name: 'finditfixit' } } } };-- const entries = names.rosterEntries(fake, (id) => (id === 55 ? ['fix it'] : []));-- const whole = (t) => names.findMentions(t, entries, { wholeOnly: true }).map(m => m.userId).sort((a, b) => a - b);-- assert.deepStrictEqual(whole('Minions has been.'), [], 'a sound-alike is nobody');-- assert.deepStrictEqual(whole("This is lame. We have to fix it."), [], 'a nickname that is a phrase is nobody');-- assert.deepStrictEqual(whole('mnss 22741 is mid and finditfixit too'), [41, 55], 'whole names, however spaced');-- assert.deepStrictEqual(names.findMentions('Minions has been.', entries).map(m => m.how), ['phonetic'], 'the loose matcher still exists for other uses');-- console.log('✅ listener: free talk → feed + XP; name-drop → lock → beef; lets go when they move on');---- // ── Persona fallback + fighter card are mic-shaped ──-- const card = await arena.getFighter('nova');-- assert.ok(card.mic && card.mic.moments >= 2 && card.level.level >= 1 && Array.isArray(card.beefs));-- assert.ok(!('active_topic' in card), 'no topics');-- assert.ok(['Rushdown', 'Sniper', 'Assassin', 'Caster', 'Counter', 'Tank', 'Zoner'].includes(card.persona.class), `fallback class from the mic stats: ${card.persona.class}`);-- assert.deepStrictEqual(Object.keys(card.persona.stat_quips).sort(), [...arena.STAT_KEYS].sort());-- const detail = await arena.getStatDetail(u1, 'heat');-- assert.strictEqual(detail.unit, 'avg judge score'); assert.ok(detail.series.length >= 1);-- assert.strictEqual(await arena.getStatDetail(u1, 'hype'), null, 'audience stats do not exist');-- const lb = await mic.levelsLeaderboard(5);-- assert.ok(lb.length >= 2 && lb[0].xp >= lb[1].xp);-- console.log('✅ fighter card, stat detail, ladder');---- // ── Chat: hype only ──-- const sent = [], room = [];-- // track() is how a caller that answers once waits for the command's replies (Chat's arena-command call).-- let pending = [];-- const fakeChat = { sendTo: (ws, m) => sent.push(m.message), broadcastToStream: (sid, m) => room.push(m.message), track: (p) => pending.push(p) };-- const run = async (client, line) => { const parts = line.split(' '); const handled = await chat.handle(fakeChat, {}, client, parts[0], parts); await Promise.all(pending); pending = []; return handled; };-- assert.strictEqual(await run({ user: { id: viewer, username: 'viewer' }, streamId: liveId, ip: '1.1.1.1' }, '!topic anything'), false, '!topic no longer exists');-- assert.strictEqual(await run({ anonId: 'b1', streamId: liveId }, '!bounty nova'), false, '!bounty no longer exists');-- assert.strictEqual(await run({ anonId: 'b2', streamId: liveId }, '!board'), false, '!board no longer exists');-- // The Arena became The Rundown (2026-10-19): !hype and !beef point there, !arena answers with the streamer's Rundown.-- await run({ user: { id: viewer, username: 'viewer', display_name: 'Viewer' }, streamId: liveId, ip: '1.1.1.1' }, '!hype');-- assert.match(sent.pop(), /Beefs are gone.*\/rundown$/);-- await run({ anonId: 'zz', streamId: liveId }, '!beef');-- assert.match(sent.pop(), /Beefs are gone/);-- await run({ anonId: 'zy', streamId: liveId }, '!arena');-- assert.match(sent.pop(), /rundown/i);-- console.log('✅ chat: !hype / !beef point to The Rundown');---- // ── Backfill: past speech on ended streams gets judged into the feed (heuristic judges, no AI) ──-- const backfill = require('../server/arena/backfill');-- const old = Number((await db.createStream({ user_id: u2, title: 'yesterday', category: 'irl', protocol: 'rtmp' })).lastInsertRowid);-- await db.run(`UPDATE streams SET is_live = 0, started_at = datetime('now', '-1 day'), ended_at = datetime('now', '-23 hours'), duration_seconds = 3600 WHERE id = ?`, [old]);-- await db.addTimelineEvents([-- { stream_id: old, user_id: u2, vod_id: 777, kind: 'speech', start_sec: 100, end_sec: 106, text: 'Chat, all of you are clowns, absolute bums, nobody in this chat could beat me at anything, sit down losers, bet!', label: null, confidence: 0.9 },-- { stream_id: old, user_id: u2, vod_id: 777, kind: 'speech', start_sec: 107, end_sec: 112, text: 'Pathetic, every single one of you is washed and delusional, I am better than this entire chat!', label: null, confidence: 0.9 },-- { stream_id: old, user_id: u2, vod_id: 777, kind: 'speech', start_sec: 400, end_sec: 406, text: 'Okay so we go to the shop, buy the potions, then head to the cave and farm the boss for the sword, let me check the map for the route.', label: null, confidence: 0.9 },-- { stream_id: old, user_id: u2, vod_id: 777, kind: 'speech', start_sec: 407, end_sec: 412, text: 'Yeah the cave is north of the village past the bridge, should take about ten minutes if we do not get lost again.', label: null, confidence: 0.9 },-- ]);-- const feedBefore = (await mic.feed({ limit: 100 })).length;-- const bfr = await backfill.run({ force: true });-- assert.ok(bfr.moments >= 1, `backfill judged old speech into the feed: ${JSON.stringify(bfr)}`);-- assert.ok(bfr.skipped >= 1, 'gameplay chunk never cost a judge call');-- const fromOld = (await mic.feed({ limit: 100 })).filter(m => m.stream_id === old);-- assert.ok(fromOld.length >= 1 && fromOld[0].vod_id === 777 && fromOld[0].aimed_at === 'chat', `moment carries VOD + target: ${JSON.stringify(fromOld[0])}`);-- assert.ok(fromOld[0].at && Date.now() - Date.parse(fromOld[0].at.replace(' ', 'T') + 'Z') > 20 * 3600 * 1000, 'moment is dated when it was SAID, not when it was judged');-- assert.ok((await mic.feed({ limit: 100 })).length >= feedBefore + fromOld.length, 'other fighters\' spicy past lines were judged too');-- const again = await backfill.run({ force: true });-- assert.strictEqual(again.judged, 0, 'cursor: nothing re-judged on the second run');-- assert.deepStrictEqual(await backfill.run({ force: true }), await backfill.run({ force: true }));-- console.log('✅ backfill: past speech judged once, VOD-linked, dated when said');---- // ── Public API smoke ──-- const express = require('express');-- const app = express(); app.use(express.json()); app.use('/api/arena', require('../server/arena/routes'));-- const srv = await new Promise(r => { const s = app.listen(0, () => r(s)); });-- const get = async (p) => { const res = await fetch(`http://127.0.0.1:${srv.address().port}/api/arena${p}`); const txt = await res.text(); let body = null; try { body = JSON.parse(txt); } catch { body = null; } return { status: res.status, body }; };-- const fd = await get('/feed?limit=5'); assert.strictEqual(fd.status, 200); assert.ok(fd.body.feed.length >= 3 && fd.body.feed[0].text);-- assert.strictEqual((await get('/backfill')).status, 404, 'backfill is POST + admin only');-- const fi = await get('/fighters'); assert.deepStrictEqual(fi.body.stats, arena.STAT_KEYS); assert.ok(fi.body.fighters.every(f => f.mic && typeof f.mic.avg_quality === 'number'));-- const bf = await get('/beefs'); assert.strictEqual(bf.body.open.length, 1); assert.strictEqual(bf.body.resolved.length, 1);-- const con = await get('/console/pixelqueen'); assert.strictEqual(con.body.listener.listening, true); assert.ok(con.body.recent_moments.length >= 1 && con.body.mic);-- const lv2 = await get('/live'); assert.strictEqual(lv2.body.live[0].open_beefs, 1);-- const st = await get('/status'); assert.strictEqual(st.body.mode, 'battle-cam'); assert.ok(!('topics_open' in st.body));-- assert.strictEqual((await get('/board')).status, 404, 'the board is gone');-- assert.strictEqual((await get('/yappers')).status, 404, 'yappers are gone');-- assert.strictEqual((await get('/fighters/quietguy')).body.not_on_roster, true);-- srv.close();-- console.log('✅ public API');---- console.log('\n✅ All Arena (Battle Cam) tests passed');-- process.exit(0);--}--})().catch(err => { console.error(err); process.exit(1); });ddeleted file mode 100644@@@ -1,69 +0,0 @@--'use strict';--// Arena voice: one synthesis per (voice, text), cached on disk; equipped cosmetic voice wins; limits.--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-voice-'));--process.env.TTS_CACHE_PATH = path.join(tmp, 'tts-cache');--const db = require('../server/db/database');--const voice = require('../server/arena/voice');--let calls = [];--voice._setSynth(async (v, text, username) => { calls.push({ sig: v.sig, text, username }); return { audio: Buffer.from(`AUDIO:${text}`).toString('base64'), mimeType: 'audio/mpeg', engine: 'espeak-ng', voiceId: 'auto:gary' }; });----(async () => {-- await db.initDb();-- await db.createUser({ username: 'Goosely', email: 'g@x', password_hash: 'x', display_name: 'Goosely', stream_key: 'g'.repeat(32) });-- const user = await db.get('SELECT * FROM users WHERE username = ?', ['Goosely']);-- const v = await voice.voiceFor(user);-- assert.strictEqual(v.kind, 'auto'); assert.strictEqual(v.identityKey, 'user:goosely', 'same identity key chat uses');-- assert.strictEqual((await voice.voiceFor(null)).kind, 'announcer');-- const a = await voice.speak({ user, text: ' you think your chat is loud? ' });-- assert.strictEqual(a.cached, false); assert.strictEqual(a.mimeType, 'audio/mpeg'); assert.ok(fs.existsSync(a.path));-- assert.strictEqual(fs.readFileSync(a.path, 'utf8'), 'AUDIO:you think your chat is loud?', 'text is normalised before synthesis');-- const b = await voice.speak({ user, text: 'you think your chat is loud?' });-- assert.strictEqual(b.cached, true); assert.strictEqual(b.path, a.path); assert.strictEqual(calls.length, 1, 'second click never re-synthesizes');-- const c = await voice.speak({ user: null, text: 'you think your chat is loud?' });-- assert.notStrictEqual(c.path, a.path, 'same text in another voice is another file'); assert.strictEqual(calls.length, 2);-- assert.strictEqual((await db.get(`SELECT COUNT(*) AS n FROM ai_usage WHERE kind = 'tts'`)).n, 2, 'every synthesis is metered');-- await assert.rejects(voice.speak({ user, text: 'x' }), /Nothing to say/);-- await db.setSetting('arena_voice_daily_max', '2');-- await assert.rejects(voice.speak({ user, text: 'a brand new line' }), /hoarse|budget/, 'daily cap stops fresh synthesis');-- const d = await voice.speak({ user, text: 'you think your chat is loud?' });-- assert.strictEqual(d.cached, true, 'cache hits still work past the cap');-- console.log('✅ voice: cached per (voice, text), metered, capped');---- let ok = 0; for (let i = 0; i < 12; i++) if (voice.allow('1.2.3.4', false)) ok++;-- assert.strictEqual(ok, 8, 'anonymous: 8 per minute'); assert.ok(voice.allow('1.2.3.4', true) === false || true);-- let ok2 = 0; for (let i = 0; i < 40; i++) if (voice.allow('5.6.7.8', true)) ok2++;-- assert.strictEqual(ok2, 30, 'signed in: 30 per minute');-- console.log('✅ voice: per-IP limits');---- // HTTP: streams the file with a week of caching; announcer works without a user.-- const express = require('express'); const app = express(); app.use('/api/arena', require('../server/arena/routes'));-- const srv = await new Promise(r => { const s = app.listen(0, () => r(s)); });-- await db.setSetting('arena_voice_daily_max', '100');-- const res = await fetch(`http://127.0.0.1:${srv.address().port}/api/arena/voice/goosely?t=${encodeURIComponent('you think your chat is loud?')}`);-- assert.strictEqual(res.status, 200); assert.strictEqual(res.headers.get('content-type'), 'audio/mpeg'); assert.ok(/max-age=604800/.test(res.headers.get('cache-control'))); assert.strictEqual(res.headers.get('x-cache'), 'HIT');-- assert.strictEqual((await res.text()), 'AUDIO:you think your chat is loud?');-- const res2 = await fetch(`http://127.0.0.1:${srv.address().port}/api/arena/voice/announcer?t=${encodeURIComponent('GRIZZLY ANSWERS')}`);-- assert.strictEqual(res2.status, 200); assert.strictEqual(res2.headers.get('x-cache'), 'MISS');-- assert.strictEqual((await fetch(`http://127.0.0.1:${srv.address().port}/api/arena/voice/nobody?t=hi`)).status, 404);-- assert.strictEqual((await fetch(`http://127.0.0.1:${srv.address().port}/api/arena/voice/goosely?t=`)).status, 400);-- srv.close();-- console.log('✅ voice: HTTP streaming + caching headers');---- // One-off clips (admin Test Voice / mod preview) get a same-origin URL from the same cache —-- // no blob:/data: needed in the browser, and the filename is traversal-proof.-- const st = voice.stash(Buffer.from('RIFFfakewav').toString('base64'), 'audio/wav');-- assert.ok(/^[a-f0-9]{32}\.wav$/.test(st.file) && fs.existsSync(path.join(voice.CACHE_DIR, st.file)));-- assert.strictEqual(voice.stash(Buffer.from('RIFFfakewav').toString('base64'), 'audio/wav').file, st.file, 'same clip → same file');-- assert.strictEqual(voice.stash('', 'audio/wav'), null);-- assert.strictEqual(voice.cachedByName('../../etc/passwd'), null, 'no traversal');-- assert.strictEqual(voice.cachedByName('zz.wav'), null);-- assert.ok(voice.cachedByName(st.file).mimeType === 'audio/wav');-- // An arena voice line lands in the clip cache by filename (the /api/tts clip route is Chat's now).-- const line = await voice.speak({ user, text: 'you think your chat is loud?' });-- assert.ok(voice.cachedByName(path.basename(line.path)), 'a spoken line is served from the clip cache');-- console.log('✅ one-off clip stash + arena voice cache');-- console.log('\n✅ All Arena voice tests passed');-- process.exit(0);--})().catch(err => { console.error(err); process.exit(1); });ddeleted file mode 100644@@@ -1,156 +0,0 @@--'use strict';----// Arena roster regression tests (Battle Cam mode): ratings are fair percentiles over MIC-ONLY--// metrics, the behaviour-only filter blocks threats/minors/doxxing but never vocabulary, quotes--// come from real transcript lines with VOD links, and the roster/card/stat API works end-to-end on--// a temp DB without any AI configured (personas fall back, no LLM calls). Beefs + the ledger +--// the listener: arena-mic.test.js.----const assert = require('assert');--const fs = require('fs');--const os = require('os');--const path = require('path');----const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-arena-'));--process.env.ARENA_IMAGE_PATH = path.join(tmp, 'arena');----const db = require('../server/db/database');--(async () => {--await db.initDb();--const arena = require('../server/arena/arena-service');----// ── Pure helpers ──--const base = { voice: { talk_ratio_pct: 10, speech_minutes: 20, wpm: 100 }, mic: { avg_quality: 2, hits_per_mic_hour: 0.5, wins: 0, clapback_rate: 0 } };--const ratings = arena._computeRatings({-- 1: { voice: { talk_ratio_pct: 60, speech_minutes: 300, wpm: 180 }, mic: { avg_quality: 8, hits_per_mic_hour: 6, wins: 5, clapback_rate: 1 } },-- 2: { ...base },-- 3: { voice: { talk_ratio_pct: 30, speech_minutes: 100, wpm: 140 }, mic: { avg_quality: 5, hits_per_mic_hour: 2, wins: 2, clapback_rate: 0.5 } },--});--assert.strictEqual(ratings[1].heat, 99, 'top of every metric is a 99');--assert.strictEqual(ratings[2].heat, 40, 'bottom is the floor, never 0');--assert.strictEqual(ratings[1].kills, 99, 'KILLS ranks beef wins');--assert.strictEqual(ratings[2].mouth, 40);--assert.ok(ratings[3].aim > 40 && ratings[3].aim < 99, 'middle sits in between');--assert.ok(ratings[1].power > ratings[3].power && ratings[3].power > ratings[2].power, 'power orders the roster');--assert.ok(Math.abs(Object.values(arena.STAT_WEIGHTS).reduce((a, b) => a + b, 0) - 1) < 1e-9, 'stat weights sum to 1');--const solo = arena._computeRatings({ 7: { ...base } });--assert.strictEqual(solo[7].heat, 70, 'a roster of one is a flat 70 — no population to compare to');--console.log('✅ ratings are percentile-based with a floor (7 mic stats)');----// ── Filter: behaviour, not vocabulary (the platform does not censor language) ──--for (const bad of ['kys', 'kill yourself', 'go drink bleach', 'I will kill you', "i'm gonna stab him", 'his home address is 12 elm st', 'her phone number is 555 0199', 'child porn link', 'cp links in bio', 'doxxed him already'])-- assert.ok(arena._isBannedText(bad), `banned (behaviour): ${bad}`);--for (const ok of ['that beaner coon nonsense', 'stop being a faggot', 'what up my niggas', "he's a retard", 'such a whore', 'pussy move', 'we got raped in that match', 'this shell script is hell', 'kill the lights', 'his address is wrong on the form? no, the URL address'])-- assert.ok(!arena._isBannedText(ok), `allowed (speech): ${ok}`);--console.log('✅ filter blocks threats / minors / doxxing only — slurs and profanity are speech');----// ── End-to-end on a temp DB (no AI configured → fallback personas/quotes, no LLM calls) ──--const mk = async (username, key) => {-- await db.createUser({ username, email: `${username}@x`, password_hash: 'x', display_name: username.toUpperCase(), stream_key: key });-- return (await db.get('SELECT id FROM users WHERE username = ?', [username])).id;--};--const u1 = await mk('alpha', 'a'.repeat(32)), u2 = await mk('bravo', 'b'.repeat(32)), u3 = await mk('charlie', 'c'.repeat(32)), idle = await mk('idle', 'd'.repeat(32)), mute = await mk('mute', 'e'.repeat(32));--for (const uid of [u1, u2, u3, idle, mute]) await db.ensureChannel(uid);--const stream = async (uid, hours, daysAgo) => {-- const id = Number((await db.createStream({ user_id: uid, title: `${uid} stream`, category: 'irl', protocol: 'rtmp' })).lastInsertRowid);-- await db.run(`UPDATE streams SET is_live = 0, started_at = datetime('now', ?), ended_at = datetime('now', ?), duration_seconds = ?, peak_viewers = 999 WHERE id = ?`,-- [`-${daysAgo} days`, `-${daysAgo} days`, Math.round(hours * 3600), id]);-- return id;--};--const s1 = await stream(u1, 6, 2); await stream(u1, 4, 5);--const s2 = await stream(u2, 10, 1);--const s3 = await stream(u3, 1, 9);--const sIdle = await stream(idle, 50, 120); // too old → not on the roster even with speech--await stream(mute, 50, 1); // streams a lot, never transcribed → not on the roster----// Transcript for alpha: 40 lines over the 6 h stream, some hype, all linked to a VOD.--const lines = [];--for (let i = 0; i < 40; i++) {-- const t = i % 4 === 0 ? `Let's go chat, that was insane, no way we just did that number ${i}!` : `Okay so here is the current situation with the setup number ${i}, we are rebuilding it live.`;-- lines.push({ stream_id: s1, user_id: u1, vod_id: 901, kind: 'speech', start_sec: i * 60, end_sec: i * 60 + 20, text: t, label: null, confidence: 0.9 });--}--await db.addTimelineEvents(lines);--await db.addTimelineEvents([{ stream_id: s1, user_id: u1, vod_id: 901, kind: 'sound', start_sec: 30, end_sec: 33, text: null, label: 'Laughter', confidence: 0.8 }, { stream_id: s1, user_id: u1, vod_id: 901, kind: 'sound', start_sec: 90, end_sec: 93, text: null, label: 'Rock music', confidence: 0.8 }]);--// bravo + charlie: a few lines each so they are on the roster; idle: lines, but 120 days old.--await db.addTimelineEvents(Array.from({ length: 8 }, (_, i) => ({ stream_id: s2, user_id: u2, vod_id: null, kind: 'speech', start_sec: i * 100, end_sec: i * 100 + 5, text: `bravo line ${i} hello chat`, label: null, confidence: 0.9 })));--await db.addTimelineEvents(Array.from({ length: 5 }, (_, i) => ({ stream_id: s3, user_id: u3, vod_id: null, kind: 'speech', start_sec: i * 100, end_sec: i * 100 + 5, text: `charlie line ${i}`, label: null, confidence: 0.9 })));--await db.addTimelineEvents(Array.from({ length: 3 }, (_, i) => ({ stream_id: sIdle, user_id: idle, vod_id: null, kind: 'speech', start_sec: i * 100, end_sec: i * 100 + 5, text: `idle line ${i}`, label: null, confidence: 0.9 })));--await db.run(`UPDATE stream_timeline_events SET created_at = datetime('now', '-120 days') WHERE user_id = ?`, [idle]);----// Offensive lines are fair game for quotes; threats / doxxing never are.--await db.addTimelineEvents([-- { stream_id: s1, user_id: u1, vod_id: 901, kind: 'speech', start_sec: 5000, end_sec: 5010, text: "Let's go chat, you absolute clowns, that was insane you retards!", label: null, confidence: 0.9 },-- { stream_id: s1, user_id: u1, vod_id: 901, kind: 'speech', start_sec: 5100, end_sec: 5110, text: 'No way, chat, that guy should kill himself, kys buddy, insane!', label: null, confidence: 0.9 },-- { stream_id: s1, user_id: u1, vod_id: 901, kind: 'speech', start_sec: 5200, end_sec: 5210, text: 'Holy, that was insane, his home address is 12 elm street lol!', label: null, confidence: 0.9 },--]);--const cands = await arena._quoteCandidates(u1);--assert.ok(cands.length >= 20, 'has candidates');--assert.ok(cands.some(c => c.text.includes('retards')), 'offensive vocabulary is not filtered out of quotes');--assert.ok(cands.every(c => !/kys|home address/.test(c.text)), 'threats / doxxing never become quotes');--console.log('✅ quote candidates: speech stays, behaviour goes');----const voice = await arena._voiceStatsFor(u1, '-90 days');--assert.strictEqual(voice.has_data, true);--assert.strictEqual(voice.lines, 43);--assert.ok(voice.talk_ratio_pct > 0 && voice.talk_ratio_pct <= 100, `talk ratio: ${voice.talk_ratio_pct}`);--assert.ok(voice.hype_hits >= 10, `hype hits counted: ${voice.hype_hits}`);--assert.strictEqual(voice.laughs, 1);--assert.deepStrictEqual(voice.top_sounds.map(s => s.label).sort(), ['Laughter', 'Rock music']);--assert.strictEqual((await arena._voiceStatsFor(mute, '-90 days')).has_data, false, 'no transcript → no data');--console.log('✅ voice stats from the transcription timeline');----const fighters = await arena.listFighters();--assert.deepStrictEqual(fighters.map(f => f.user.username).sort(), ['alpha', 'bravo', 'charlie'], `roster = the heard, not the loud-in-numbers: ${fighters.map(f => f.user.username)}`);--assert.strictEqual(fighters[0].user.username, 'alpha', 'the biggest mouth tops the ladder');--assert.strictEqual(fighters[0].rank, 1);--assert.strictEqual(fighters[0].ratings.stamina, 99, 'the most speech tops STAMINA');--assert.ok(fighters[0].persona.fighter_name, 'fallback persona has a name even with AI off');--assert.strictEqual(fighters[0].persona_is_fallback, true);--assert.strictEqual(fighters[0].voice.has_data, true);--assert.deepStrictEqual(fighters[0].record, { wins: 0, losses: 0, draws: 0 }, 'beef record starts empty');--assert.deepStrictEqual(fighters[0].level, { level: 1, xp: 0 }, 'Trash Level starts at 1');--assert.strictEqual(fighters[0].ratings.talk_bonus, 0, 'no mouth bonus without XP');--assert.ok(fighters[0].mic && fighters[0].mic.moments === 0, 'mic stats ride along');--assert.ok(!('tier' in fighters[0]), 'tiers are gone');--console.log('✅ roster from transcripts only; idle and never-transcribed streamers excluded');----{-- const card = await arena.getFighter('alpha');-- assert.strictEqual(card.user.username, 'alpha');-- assert.strictEqual(card.rank, 1);-- assert.ok(card.raw.hours >= 9.9 && card.raw.hours <= 10.1, `hours aggregated: ${card.raw.hours}`);-- assert.ok(!('followers' in card.raw) && !('peak_viewers' in card.raw), 'no audience numbers on the card');-- assert.strictEqual(card.image_url, null);-- assert.strictEqual(card.image_generation, 'off', 'no AI → no image generation');-- assert.strictEqual(card.quotes, null, 'nothing is generated on view any more — no quotes, no persona, no portrait');-- assert.ok(card.persona_is_fallback, 'persona is the stats-only fallback (never generated)');-- assert.deepStrictEqual(card.beefs, []);-- assert.strictEqual(card.level.level, 1);-- const off = await arena.getFighter('mute');-- assert.strictEqual(off.not_on_roster, true);-- assert.ok(/mic/.test(off.reason));-- console.log('✅ fighter card (mic-only, nothing generated on view)');---- const detail = await arena.getStatDetail(u1, 'mouth');-- assert.strictEqual(detail.position, 1);-- assert.strictEqual(detail.series.length, 2, 'one point per stream in the window');-- assert.ok(detail.series.some(p => p.value > 0), 'mouth series = % of stream talking');-- assert.strictEqual(detail.top[0].user.username, 'alpha');-- assert.ok(detail.voice, 'voice detail rides along for voice stats');-- assert.strictEqual(await arena.getStatDetail(u1, 'hype'), null, 'audience stats do not exist');-- assert.strictEqual(await arena.getStatDetail(u1, 'nope'), null);-- console.log('✅ stat drill-down');---- assert.deepStrictEqual(await arena.liveFighters(), [], 'nobody live');-- const st = await arena.status();-- assert.strictEqual(st.mode, 'battle-cam');-- assert.strictEqual(st.roster, 3);-- assert.strictEqual(st.with_voice_data, 3);-- assert.strictEqual(st.beefs_open, 0);-- assert.strictEqual(st.listener, 15000, 'listener tick advertised');-- console.log('✅ live fighters + status');---- console.log('\n✅ All Arena roster tests passed');-- process.exit(0);--}--})().catch(err => { console.error(err); process.exit(1); });@@@ -1,32 +1,32 @@{"service": "live",-- "release": "81dc337dc7ae261ccc498f5894241f1f0e9cc52f",-- "recorded_at": "2026-10-08T18:28:45.009Z",++ "release": "f0fed5be6d1b25cfcabb1a3f3e2db347dfea88fb",++ "recorded_at": "2026-10-19T23:30:31.918Z","note": "N-1 fixture: written by `npm run n-1:record`, replayed by test/n-1.test.js","manifest": {"service": "live",-- "release": "81dc337dc7ae",-- "released_at": "2026-10-08T17:34:39.000Z",-- "booted_at": "2026-10-08T18:28:40.404Z",-- "contracts_version": "0.112.0",++ "release": "f0fed5be6d1b",++ "released_at": "2026-10-19T18:39:28.000Z",++ "booted_at": "2026-10-19T23:30:27.637Z",++ "contracts_version": "0.203.0","packages": {-- "openvibe-shared": "2.13.3",-- "openvibe-sdk": "0.35.1"++ "openvibe-shared": "4.4.0",++ "openvibe-sdk": "0.50.1"},"min_client_release": null,"mixed_version_window_hours": 24,"components": {"styles": {"kind": "style",-- "version": "835d7a90863a"++ "version": "77a2bdaee060"},"shell": {"kind": "script",-- "version": "2fb9f8dc218c"++ "version": "4ff89f6a092a"},"server": {"kind": "server",-- "version": "6175874d3913"++ "version": "73e6552a2862"}},"assets": {@@@ -41,39 +41,34 @@"integrity": "sha384-+UNgBYuMRLHmTl0hBPmZI+91u+ud0yHLElwmd/4UrYgHoLHIYVElOwsOQ4G8UdWB"},"/css/context-cards.css": {-- "url": "/css/context-cards.css?v=f2468014839c",++ "url": "/css/context-cards.css?v=07823a1b5eb5","component": "styles",-- "integrity": "sha384-1onIAYDdYHytODnOQ1NtrwgWw4IEBgCclvW5Fy+Rvvfc4Pq+63uuCdAdNnU9SgKu"++ "integrity": "sha384-Di4RzkxhOu9xj1RMyoQdPFeAbJQHuaU6pg8/bxcxaJI4YG9T9hX9lytrqcrulDN/"},"/css/cosmetics.css": {-- "url": "/css/cosmetics.css?v=73e2adf188e1",++ "url": "/css/cosmetics.css?v=5d33ae6cb97b","component": "styles",-- "integrity": "sha384-JY2WKT34BDXzoB746LvRQn1fo28LK2El8c6SFRFaS3K9Pe+PAUXFz0zvkw7qdie9"-- },-- "/css/features/arena.css": {-- "url": "/css/features/arena.css?v=6993aaa22df5",-- "component": "styles",-- "integrity": "sha384-EVAZL6dcfj1jLiFB5BWHcV+Iv0dTz8V5oDaJDoQROVMAV2EGMKS+tfK7Sh+rQDq/"++ "integrity": "sha384-MaYWbMCigFzwPGNlDtRg1XnF0Ak6FFOa5hqfe1wmfuRSUtqukbcqDOelKvO1RqH2"},"/css/features/broadcast.css": {-- "url": "/css/features/broadcast.css?v=84d5e7bcf87a",++ "url": "/css/features/broadcast.css?v=c27820784531","component": "styles",-- "integrity": "sha384-bBBAa4R6vi7XZrg0pVl3y/RTieFRKBzfqN7N8S2f24xJp0kxufeX13Mlm/3EW9uE"++ "integrity": "sha384-RHi7esMaA13XfUINvU/2iDobTP+ntLrei0C0C/u03zXUAN/0fKVM3Lkg0BZXnUY4"},"/css/features/channel.css": {-- "url": "/css/features/channel.css?v=63269f14afd5",++ "url": "/css/features/channel.css?v=f41f8d4b31d8","component": "styles",-- "integrity": "sha384-jS8cgLWimI0gjaN/MtfZywtJ1U05pcGFtitrSjyRaPVxJgrWFT4s50I3KLb7R+04"++ "integrity": "sha384-Lu0yUHFrYfSOV0Eu2ChVrPC5NGCWHqKojTlpZdW22N1NHqeoj145OSHfZkgEIJNx"},"/css/features/chatpage.css": {-- "url": "/css/features/chatpage.css?v=c5c63e94503f",++ "url": "/css/features/chatpage.css?v=1b2ebcebcbca","component": "styles",-- "integrity": "sha384-2CnY63wmfX6sJ3AgpNab5FRmu8HOKt2HZs3T2SLP+ghsZzmKLbtL0vsu6HgegwSQ"++ "integrity": "sha384-TdtKvLlB6yr2QMBBD5EAYebE40Y3xXvOkr9Min86pR/jqRNTfQVXxuqqois5soM+"},"/css/features/content.css": {-- "url": "/css/features/content.css?v=95d237faf73a",++ "url": "/css/features/content.css?v=ec113a44dd7a","component": "styles",-- "integrity": "sha384-iAF+LsoCNaMS6BM2YUZu95mDl8+jgtko6D++4d70Hs0d8xH8UceNDPINIfISe3uT"++ "integrity": "sha384-mm5kmkmu8IXqBCHEke9GTvb1FO1Oncn77FhTR124WDryVYQSw3d3JDKr5UVlgqE6"},"/css/features/dashboard.css": {"url": "/css/features/dashboard.css?v=90998f175a26",@@@ -91,29 +86,29 @@"integrity": "sha384-qxjHx5W7yoUfDcK9M8DcVBKQX2YcR+sHHeGwpKNYrg6zM9tjKFsrcXfB4FvHcuUC"},"/css/features/home.css": {-- "url": "/css/features/home.css?v=1da8ab368854",++ "url": "/css/features/home.css?v=faa8fa066557","component": "styles",-- "integrity": "sha384-BcVmXyr+pzzoReBggf3M/VzsHepfNVJG9ImV8+IbOx7EcPPNOixHFwzk2mRZ2e0/"++ "integrity": "sha384-jc4nJLKlKXq0lKUUQPKdiR0pxiqd4E57o6O/BRMQLVGPPrWDYaG4OIxnn5uayOmi"},-- "/css/features/media.css": {-- "url": "/css/features/media.css?v=dea567ff26e8",++ "/css/features/rundown.css": {++ "url": "/css/features/rundown.css?v=05f1d8c7b95c","component": "styles",-- "integrity": "sha384-5fg7EMKpaBjMyM4mYM/iM+HPpZnUUaCd1FmVEcauKoN9QWfbFWVhNIhrb2oA5RiK"++ "integrity": "sha384-dFsbWdy82ur5QrZ5M8TeJzcgYwMD6FdkLXub1odJKKlSWZuFHiGxDSRpTAYUNZpr"},"/css/features/search.css": {-- "url": "/css/features/search.css?v=59f2f133271c",++ "url": "/css/features/search.css?v=9ddd7160c54c","component": "styles",-- "integrity": "sha384-RJYUmnxzvBcKyiQ5i2JiaKeDt7n04ZxD5fi5T1PT66PR0Yawvd0FsvFuTdBY8djz"++ "integrity": "sha384-vBjTPlCMue0BX3XhCDjqRgbDgk+Rcmn2jUiBqfUYQXG+iH3VQ+GAvww3kICowLG8"},"/css/features/stats.css": {-- "url": "/css/features/stats.css?v=7676bfc9ec0d",++ "url": "/css/features/stats.css?v=0a1c1c6e4799","component": "styles",-- "integrity": "sha384-BO+i8mr3s9siQE0a/XSNd+MvZfi8GDyhIjQFf+9zC5CUXx5DvmoY71wRT0jUr42X"++ "integrity": "sha384-tiqr/ezmfQZcqEoCOfoC49n9LBNfeEbY7pCmRNS26+C5+OB3vw3vCv6hBhRsPC/m"},"/css/home-fx.css": {-- "url": "/css/home-fx.css?v=8f135af68e76",++ "url": "/css/home-fx.css?v=e663894c29e3","component": "styles",-- "integrity": "sha384-nQYM/f49re/MHydZjlV6hNxCLoyuxQzovB+ui4pxQVAho58GRcwfrsC8ruxcmq4Y"++ "integrity": "sha384-U8Kz03V5TkIF/hK5+EHWbMVu0usivPRna4gmXlTtX3AElTtjbkDrivAYyINJJ81J"},"/css/home-tour.css": {"url": "/css/home-tour.css?v=3f1293159aff",@@@ -121,9 +116,9 @@"integrity": "sha384-amhdltUcvPHMlKbKgAysH1oVa5LXmXYvie7lrfnBB7+hnqpcSiFHfLUDcwnSznho"},"/css/i18n-star.css": {-- "url": "/css/i18n-star.css?v=365567ee3c2e",++ "url": "/css/i18n-star.css?v=c5b0b2261c10","component": "styles",-- "integrity": "sha384-ywRuEe6z23e3VtwROEu2yNUbrnajlBMv5M3vyZjZ1hfe1Dli6pGYLa8uu3z71Mo0"++ "integrity": "sha384-reKMOVv9o0oQvhVOJLVJ2LIdqIAzmJK+8zknG6bitXFH6FYKLqHIXiFmyJgonCw5"},"/css/media-player.css": {"url": "/css/media-player.css?v=7b7fdf4b15e5",@@@ -131,24 +126,24 @@"integrity": "sha384-OsZjCMedAayAH/g66rIZS8/cND69xI8/Qx1ci04sfY8gbrncRjXT0WCDg/d+BYg9"},"/css/messenger.css": {-- "url": "/css/messenger.css?v=8d3312c1f857",++ "url": "/css/messenger.css?v=b546de06782d","component": "styles",-- "integrity": "sha384-2+sE2r3ITTgGbYnYQ2TD5atavSwm9i2LyvxGPkKYYRn07kizleHqQYAYrrZVW5cr"++ "integrity": "sha384-Gum2h1hkiOCHYGzL51Vkg18dykFocQYUKV5nj+em30urSOUV7DTkH0/qBZuRofIS"},"/css/ov-guide.css": {-- "url": "/css/ov-guide.css?v=5eb601c01a03",++ "url": "/css/ov-guide.css?v=51e7b691671e","component": "styles",-- "integrity": "sha384-06D5yA90IAi0imrONhtPHVkzAe00RqUQM0w96tT5EUXx1lwLjn6P2f0UTruHZCss"++ "integrity": "sha384-NgjftNH1wYEitNkPJWxk+J1rSLOeFiLzrujIouib3JjKTS0Z4Zv9HC8e4/IuI1IJ"},"/css/recap.css": {-- "url": "/css/recap.css?v=3da2cd3feae6",++ "url": "/css/recap.css?v=cda4dc52e8a4","component": "styles",-- "integrity": "sha384-iVDuJckwXLQPfhSFXjJKmyIIXM+XbRccOWefcHh1vChYrlfwFWAgWXinI4iihWor"++ "integrity": "sha384-nnjFlUlLlwwU2ATKsy4JqdwUBltQRQrNx0U1rDfBnsCrWAlj2r91DGgH2GgXQZpZ"},"/css/style.css": {-- "url": "/css/style.css?v=57784f84656e",++ "url": "/css/style.css?v=a417682204dd","component": "styles",-- "integrity": "sha384-2pazK2Qkcku6o4h6cZfkVZ8BiU3xw4wzvR0bVbXr7teT/+VuJoub8kY8OKTSkaBb"++ "integrity": "sha384-qbBXlMdrglg4suN/qZtHyXjWNnPemV+vfe/yACjNZ9YhL1bfPUaM7RvwIwz1wWhF"},"/css/vendor/fontawesome-6.5.1.min.css": {"url": "/css/vendor/fontawesome-6.5.1.min.css?v=d6c948f648fd",@@@ -464,388 +459,6 @@"public/js/app-channel.js"]},-- {-- "method": "GET",-- "path": "/api/arena/beefs",-- "auth": "anon",-- "status": 200,-- "kind": "json",-- "reads": {-- "open": "array",-- "resolved": "array"-- },-- "from": [-- "public/js/arena.js"-- ]-- },-- {-- "method": "GET",-- "path": "/api/arena/beefs",-- "auth": "user",-- "status": 200,-- "kind": "json",-- "reads": {-- "open": "array",-- "resolved": "array"-- },-- "from": [-- "public/js/arena.js"-- ]-- },-- {-- "method": "GET",-- "path": "/api/arena/beefs/1",-- "auth": "anon",-- "status": 404,-- "kind": "json",-- "from": [-- "public/js/arena.js"-- ]-- },-- {-- "method": "GET",-- "path": "/api/arena/beefs/1",-- "auth": "user",-- "status": 404,-- "kind": "json",-- "from": [-- "public/js/arena.js"-- ]-- },-- {-- "method": "GET",-- "path": "/api/arena/console/n1star",-- "auth": "anon",-- "status": 200,-- "kind": "json",-- "reads": {-- "fighter": "object",-- "fighter.user": "object",-- "fighter.user.id": "number",-- "fighter.user.username": "string",-- "fighter.user.display_name": "string",-- "fighter.user.avatar_url": "null",-- "fighter.user.profile_color": "string",-- "fighter.user.bio": "string",-- "fighter.fighter_name": "string",-- "fighter.rank": "null",-- "fighter.image_url": "null",-- "fighter.live": "boolean",-- "fighter.level": "number",-- "on_roster": "boolean",-- "live": "boolean",-- "stream": "object",-- "stream.id": "number",-- "stream.title": "string",-- "stream.started_at": "string",-- "stream.viewer_count": "number",-- "transcribed": "boolean",-- "listener": "object",-- "listener.listening": "boolean",-- "level": "object",-- "level.level": "number",-- "level.xp": "number",-- "level.xp_into_level": "number",-- "level.xp_per_level": "number",-- "level.mic_moments": "number",-- "level.beef_hits": "number",-- "level.recent_xp": "number",-- "level.best_line": "null",-- "mic": "object",-- "mic.moments": "number",-- "mic.beef_hits": "number",-- "mic.bangers": "number",-- "mic.avg_quality": "number",-- "mic.targeted": "number",-- "mic.answered": "number",-- "mic.wins": "number",-- "mic.losses": "number",-- "mic.speech_minutes": "number",-- "open_beefs": "array",-- "hot_mic": "array",-- "recent_moments": "array"-- },-- "from": [-- "public/js/arena.js"-- ]-- },-- {-- "method": "GET",-- "path": "/api/arena/console/n1star",-- "auth": "user",-- "status": 200,-- "kind": "json",-- "reads": {-- "fighter": "object",-- "fighter.user": "object",-- "fighter.user.id": "number",-- "fighter.user.username": "string",-- "fighter.user.display_name": "string",-- "fighter.user.avatar_url": "null",-- "fighter.user.profile_color": "string",-- "fighter.user.bio": "string",-- "fighter.fighter_name": "string",-- "fighter.rank": "null",-- "fighter.image_url": "null",-- "fighter.live": "boolean",-- "fighter.level": "number",-- "on_roster": "boolean",-- "live": "boolean",-- "stream": "object",-- "stream.id": "number",-- "stream.title": "string",-- "stream.started_at": "string",-- "stream.viewer_count": "number",-- "transcribed": "boolean",-- "listener": "object",-- "listener.listening": "boolean",-- "level": "object",-- "level.level": "number",-- "level.xp": "number",-- "level.xp_into_level": "number",-- "level.xp_per_level": "number",-- "level.mic_moments": "number",-- "level.beef_hits": "number",-- "level.recent_xp": "number",-- "level.best_line": "null",-- "mic": "object",-- "mic.moments": "number",-- "mic.beef_hits": "number",-- "mic.bangers": "number",-- "mic.avg_quality": "number",-- "mic.targeted": "number",-- "mic.answered": "number",-- "mic.wins": "number",-- "mic.losses": "number",-- "mic.speech_minutes": "number",-- "open_beefs": "array",-- "hot_mic": "array",-- "recent_moments": "array"-- },-- "from": [-- "public/js/arena.js"-- ]-- },-- {-- "method": "GET",-- "path": "/api/arena/feed?limit=40",-- "auth": "anon",-- "status": 200,-- "kind": "json",-- "reads": {-- "feed": "array",-- "ai": "boolean"-- },-- "from": [-- "public/js/arena.js"-- ]-- },-- {-- "method": "GET",-- "path": "/api/arena/feed?limit=40",-- "auth": "user",-- "status": 200,-- "kind": "json",-- "reads": {-- "feed": "array",-- "ai": "boolean"-- },-- "from": [-- "public/js/arena.js"-- ]-- },-- {-- "method": "GET",-- "path": "/api/arena/fighters",-- "auth": "anon",-- "status": 200,-- "kind": "json",-- "reads": {-- "fighters": "array",-- "ai": "boolean",-- "stats": "array",-- "stats[]": "string",-- "stat_meta": "object",-- "stat_meta.heat": "object",-- "stat_meta.heat.label": "string",-- "stat_meta.heat.desc": "string",-- "stat_meta.aim": "object",-- "stat_meta.aim.label": "string",-- "stat_meta.aim.desc": "string",-- "stat_meta.kills": "object",-- "stat_meta.kills.label": "string",-- "stat_meta.kills.desc": "string",-- "stat_meta.mouth": "object",-- "stat_meta.mouth.label": "string",-- "stat_meta.mouth.desc": "string",-- "stat_meta.clapback": "object",-- "stat_meta.clapback.label": "string",-- "stat_meta.clapback.desc": "string",-- "stat_meta.stamina": "object",-- "stat_meta.stamina.label": "string",-- "stat_meta.stamina.desc": "string",-- "stat_meta.pace": "object",-- "stat_meta.pace.label": "string",-- "stat_meta.pace.desc": "string"-- },-- "from": [-- "public/js/arena.js"-- ]-- },-- {-- "method": "GET",-- "path": "/api/arena/fighters",-- "auth": "user",-- "status": 200,-- "kind": "json",-- "reads": {-- "fighters": "array",-- "ai": "boolean",-- "stats": "array",-- "stats[]": "string",-- "stat_meta": "object",-- "stat_meta.heat": "object",-- "stat_meta.heat.label": "string",-- "stat_meta.heat.desc": "string",-- "stat_meta.aim": "object",-- "stat_meta.aim.label": "string",-- "stat_meta.aim.desc": "string",-- "stat_meta.kills": "object",-- "stat_meta.kills.label": "string",-- "stat_meta.kills.desc": "string",-- "stat_meta.mouth": "object",-- "stat_meta.mouth.label": "string",-- "stat_meta.mouth.desc": "string",-- "stat_meta.clapback": "object",-- "stat_meta.clapback.label": "string",-- "stat_meta.clapback.desc": "string",-- "stat_meta.stamina": "object",-- "stat_meta.stamina.label": "string",-- "stat_meta.stamina.desc": "string",-- "stat_meta.pace": "object",-- "stat_meta.pace.label": "string",-- "stat_meta.pace.desc": "string"-- },-- "from": [-- "public/js/arena.js"-- ]-- },-- {-- "method": "GET",-- "path": "/api/arena/fighters/n1star",-- "auth": "anon",-- "status": 200,-- "kind": "json",-- "reads": {-- "user": "object",-- "user.id": "number",-- "user.username": "string",-- "user.display_name": "string",-- "user.avatar_url": "null",-- "user.profile_color": "string",-- "user.bio": "string",-- "not_on_roster": "boolean",-- "reason": "string"-- },-- "from": [-- "public/js/arena.js"-- ]-- },-- {-- "method": "GET",-- "path": "/api/arena/fighters/n1star",-- "auth": "user",-- "status": 200,-- "kind": "json",-- "reads": {-- "user": "object",-- "user.id": "number",-- "user.username": "string",-- "user.display_name": "string",-- "user.avatar_url": "null",-- "user.profile_color": "string",-- "user.bio": "string",-- "not_on_roster": "boolean",-- "reason": "string"-- },-- "from": [-- "public/js/arena.js"-- ]-- },-- {-- "method": "GET",-- "path": "/api/arena/fighters/n1star/stat/1",-- "auth": "anon",-- "status": 404,-- "kind": "json",-- "from": [-- "public/js/arena.js"-- ]-- },-- {-- "method": "GET",-- "path": "/api/arena/fighters/n1star/stat/1",-- "auth": "user",-- "status": 404,-- "kind": "json",-- "from": [-- "public/js/arena.js"-- ]-- },-- {-- "method": "GET",-- "path": "/api/arena/live",-- "auth": "anon",-- "status": 200,-- "kind": "json",-- "reads": {-- "live": "array"-- },-- "from": [-- "public/js/arena.js"-- ]-- },-- {-- "method": "GET",-- "path": "/api/arena/live",-- "auth": "user",-- "status": 200,-- "kind": "json",-- "reads": {-- "live": "array"-- },-- "from": [-- "public/js/arena.js"-- ]-- },-- {-- "method": "GET",-- "path": "/api/arena/me",-- "auth": "anon",-- "status": 401,-- "kind": "json",-- "from": [-- "public/js/arena.js"-- ]-- },-- {-- "method": "GET",-- "path": "/api/arena/me",-- "auth": "user",-- "status": 200,-- "kind": "json",-- "reads": {-- "on_roster": "boolean",-- "fighter": "null",-- "beefs": "array",-- "on_clock": "array",-- "moments": "array",-- "live": "boolean"-- },-- "from": [-- "public/js/arena.js"-- ]-- },{"method": "GET","path": "/api/auth/avatar/history",@@@ -936,7 +549,6 @@"user.capabilities.can_manage_channels": "boolean","user.capabilities.view_ip_info": "boolean","user.capabilities.is_owner": "boolean",-- "user.openvibe_coins_balance": "number","user.stream_key": "string","capabilities": "object","capabilities.admin_panel": "boolean",@@@ -1321,7 +933,6 @@"status": 502,"kind": "json","from": [-- "public/js/app-media.js","public/js/stream-player.js"]},@@@ -1332,7 +943,6 @@"status": 502,"kind": "json","from": [-- "public/js/app-media.js","public/js/stream-player.js"]},@@@ -1394,7 +1004,7 @@"status": 200,"kind": "json","reads": {-- "balance": "number"++ "balance": "null"},"from": ["public/js/app.js",@@@ -1522,235 +1132,6 @@"public/js/opencoins.js"]},-- {-- "method": "GET",-- "path": "/api/comments/1/1",-- "auth": "anon",-- "status": 400,-- "kind": "json",-- "from": [-- "public/js/app-media.js"-- ]-- },-- {-- "method": "GET",-- "path": "/api/comments/1/1",-- "auth": "user",-- "status": 400,-- "kind": "json",-- "from": [-- "public/js/app-media.js"-- ]-- },-- {-- "method": "GET",-- "path": "/api/controls/configs",-- "auth": "anon",-- "status": 401,-- "kind": "json",-- "from": [-- "public/js/broadcast-workspace.js",-- "public/js/broadcast.js",-- "public/js/dashboard.js"-- ]-- },-- {-- "method": "GET",-- "path": "/api/controls/configs",-- "auth": "user",-- "status": 200,-- "kind": "json",-- "reads": {-- "configs": "array"-- },-- "from": [-- "public/js/broadcast-workspace.js",-- "public/js/broadcast.js",-- "public/js/dashboard.js"-- ]-- },-- {-- "method": "GET",-- "path": "/api/controls/configs/1",-- "auth": "anon",-- "status": 401,-- "kind": "json",-- "from": [-- "public/js/dashboard.js"-- ]-- },-- {-- "method": "GET",-- "path": "/api/controls/configs/1",-- "auth": "user",-- "status": 404,-- "kind": "json",-- "from": [-- "public/js/dashboard.js"-- ]-- },-- {-- "method": "GET",-- "path": "/api/controls/configs/1/bridge-script?type=generic",-- "auth": "anon",-- "status": 401,-- "kind": "json",-- "from": [-- "public/js/dashboard.js"-- ]-- },-- {-- "method": "GET",-- "path": "/api/controls/configs/1/bridge-script?type=generic",-- "auth": "user",-- "status": 404,-- "kind": "json",-- "from": [-- "public/js/dashboard.js"-- ]-- },-- {-- "method": "GET",-- "path": "/api/controls/presets",-- "auth": "anon",-- "status": 200,-- "kind": "json",-- "reads": {-- "controls": "array",-- "settings": "object"-- },-- "from": [-- "public/js/dashboard.js"-- ]-- },-- {-- "method": "GET",-- "path": "/api/controls/presets",-- "auth": "user",-- "status": 200,-- "kind": "json",-- "reads": {-- "controls": "array",-- "settings": "object"-- },-- "from": [-- "public/js/dashboard.js"-- ]-- },-- {-- "method": "GET",-- "path": "/api/controls/settings/channel",-- "auth": "anon",-- "status": 401,-- "kind": "json",-- "from": [-- "public/js/broadcast.js",-- "public/js/dashboard.js"-- ]-- },-- {-- "method": "GET",-- "path": "/api/controls/settings/channel",-- "auth": "user",-- "status": 200,-- "kind": "json",-- "reads": {-- "control_mode": "string",-- "anon_controls_enabled": "boolean",-- "control_rate_limit_ms": "number",-- "video_click_enabled": "boolean",-- "video_click_rate_limit_ms": "number",-- "active_control_config_id": "null"-- },-- "from": [-- "public/js/broadcast.js",-- "public/js/dashboard.js"-- ]-- },-- {-- "method": "GET",-- "path": "/api/controls/whitelist",-- "auth": "anon",-- "status": 401,-- "kind": "json",-- "from": [-- "public/js/dashboard.js"-- ]-- },-- {-- "method": "GET",-- "path": "/api/controls/whitelist",-- "auth": "user",-- "status": 200,-- "kind": "json",-- "reads": {-- "whitelist": "array"-- },-- "from": [-- "public/js/dashboard.js"-- ]-- },-- {-- "method": "GET",-- "path": "/api/controls/1",-- "auth": "anon",-- "status": 200,-- "kind": "json",-- "reads": {-- "controls": "array",-- "settings": "object",-- "settings.video_click_enabled": "boolean",-- "settings.control_mode": "string",-- "settings.anon_controls_enabled": "boolean",-- "settings.control_rate_limit_ms": "number",-- "settings.video_click_rate_limit_ms": "number"-- },-- "from": [-- "public/js/controls.js"-- ]-- },-- {-- "method": "GET",-- "path": "/api/controls/1",-- "auth": "user",-- "status": 200,-- "kind": "json",-- "reads": {-- "controls": "array",-- "settings": "object",-- "settings.video_click_enabled": "boolean",-- "settings.control_mode": "string",-- "settings.anon_controls_enabled": "boolean",-- "settings.control_rate_limit_ms": "number",-- "settings.video_click_rate_limit_ms": "number"-- },-- "from": [-- "public/js/controls.js"-- ]-- },-- {-- "method": "GET",-- "path": "/api/controls/1/config",-- "auth": "anon",-- "status": 401,-- "kind": "json",-- "from": [-- "public/js/broadcast.js"-- ]-- },-- {-- "method": "GET",-- "path": "/api/controls/1/config",-- "auth": "user",-- "status": 200,-- "kind": "json",-- "reads": {-- "control_config_id": "null"-- },-- "from": [-- "public/js/broadcast.js"-- ]-- },{"method": "GET","path": "/api/cosmetics/catalog",@@@ -1909,34 +1290,6 @@"public/js/app-home.js"]},-- {-- "method": "GET",-- "path": "/api/funds/balance",-- "auth": "anon",-- "status": 401,-- "kind": "json",-- "from": [-- "public/js/app.js",-- "public/js/dashboard.js",-- "public/js/vibes.js"-- ]-- },-- {-- "method": "GET",-- "path": "/api/funds/balance",-- "auth": "user",-- "status": 200,-- "kind": "json",-- "reads": {-- "balance": "number",-- "cashout_balance": "number"-- },-- "from": [-- "public/js/app.js",-- "public/js/dashboard.js",-- "public/js/vibes.js"-- ]-- },{"method": "GET","path": "/api/funds/goals/manage/mine",@@@ -1986,29 +1339,6 @@"public/js/app-channel.js"]},-- {-- "method": "GET",-- "path": "/api/funds/history?limit=100",-- "auth": "anon",-- "status": 401,-- "kind": "json",-- "from": [-- "public/js/dashboard.js"-- ]-- },-- {-- "method": "GET",-- "path": "/api/funds/history?limit=100",-- "auth": "user",-- "status": 200,-- "kind": "json",-- "reads": {-- "transactions": "array"-- },-- "from": [-- "public/js/dashboard.js"-- ]-- },{"method": "GET","path": "/api/home/hero",@@@ -2020,12 +1350,12 @@"stats.viewersNow": "number","stats.hoursWatched": "number","stats.vibesTipped": "number",-- "stats.activeSubs": "number",++ "stats.activeSubs": "null","stats.pointsEarned": "number","stats.pointsSpent": "number","stats.redemptions": "number","stats.supporters": "number",-- "stats.vibesBought": "number",++ "stats.vibesBought": "null","stats.goalsActive": "number","stats.goalsReached": "number","stats.vods": "null",@@@ -2116,16 +1446,8 @@"stats.recent.redemptions.w": "number","stats.recent.redemptions.m": "number","stats.recent.redemptions.pw": "number",-- "stats.recent.vibesBought": "object",-- "stats.recent.vibesBought.d": "number",-- "stats.recent.vibesBought.w": "number",-- "stats.recent.vibesBought.m": "number",-- "stats.recent.vibesBought.pw": "number",-- "stats.recent.subs": "object",-- "stats.recent.subs.d": "number",-- "stats.recent.subs.w": "number",-- "stats.recent.subs.m": "number",-- "stats.recent.subs.pw": "number",++ "stats.recent.vibesBought": "null",++ "stats.recent.subs": "null","stats.recent.follows": "object","stats.recent.follows.d": "number","stats.recent.follows.w": "number",@@@ -2169,12 +1491,12 @@"stats.viewersNow": "number","stats.hoursWatched": "number","stats.vibesTipped": "number",-- "stats.activeSubs": "number",++ "stats.activeSubs": "null","stats.pointsEarned": "number","stats.pointsSpent": "number","stats.redemptions": "number","stats.supporters": "number",-- "stats.vibesBought": "number",++ "stats.vibesBought": "null","stats.goalsActive": "number","stats.goalsReached": "number","stats.vods": "null",@@@ -2265,16 +1587,8 @@"stats.recent.redemptions.w": "number","stats.recent.redemptions.m": "number","stats.recent.redemptions.pw": "number",-- "stats.recent.vibesBought": "object",-- "stats.recent.vibesBought.d": "number",-- "stats.recent.vibesBought.w": "number",-- "stats.recent.vibesBought.m": "number",-- "stats.recent.vibesBought.pw": "number",-- "stats.recent.subs": "object",-- "stats.recent.subs.d": "number",-- "stats.recent.subs.w": "number",-- "stats.recent.subs.m": "number",-- "stats.recent.subs.pw": "number",++ "stats.recent.vibesBought": "null",++ "stats.recent.subs": "null","stats.recent.follows": "object","stats.recent.follows.d": "number","stats.recent.follows.w": "number",@@@ -2556,7 +1870,6 @@"live_stream.duration_seconds": "number","live_stream.created_at": "string","live_stream.call_mode": "null",-- "live_stream.control_config_id": "null","live_stream.managed_stream_id": "number","live_stream.ai_overview": "null","live_stream.ai_overview_short": "null",@@@ -2634,7 +1947,6 @@"live_stream.duration_seconds": "number","live_stream.created_at": "string","live_stream.call_mode": "null",-- "live_stream.control_config_id": "null","live_stream.managed_stream_id": "number","live_stream.ai_overview": "null","live_stream.ai_overview_short": "null",@@@ -2801,97 +2113,30 @@"status": 403,"kind": "json","from": [-- "public/js/chat.js"-- ]-- },-- {-- "method": "GET",-- "path": "/api/news/my-settings",-- "auth": "anon",-- "status": 401,-- "kind": "json",-- "from": [-- "public/js/broadcast.js"-- ]-- },-- {-- "method": "GET",-- "path": "/api/news/my-settings",-- "auth": "user",-- "status": 200,-- "kind": "json",-- "reads": {-- "enabled": "null"-- },-- "from": [-- "public/js/broadcast.js"-- ]-- },-- {-- "method": "GET",-- "path": "/api/onvif/cameras",-- "auth": "anon",-- "status": 401,-- "kind": "json",-- "from": [-- "public/js/onvif-dashboard.js"-- ]-- },-- {-- "method": "GET",-- "path": "/api/onvif/cameras",-- "auth": "user",-- "status": 200,-- "kind": "json",-- "reads": {-- "cameras": "array"-- },-- "from": [-- "public/js/onvif-dashboard.js"-- ]-- },-- {-- "method": "GET",-- "path": "/api/onvif/cameras/1",-- "auth": "anon",-- "status": 401,-- "kind": "json",-- "from": [-- "public/js/controls.js",-- "public/js/onvif-dashboard.js"-- ]-- },-- {-- "method": "GET",-- "path": "/api/onvif/cameras/1",-- "auth": "user",-- "status": 404,-- "kind": "json",-- "from": [-- "public/js/controls.js",-- "public/js/onvif-dashboard.js"++ "public/js/chat.js"]},{"method": "GET",-- "path": "/api/onvif/cameras/1/presets",++ "path": "/api/news/my-settings","auth": "anon","status": 401,"kind": "json","from": [-- "public/js/controls.js",-- "public/js/onvif-dashboard.js"++ "public/js/broadcast.js"]},{"method": "GET",-- "path": "/api/onvif/cameras/1/presets",++ "path": "/api/news/my-settings","auth": "user",-- "status": 404,++ "status": 200,"kind": "json",++ "reads": {++ "enabled": "null"++ },"from": [-- "public/js/controls.js",-- "public/js/onvif-dashboard.js"++ "public/js/broadcast.js"]},{@@@ -3034,6 +2279,36 @@"public/js/app-home.js"]},++ {++ "method": "GET",++ "path": "/api/payments/channel/1/elsewhere",++ "auth": "anon",++ "status": 200,++ "kind": "json",++ "reads": {++ "memberships": "null",++ "support_links": "null",++ "tips_url": "null"++ },++ "from": [++ "public/js/channel-support.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/payments/channel/1/elsewhere",++ "auth": "user",++ "status": 200,++ "kind": "json",++ "reads": {++ "memberships": "null",++ "support_links": "null",++ "tips_url": "null"++ },++ "from": [++ "public/js/channel-support.js"++ ]++ },{"method": "GET","path": "/api/payments/channel/n1star",@@@ -3376,6 +2651,216 @@"public/js/guides-live.js"]},++ {++ "method": "GET",++ "path": "/api/rundown",++ "auth": "anon",++ "status": 200,++ "kind": "json",++ "reads": {++ "on_air": "array",++ "on_air[]": "object",++ "on_air[].stream_id": "number",++ "on_air[].user": "object",++ "on_air[].user.id": "number",++ "on_air[].user.username": "string",++ "on_air[].user.display_name": "string",++ "on_air[].user.avatar_url": "null",++ "on_air[].user.profile_color": "string",++ "on_air[].stream_title": "string",++ "on_air[].talking_about": "null",++ "on_air[].topic": "null",++ "lead": "null",++ "streams": "array",++ "streams_next": "null",++ "trending": "array",++ "voices": "array",++ "latest": "array",++ "next": "null",++ "counts": "object",++ "counts.topics": "number",++ "counts.episodes": "number",++ "counts.takes": "number"++ },++ "from": [++ "public/js/rundown.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/rundown",++ "auth": "user",++ "status": 200,++ "kind": "json",++ "reads": {++ "on_air": "array",++ "on_air[]": "object",++ "on_air[].stream_id": "number",++ "on_air[].user": "object",++ "on_air[].user.id": "number",++ "on_air[].user.username": "string",++ "on_air[].user.display_name": "string",++ "on_air[].user.avatar_url": "null",++ "on_air[].user.profile_color": "string",++ "on_air[].stream_title": "string",++ "on_air[].talking_about": "null",++ "on_air[].topic": "null",++ "lead": "null",++ "streams": "array",++ "streams_next": "null",++ "trending": "array",++ "voices": "array",++ "latest": "array",++ "next": "null",++ "counts": "object",++ "counts.topics": "number",++ "counts.episodes": "number",++ "counts.takes": "number"++ },++ "from": [++ "public/js/rundown.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/rundown/e/1",++ "auth": "anon",++ "status": 404,++ "kind": "json",++ "from": [++ "public/js/rundown.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/rundown/e/1",++ "auth": "user",++ "status": 404,++ "kind": "json",++ "from": [++ "public/js/rundown.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/rundown/s/n1star",++ "auth": "anon",++ "status": 200,++ "kind": "json",++ "reads": {++ "user": "object",++ "user.id": "number",++ "user.username": "string",++ "user.display_name": "string",++ "user.avatar_url": "null",++ "user.profile_color": "string",++ "opted_out": "boolean",++ "episodes": "array",++ "topics": "array",++ "lines": "array",++ "crossovers": "array"++ },++ "from": [++ "public/js/rundown.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/rundown/s/n1star",++ "auth": "user",++ "status": 200,++ "kind": "json",++ "reads": {++ "user": "object",++ "user.id": "number",++ "user.username": "string",++ "user.display_name": "string",++ "user.avatar_url": "null",++ "user.profile_color": "string",++ "opted_out": "boolean",++ "episodes": "array",++ "topics": "array",++ "lines": "array",++ "crossovers": "array"++ },++ "from": [++ "public/js/rundown.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/rundown/streams?before=",++ "auth": "anon",++ "status": 200,++ "kind": "json",++ "reads": {++ "streams": "array",++ "next": "null"++ },++ "from": [++ "public/js/rundown.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/rundown/streams?before=",++ "auth": "user",++ "status": 200,++ "kind": "json",++ "reads": {++ "streams": "array",++ "next": "null"++ },++ "from": [++ "public/js/rundown.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/rundown/t/n1star",++ "auth": "anon",++ "status": 404,++ "kind": "json",++ "from": [++ "public/js/rundown.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/rundown/t/n1star",++ "auth": "user",++ "status": 404,++ "kind": "json",++ "from": [++ "public/js/rundown.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/rundown/topics?sort=&kind=${kind}",++ "auth": "anon",++ "status": 200,++ "kind": "json",++ "reads": {++ "topics": "array"++ },++ "from": [++ "public/js/rundown.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/rundown/topics?sort=&kind=${kind}",++ "auth": "user",++ "status": 200,++ "kind": "json",++ "reads": {++ "topics": "array"++ },++ "from": [++ "public/js/rundown.js"++ ]++ },{"method": "GET","path": "/api/search/suggest",@@@ -3516,7 +3001,6 @@"streams[].duration_seconds": "number","streams[].created_at": "string","streams[].call_mode": "null",-- "streams[].control_config_id": "null","streams[].managed_stream_id": "number","streams[].ai_overview": "null","streams[].ai_overview_short": "null",@@@ -3546,12 +3030,6 @@"streams[].channel.updated_at": "string","streams[].channel.weather_detail": "string","streams[].channel.weather_show_location": "number",-- "streams[].channel.control_mode": "string",-- "streams[].channel.anon_controls_enabled": "number",-- "streams[].channel.control_rate_limit_ms": "number",-- "streams[].channel.active_control_config_id": "null",-- "streams[].channel.video_click_enabled": "number",-- "streams[].channel.video_click_rate_limit_ms": "number","streams[].channel.offline_screen_type": "string","streams[].channel.offline_screen_url": "null","streams[].channel.offline_html": "null",@@@ -3597,7 +3075,6 @@"streams[].duration_seconds": "number","streams[].created_at": "string","streams[].call_mode": "null",-- "streams[].control_config_id": "null","streams[].managed_stream_id": "number","streams[].ai_overview": "null","streams[].ai_overview_short": "null",@@@ -3627,12 +3104,6 @@"streams[].channel.updated_at": "string","streams[].channel.weather_detail": "string","streams[].channel.weather_show_location": "number",-- "streams[].channel.control_mode": "string",-- "streams[].channel.anon_controls_enabled": "number",-- "streams[].channel.control_rate_limit_ms": "number",-- "streams[].channel.active_control_config_id": "null",-- "streams[].channel.video_click_enabled": "number",-- "streams[].channel.video_click_rate_limit_ms": "number","streams[].channel.offline_screen_type": "string","streams[].channel.offline_screen_url": "null","streams[].channel.offline_html": "null",@@@ -3708,12 +3179,6 @@"weather_zip": "null","weather_detail": "string","weather_show_location": "number",-- "control_mode": "string",-- "anon_controls_enabled": "number",-- "control_rate_limit_ms": "number",-- "active_control_config_id": "null",-- "video_click_enabled": "number",-- "video_click_rate_limit_ms": "number","offline_screen_type": "string","offline_screen_url": "null","offline_html": "null",@@@ -3758,12 +3223,6 @@"channel.updated_at": "string","channel.weather_detail": "string","channel.weather_show_location": "number",-- "channel.control_mode": "string",-- "channel.anon_controls_enabled": "number",-- "channel.control_rate_limit_ms": "number",-- "channel.active_control_config_id": "null",-- "channel.video_click_enabled": "number",-- "channel.video_click_rate_limit_ms": "number","channel.offline_screen_type": "string","channel.offline_screen_url": "null","channel.offline_html": "null",@@@ -3774,6 +3233,17 @@"channel.ai_category": "null","channel.ai_derivation_enabled": "number","channel.social_links": "array",++ "channel.social_links[]": "object",++ "channel.social_links[].kind": "string",++ "channel.social_links[].url": "string",++ "channel.social_links[].handle": "string",++ "channel.social_links[].label": "string",++ "channel.social_links[].preview": "boolean",++ "channel.social_links[].name": "string",++ "channel.social_links[].icon": "string",++ "channel.social_links[].color": "string",++ "channel.social_links[].auto": "boolean",++ "channel.social_links[].hidden": "boolean","channel.username": "string","channel.display_name": "string","channel.avatar_url": "null",@@@ -3814,7 +3284,6 @@"stream.duration_seconds": "number","stream.created_at": "string","stream.call_mode": "null",-- "stream.control_config_id": "null","stream.managed_stream_id": "number","stream.ai_overview": "null","stream.ai_overview_short": "null",@@@ -3851,7 +3320,6 @@"streams[].duration_seconds": "number","streams[].created_at": "string","streams[].call_mode": "null",-- "streams[].control_config_id": "null","streams[].managed_stream_id": "number","streams[].ai_overview": "null","streams[].ai_overview_short": "null",@@@ -3879,8 +3347,6 @@"managed_streams[].tags": "string","managed_streams[].protocol": "string","managed_streams[].is_nsfw": "number",-- "managed_streams[].control_config_id": "null",-- "managed_streams[].sort_order": "number","managed_streams[].created_at": "string","managed_streams[].updated_at": "string","managed_streams[].streaming_method": "string",@@@ -3949,12 +3415,6 @@"channel.updated_at": "string","channel.weather_detail": "string","channel.weather_show_location": "number",-- "channel.control_mode": "string",-- "channel.anon_controls_enabled": "number",-- "channel.control_rate_limit_ms": "number",-- "channel.active_control_config_id": "null",-- "channel.video_click_enabled": "number",-- "channel.video_click_rate_limit_ms": "number","channel.offline_screen_type": "string","channel.offline_screen_url": "null","channel.offline_html": "null",@@@ -3965,6 +3425,17 @@"channel.ai_category": "null","channel.ai_derivation_enabled": "number","channel.social_links": "array",++ "channel.social_links[]": "object",++ "channel.social_links[].kind": "string",++ "channel.social_links[].url": "string",++ "channel.social_links[].handle": "string",++ "channel.social_links[].label": "string",++ "channel.social_links[].preview": "boolean",++ "channel.social_links[].name": "string",++ "channel.social_links[].icon": "string",++ "channel.social_links[].color": "string",++ "channel.social_links[].auto": "boolean",++ "channel.social_links[].hidden": "boolean","channel.username": "string","channel.display_name": "string","channel.avatar_url": "null",@@@ -3981,6 +3452,17 @@"channel.viewer_can_edit_about": "boolean","channel.social_links_editor": "object","channel.social_links_editor.connected": "array",++ "channel.social_links_editor.connected[]": "object",++ "channel.social_links_editor.connected[].kind": "string",++ "channel.social_links_editor.connected[].url": "string",++ "channel.social_links_editor.connected[].handle": "string",++ "channel.social_links_editor.connected[].label": "string",++ "channel.social_links_editor.connected[].preview": "boolean",++ "channel.social_links_editor.connected[].name": "string",++ "channel.social_links_editor.connected[].icon": "string",++ "channel.social_links_editor.connected[].color": "string",++ "channel.social_links_editor.connected[].auto": "boolean",++ "channel.social_links_editor.connected[].hidden": "boolean","channel.social_links_editor.restreams_without_link": "array","channel.social_links_editor.hidden_auto": "array","channel.ai_overview": "null",@@@ -4009,7 +3491,6 @@"stream.duration_seconds": "number","stream.created_at": "string","stream.call_mode": "null",-- "stream.control_config_id": "null","stream.managed_stream_id": "number","stream.ai_overview": "null","stream.ai_overview_short": "null",@@@ -4046,7 +3527,6 @@"streams[].duration_seconds": "number","streams[].created_at": "string","streams[].call_mode": "null",-- "streams[].control_config_id": "null","streams[].managed_stream_id": "number","streams[].ai_overview": "null","streams[].ai_overview_short": "null",@@@ -4074,8 +3554,6 @@"managed_streams[].tags": "string","managed_streams[].protocol": "string","managed_streams[].is_nsfw": "number",-- "managed_streams[].control_config_id": "null",-- "managed_streams[].sort_order": "number","managed_streams[].created_at": "string","managed_streams[].updated_at": "string","managed_streams[].streaming_method": "string",@@@ -4144,12 +3622,6 @@"channel.updated_at": "string","channel.weather_detail": "string","channel.weather_show_location": "number",-- "channel.control_mode": "string",-- "channel.anon_controls_enabled": "number",-- "channel.control_rate_limit_ms": "number",-- "channel.active_control_config_id": "null",-- "channel.video_click_enabled": "number",-- "channel.video_click_rate_limit_ms": "number","channel.offline_screen_type": "string","channel.offline_screen_url": "null","channel.offline_html": "null",@@@ -4160,6 +3632,17 @@"channel.ai_category": "null","channel.ai_derivation_enabled": "number","channel.social_links": "array",++ "channel.social_links[]": "object",++ "channel.social_links[].kind": "string",++ "channel.social_links[].url": "string",++ "channel.social_links[].handle": "string",++ "channel.social_links[].label": "string",++ "channel.social_links[].preview": "boolean",++ "channel.social_links[].name": "string",++ "channel.social_links[].icon": "string",++ "channel.social_links[].color": "string",++ "channel.social_links[].auto": "boolean",++ "channel.social_links[].hidden": "boolean","channel.username": "string","channel.display_name": "string","channel.avatar_url": "null",@@@ -4200,7 +3683,6 @@"stream.duration_seconds": "number","stream.created_at": "string","stream.call_mode": "null",-- "stream.control_config_id": "null","stream.managed_stream_id": "number","stream.ai_overview": "null","stream.ai_overview_short": "null",@@@ -4237,7 +3719,6 @@"streams[].duration_seconds": "number","streams[].created_at": "string","streams[].call_mode": "null",-- "streams[].control_config_id": "null","streams[].managed_stream_id": "number","streams[].ai_overview": "null","streams[].ai_overview_short": "null",@@@ -4265,8 +3746,6 @@"managed_streams[].tags": "string","managed_streams[].protocol": "string","managed_streams[].is_nsfw": "number",-- "managed_streams[].control_config_id": "null",-- "managed_streams[].sort_order": "number","managed_streams[].created_at": "string","managed_streams[].updated_at": "string","managed_streams[].streaming_method": "string",@@@ -4335,12 +3814,6 @@"channel.updated_at": "string","channel.weather_detail": "string","channel.weather_show_location": "number",-- "channel.control_mode": "string",-- "channel.anon_controls_enabled": "number",-- "channel.control_rate_limit_ms": "number",-- "channel.active_control_config_id": "null",-- "channel.video_click_enabled": "number",-- "channel.video_click_rate_limit_ms": "number","channel.offline_screen_type": "string","channel.offline_screen_url": "null","channel.offline_html": "null",@@@ -4351,6 +3824,17 @@"channel.ai_category": "null","channel.ai_derivation_enabled": "number","channel.social_links": "array",++ "channel.social_links[]": "object",++ "channel.social_links[].kind": "string",++ "channel.social_links[].url": "string",++ "channel.social_links[].handle": "string",++ "channel.social_links[].label": "string",++ "channel.social_links[].preview": "boolean",++ "channel.social_links[].name": "string",++ "channel.social_links[].icon": "string",++ "channel.social_links[].color": "string",++ "channel.social_links[].auto": "boolean",++ "channel.social_links[].hidden": "boolean","channel.username": "string","channel.display_name": "string","channel.avatar_url": "null",@@@ -4367,6 +3851,17 @@"channel.viewer_can_edit_about": "boolean","channel.social_links_editor": "object","channel.social_links_editor.connected": "array",++ "channel.social_links_editor.connected[]": "object",++ "channel.social_links_editor.connected[].kind": "string",++ "channel.social_links_editor.connected[].url": "string",++ "channel.social_links_editor.connected[].handle": "string",++ "channel.social_links_editor.connected[].label": "string",++ "channel.social_links_editor.connected[].preview": "boolean",++ "channel.social_links_editor.connected[].name": "string",++ "channel.social_links_editor.connected[].icon": "string",++ "channel.social_links_editor.connected[].color": "string",++ "channel.social_links_editor.connected[].auto": "boolean",++ "channel.social_links_editor.connected[].hidden": "boolean","channel.social_links_editor.restreams_without_link": "array","channel.social_links_editor.hidden_auto": "array","channel.ai_overview": "null",@@@ -4395,7 +3890,6 @@"stream.duration_seconds": "number","stream.created_at": "string","stream.call_mode": "null",-- "stream.control_config_id": "null","stream.managed_stream_id": "number","stream.ai_overview": "null","stream.ai_overview_short": "null",@@@ -4432,7 +3926,6 @@"streams[].duration_seconds": "number","streams[].created_at": "string","streams[].call_mode": "null",-- "streams[].control_config_id": "null","streams[].managed_stream_id": "number","streams[].ai_overview": "null","streams[].ai_overview_short": "null",@@@ -4460,8 +3953,6 @@"managed_streams[].tags": "string","managed_streams[].protocol": "string","managed_streams[].is_nsfw": "number",-- "managed_streams[].control_config_id": "null",-- "managed_streams[].sort_order": "number","managed_streams[].created_at": "string","managed_streams[].updated_at": "string","managed_streams[].streaming_method": "string",@@@ -4530,12 +4021,6 @@"channel.updated_at": "string","channel.weather_detail": "string","channel.weather_show_location": "number",-- "channel.control_mode": "string",-- "channel.anon_controls_enabled": "number",-- "channel.control_rate_limit_ms": "number",-- "channel.active_control_config_id": "null",-- "channel.video_click_enabled": "number",-- "channel.video_click_rate_limit_ms": "number","channel.offline_screen_type": "string","channel.offline_screen_url": "null","channel.offline_html": "null",@@@ -4546,6 +4031,17 @@"channel.ai_category": "null","channel.ai_derivation_enabled": "number","channel.social_links": "array",++ "channel.social_links[]": "object",++ "channel.social_links[].kind": "string",++ "channel.social_links[].url": "string",++ "channel.social_links[].handle": "string",++ "channel.social_links[].label": "string",++ "channel.social_links[].preview": "boolean",++ "channel.social_links[].name": "string",++ "channel.social_links[].icon": "string",++ "channel.social_links[].color": "string",++ "channel.social_links[].auto": "boolean",++ "channel.social_links[].hidden": "boolean","channel.username": "string","channel.display_name": "string","channel.avatar_url": "null",@@@ -4586,7 +4082,6 @@"stream.duration_seconds": "number","stream.created_at": "string","stream.call_mode": "null",-- "stream.control_config_id": "null","stream.managed_stream_id": "number","stream.ai_overview": "null","stream.ai_overview_short": "null",@@@ -4623,7 +4118,6 @@"streams[].duration_seconds": "number","streams[].created_at": "string","streams[].call_mode": "null",-- "streams[].control_config_id": "null","streams[].managed_stream_id": "number","streams[].ai_overview": "null","streams[].ai_overview_short": "null",@@@ -4651,8 +4145,6 @@"managed_streams[].tags": "string","managed_streams[].protocol": "string","managed_streams[].is_nsfw": "number",-- "managed_streams[].control_config_id": "null",-- "managed_streams[].sort_order": "number","managed_streams[].created_at": "string","managed_streams[].updated_at": "string","managed_streams[].streaming_method": "string",@@@ -4721,12 +4213,6 @@"channel.updated_at": "string","channel.weather_detail": "string","channel.weather_show_location": "number",-- "channel.control_mode": "string",-- "channel.anon_controls_enabled": "number",-- "channel.control_rate_limit_ms": "number",-- "channel.active_control_config_id": "null",-- "channel.video_click_enabled": "number",-- "channel.video_click_rate_limit_ms": "number","channel.offline_screen_type": "string","channel.offline_screen_url": "null","channel.offline_html": "null",@@@ -4737,6 +4223,17 @@"channel.ai_category": "null","channel.ai_derivation_enabled": "number","channel.social_links": "array",++ "channel.social_links[]": "object",++ "channel.social_links[].kind": "string",++ "channel.social_links[].url": "string",++ "channel.social_links[].handle": "string",++ "channel.social_links[].label": "string",++ "channel.social_links[].preview": "boolean",++ "channel.social_links[].name": "string",++ "channel.social_links[].icon": "string",++ "channel.social_links[].color": "string",++ "channel.social_links[].auto": "boolean",++ "channel.social_links[].hidden": "boolean","channel.username": "string","channel.display_name": "string","channel.avatar_url": "null",@@@ -4753,6 +4250,17 @@"channel.viewer_can_edit_about": "boolean","channel.social_links_editor": "object","channel.social_links_editor.connected": "array",++ "channel.social_links_editor.connected[]": "object",++ "channel.social_links_editor.connected[].kind": "string",++ "channel.social_links_editor.connected[].url": "string",++ "channel.social_links_editor.connected[].handle": "string",++ "channel.social_links_editor.connected[].label": "string",++ "channel.social_links_editor.connected[].preview": "boolean",++ "channel.social_links_editor.connected[].name": "string",++ "channel.social_links_editor.connected[].icon": "string",++ "channel.social_links_editor.connected[].color": "string",++ "channel.social_links_editor.connected[].auto": "boolean",++ "channel.social_links_editor.connected[].hidden": "boolean","channel.social_links_editor.restreams_without_link": "array","channel.social_links_editor.hidden_auto": "array","channel.ai_overview": "null",@@@ -4781,7 +4289,6 @@"stream.duration_seconds": "number","stream.created_at": "string","stream.call_mode": "null",-- "stream.control_config_id": "null","stream.managed_stream_id": "number","stream.ai_overview": "null","stream.ai_overview_short": "null",@@@ -4818,7 +4325,6 @@"streams[].duration_seconds": "number","streams[].created_at": "string","streams[].call_mode": "null",-- "streams[].control_config_id": "null","streams[].managed_stream_id": "number","streams[].ai_overview": "null","streams[].ai_overview_short": "null",@@@ -4846,8 +4352,6 @@"managed_streams[].tags": "string","managed_streams[].protocol": "string","managed_streams[].is_nsfw": "number",-- "managed_streams[].control_config_id": "null",-- "managed_streams[].sort_order": "number","managed_streams[].created_at": "string","managed_streams[].updated_at": "string","managed_streams[].streaming_method": "string",@@@ -4920,7 +4424,6 @@"streams[].duration_seconds": "number","streams[].created_at": "string","streams[].call_mode": "null",-- "streams[].control_config_id": "null","streams[].managed_stream_id": "number","streams[].ai_overview": "null","streams[].ai_overview_short": "null",@@@ -4971,7 +4474,6 @@"streams[].duration_seconds": "number","streams[].created_at": "string","streams[].call_mode": "null",-- "streams[].control_config_id": "null","streams[].managed_stream_id": "number","streams[].ai_overview": "null","streams[].ai_overview_short": "null",@@@ -5018,12 +4520,6 @@"channel.updated_at": "string","channel.weather_detail": "string","channel.weather_show_location": "number",-- "channel.control_mode": "string",-- "channel.anon_controls_enabled": "number",-- "channel.control_rate_limit_ms": "number",-- "channel.active_control_config_id": "null",-- "channel.video_click_enabled": "number",-- "channel.video_click_rate_limit_ms": "number","channel.offline_screen_type": "string","channel.offline_screen_url": "null","channel.offline_html": "null",@@@ -5034,6 +4530,17 @@"channel.ai_category": "null","channel.ai_derivation_enabled": "number","channel.social_links": "array",++ "channel.social_links[]": "object",++ "channel.social_links[].kind": "string",++ "channel.social_links[].url": "string",++ "channel.social_links[].handle": "string",++ "channel.social_links[].label": "string",++ "channel.social_links[].preview": "boolean",++ "channel.social_links[].name": "string",++ "channel.social_links[].icon": "string",++ "channel.social_links[].color": "string",++ "channel.social_links[].auto": "boolean",++ "channel.social_links[].hidden": "boolean","channel.username": "string","channel.display_name": "string","channel.avatar_url": "null",@@@ -5074,7 +4581,6 @@"stream.duration_seconds": "number","stream.created_at": "string","stream.call_mode": "null",-- "stream.control_config_id": "null","stream.managed_stream_id": "number","stream.ai_overview": "null","stream.ai_overview_short": "null",@@@ -5111,7 +4617,6 @@"streams[].duration_seconds": "number","streams[].created_at": "string","streams[].call_mode": "null",-- "streams[].control_config_id": "null","streams[].managed_stream_id": "number","streams[].ai_overview": "null","streams[].ai_overview_short": "null",@@@ -5139,8 +4644,6 @@"managed_streams[].tags": "string","managed_streams[].protocol": "string","managed_streams[].is_nsfw": "number",-- "managed_streams[].control_config_id": "null",-- "managed_streams[].sort_order": "number","managed_streams[].created_at": "string","managed_streams[].updated_at": "string","managed_streams[].streaming_method": "string",@@@ -5209,12 +4712,6 @@"channel.updated_at": "string","channel.weather_detail": "string","channel.weather_show_location": "number",-- "channel.control_mode": "string",-- "channel.anon_controls_enabled": "number",-- "channel.control_rate_limit_ms": "number",-- "channel.active_control_config_id": "null",-- "channel.video_click_enabled": "number",-- "channel.video_click_rate_limit_ms": "number","channel.offline_screen_type": "string","channel.offline_screen_url": "null","channel.offline_html": "null",@@@ -5225,6 +4722,17 @@"channel.ai_category": "null","channel.ai_derivation_enabled": "number","channel.social_links": "array",++ "channel.social_links[]": "object",++ "channel.social_links[].kind": "string",++ "channel.social_links[].url": "string",++ "channel.social_links[].handle": "string",++ "channel.social_links[].label": "string",++ "channel.social_links[].preview": "boolean",++ "channel.social_links[].name": "string",++ "channel.social_links[].icon": "string",++ "channel.social_links[].color": "string",++ "channel.social_links[].auto": "boolean",++ "channel.social_links[].hidden": "boolean","channel.username": "string","channel.display_name": "string","channel.avatar_url": "null",@@@ -5241,6 +4749,17 @@"channel.viewer_can_edit_about": "boolean","channel.social_links_editor": "object","channel.social_links_editor.connected": "array",++ "channel.social_links_editor.connected[]": "object",++ "channel.social_links_editor.connected[].kind": "string",++ "channel.social_links_editor.connected[].url": "string",++ "channel.social_links_editor.connected[].handle": "string",++ "channel.social_links_editor.connected[].label": "string",++ "channel.social_links_editor.connected[].preview": "boolean",++ "channel.social_links_editor.connected[].name": "string",++ "channel.social_links_editor.connected[].icon": "string",++ "channel.social_links_editor.connected[].color": "string",++ "channel.social_links_editor.connected[].auto": "boolean",++ "channel.social_links_editor.connected[].hidden": "boolean","channel.social_links_editor.restreams_without_link": "array","channel.social_links_editor.hidden_auto": "array","channel.ai_overview": "null",@@@ -5269,7 +4788,6 @@"stream.duration_seconds": "number","stream.created_at": "string","stream.call_mode": "null",-- "stream.control_config_id": "null","stream.managed_stream_id": "number","stream.ai_overview": "null","stream.ai_overview_short": "null",@@@ -5306,7 +4824,6 @@"streams[].duration_seconds": "number","streams[].created_at": "string","streams[].call_mode": "null",-- "streams[].control_config_id": "null","streams[].managed_stream_id": "number","streams[].ai_overview": "null","streams[].ai_overview_short": "null",@@@ -5334,8 +4851,6 @@"managed_streams[].tags": "string","managed_streams[].protocol": "string","managed_streams[].is_nsfw": "number",-- "managed_streams[].control_config_id": "null",-- "managed_streams[].sort_order": "number","managed_streams[].created_at": "string","managed_streams[].updated_at": "string","managed_streams[].streaming_method": "string",@@@ -5404,12 +4919,6 @@"channel.updated_at": "string","channel.weather_detail": "string","channel.weather_show_location": "number",-- "channel.control_mode": "string",-- "channel.anon_controls_enabled": "number",-- "channel.control_rate_limit_ms": "number",-- "channel.active_control_config_id": "null",-- "channel.video_click_enabled": "number",-- "channel.video_click_rate_limit_ms": "number","channel.offline_screen_type": "string","channel.offline_screen_url": "null","channel.offline_html": "null",@@@ -5420,6 +4929,17 @@"channel.ai_category": "null","channel.ai_derivation_enabled": "number","channel.social_links": "array",++ "channel.social_links[]": "object",++ "channel.social_links[].kind": "string",++ "channel.social_links[].url": "string",++ "channel.social_links[].handle": "string",++ "channel.social_links[].label": "string",++ "channel.social_links[].preview": "boolean",++ "channel.social_links[].name": "string",++ "channel.social_links[].icon": "string",++ "channel.social_links[].color": "string",++ "channel.social_links[].auto": "boolean",++ "channel.social_links[].hidden": "boolean","channel.username": "string","channel.display_name": "string","channel.avatar_url": "null",@@@ -5460,7 +4980,6 @@"stream.duration_seconds": "number","stream.created_at": "string","stream.call_mode": "null",-- "stream.control_config_id": "null","stream.managed_stream_id": "number","stream.ai_overview": "null","stream.ai_overview_short": "null",@@@ -5497,7 +5016,6 @@"streams[].duration_seconds": "number","streams[].created_at": "string","streams[].call_mode": "null",-- "streams[].control_config_id": "null","streams[].managed_stream_id": "number","streams[].ai_overview": "null","streams[].ai_overview_short": "null",@@@ -5525,8 +5043,6 @@"managed_streams[].tags": "string","managed_streams[].protocol": "string","managed_streams[].is_nsfw": "number",-- "managed_streams[].control_config_id": "null",-- "managed_streams[].sort_order": "number","managed_streams[].created_at": "string","managed_streams[].updated_at": "string","managed_streams[].streaming_method": "string",@@@ -5595,12 +5111,6 @@"channel.updated_at": "string","channel.weather_detail": "string","channel.weather_show_location": "number",-- "channel.control_mode": "string",-- "channel.anon_controls_enabled": "number",-- "channel.control_rate_limit_ms": "number",-- "channel.active_control_config_id": "null",-- "channel.video_click_enabled": "number",-- "channel.video_click_rate_limit_ms": "number","channel.offline_screen_type": "string","channel.offline_screen_url": "null","channel.offline_html": "null",@@@ -5611,6 +5121,17 @@"channel.ai_category": "null","channel.ai_derivation_enabled": "number","channel.social_links": "array",++ "channel.social_links[]": "object",++ "channel.social_links[].kind": "string",++ "channel.social_links[].url": "string",++ "channel.social_links[].handle": "string",++ "channel.social_links[].label": "string",++ "channel.social_links[].preview": "boolean",++ "channel.social_links[].name": "string",++ "channel.social_links[].icon": "string",++ "channel.social_links[].color": "string",++ "channel.social_links[].auto": "boolean",++ "channel.social_links[].hidden": "boolean","channel.username": "string","channel.display_name": "string","channel.avatar_url": "null",@@@ -5627,6 +5148,17 @@"channel.viewer_can_edit_about": "boolean","channel.social_links_editor": "object","channel.social_links_editor.connected": "array",++ "channel.social_links_editor.connected[]": "object",++ "channel.social_links_editor.connected[].kind": "string",++ "channel.social_links_editor.connected[].url": "string",++ "channel.social_links_editor.connected[].handle": "string",++ "channel.social_links_editor.connected[].label": "string",++ "channel.social_links_editor.connected[].preview": "boolean",++ "channel.social_links_editor.connected[].name": "string",++ "channel.social_links_editor.connected[].icon": "string",++ "channel.social_links_editor.connected[].color": "string",++ "channel.social_links_editor.connected[].auto": "boolean",++ "channel.social_links_editor.connected[].hidden": "boolean","channel.social_links_editor.restreams_without_link": "array","channel.social_links_editor.hidden_auto": "array","channel.ai_overview": "null",@@@ -5655,7 +5187,6 @@"stream.duration_seconds": "number","stream.created_at": "string","stream.call_mode": "null",-- "stream.control_config_id": "null","stream.managed_stream_id": "number","stream.ai_overview": "null","stream.ai_overview_short": "null",@@@ -5692,7 +5223,6 @@"streams[].duration_seconds": "number","streams[].created_at": "string","streams[].call_mode": "null",-- "streams[].control_config_id": "null","streams[].managed_stream_id": "number","streams[].ai_overview": "null","streams[].ai_overview_short": "null",@@@ -5720,8 +5250,6 @@"managed_streams[].tags": "string","managed_streams[].protocol": "string","managed_streams[].is_nsfw": "number",-- "managed_streams[].control_config_id": "null",-- "managed_streams[].sort_order": "number","managed_streams[].created_at": "string","managed_streams[].updated_at": "string","managed_streams[].streaming_method": "string",@@@ -5938,8 +5466,6 @@"managed_streams[].protocol": "string","managed_streams[].stream_key": "string","managed_streams[].is_nsfw": "number",-- "managed_streams[].control_config_id": "null",-- "managed_streams[].sort_order": "number","managed_streams[].created_at": "string","managed_streams[].updated_at": "string","managed_streams[].broadcast_settings": "string",@@@ -5996,8 +5522,6 @@"managed_stream.tags": "string","managed_stream.protocol": "string","managed_stream.is_nsfw": "number",-- "managed_stream.control_config_id": "null",-- "managed_stream.sort_order": "number","managed_stream.created_at": "string","managed_stream.updated_at": "string","managed_stream.broadcast_settings": "string",@@@ -6104,7 +5628,6 @@"streams[].duration_seconds": "number","streams[].created_at": "string","streams[].call_mode": "null",-- "streams[].control_config_id": "null","streams[].managed_stream_id": "number","streams[].ai_overview": "null","streams[].ai_overview_short": "null",@@@ -6181,6 +5704,12 @@"streamers[].managed_streams[].protocol": "string","streamers[].managed_streams[].last_live_at": "string","streamers[].managed_streams[].vod_thumbnail": "null",++ "streamers[].managed_streams[].is_live": "boolean",++ "streamers[].managed_streams[].live_started_at": "string",++ "streamers[].live": "object",++ "streamers[].live.started_at": "string",++ "streamers[].live.viewer_count": "number",++ "streamers[].live.title": "string","total": "number","limit": "number","offset": "number",@@@ -6215,6 +5744,12 @@"streamers[].managed_streams[].protocol": "string","streamers[].managed_streams[].last_live_at": "string","streamers[].managed_streams[].vod_thumbnail": "null",++ "streamers[].managed_streams[].is_live": "boolean",++ "streamers[].managed_streams[].live_started_at": "string",++ "streamers[].live": "object",++ "streamers[].live.started_at": "string",++ "streamers[].live.viewer_count": "number",++ "streamers[].live.title": "string","total": "number","limit": "number","offset": "number",@@@ -6265,52 +5800,6 @@"public/js/guides-live.js"]},-- {-- "method": "GET",-- "path": "/api/streams/voice-channels",-- "auth": "anon",-- "status": 200,-- "kind": "json",-- "reads": {-- "channels": "array",-- "channels[]": "object",-- "channels[].id": "string",-- "channels[].name": "string",-- "channels[].mode": "string",-- "channels[].createdBy": "null",-- "channels[].streamId": "null",-- "channels[].permanent": "boolean",-- "channels[].maxParticipants": "number",-- "channels[].participantCount": "number",-- "channels[].participants": "array"-- },-- "from": [-- "public/js/voice-channels.js"-- ]-- },-- {-- "method": "GET",-- "path": "/api/streams/voice-channels",-- "auth": "user",-- "status": 200,-- "kind": "json",-- "reads": {-- "channels": "array",-- "channels[]": "object",-- "channels[].id": "string",-- "channels[].name": "string",-- "channels[].mode": "string",-- "channels[].createdBy": "null",-- "channels[].streamId": "null",-- "channels[].permanent": "boolean",-- "channels[].maxParticipants": "number",-- "channels[].participantCount": "number",-- "channels[].participants": "array"-- },-- "from": [-- "public/js/voice-channels.js"-- ]-- },{"method": "GET","path": "/api/streams/1",@@@ -6338,7 +5827,6 @@"stream.duration_seconds": "number","stream.created_at": "string","stream.call_mode": "null",-- "stream.control_config_id": "null","stream.managed_stream_id": "number","stream.ai_overview": "null","stream.ai_overview_short": "null",@@@ -6369,12 +5857,6 @@"stream.channel.updated_at": "string","stream.channel.weather_detail": "string","stream.channel.weather_show_location": "number",-- "stream.channel.control_mode": "string",-- "stream.channel.anon_controls_enabled": "number",-- "stream.channel.control_rate_limit_ms": "number",-- "stream.channel.active_control_config_id": "null",-- "stream.channel.video_click_enabled": "number",-- "stream.channel.video_click_rate_limit_ms": "number","stream.channel.offline_screen_type": "string","stream.channel.offline_screen_url": "null","stream.channel.offline_html": "null",@@@ -6388,7 +5870,7 @@"stream.pip_overlay": "null"},"from": [-- "public/js/app-media.js"++ "public/popout-chat.html"]},{@@@ -6418,7 +5900,6 @@"stream.duration_seconds": "number","stream.created_at": "string","stream.call_mode": "null",-- "stream.control_config_id": "null","stream.managed_stream_id": "number","stream.ai_overview": "null","stream.ai_overview_short": "null",@@@ -6449,12 +5930,6 @@"stream.channel.updated_at": "string","stream.channel.weather_detail": "string","stream.channel.weather_show_location": "number",-- "stream.channel.control_mode": "string",-- "stream.channel.anon_controls_enabled": "number",-- "stream.channel.control_rate_limit_ms": "number",-- "stream.channel.active_control_config_id": "null",-- "stream.channel.video_click_enabled": "number",-- "stream.channel.video_click_rate_limit_ms": "number","stream.channel.offline_screen_type": "string","stream.channel.offline_screen_url": "null","stream.channel.offline_html": "null",@@@ -6468,39 +5943,7 @@"stream.pip_overlay": "null"},"from": [-- "public/js/app-media.js"-- ]-- },-- {-- "method": "GET",-- "path": "/api/streams/1/call",-- "auth": "anon",-- "status": 200,-- "kind": "json",-- "reads": {-- "call_mode": "null",-- "channelId": "null",-- "participants": "array",-- "participant_count": "number"-- },-- "from": [-- "public/js/broadcast.js"-- ]-- },-- {-- "method": "GET",-- "path": "/api/streams/1/call",-- "auth": "user",-- "status": 200,-- "kind": "json",-- "reads": {-- "call_mode": "null",-- "channelId": "null",-- "participants": "array",-- "participant_count": "number"-- },-- "from": [-- "public/js/broadcast.js"++ "public/popout-chat.html"]},{@@@ -6650,100 +6093,40 @@"method": "GET","path": "/api/vods/stream/1/live","auth": "anon",-- "status": 404,-- "kind": "json",-- "from": [-- "public/js/app-channel.js"-- ]-- },-- {-- "method": "GET",-- "path": "/api/vods/stream/1/live",-- "auth": "user",-- "status": 404,-- "kind": "json",-- "from": [-- "public/js/app-channel.js"-- ]-- },-- {-- "method": "GET",-- "path": "/api/vods/1/context",-- "auth": "anon",-- "status": 404,-- "kind": "json",-- "from": [-- "public/js/context-cards.js"-- ]-- },-- {-- "method": "GET",-- "path": "/api/vods/1/context",-- "auth": "user",-- "status": 404,-- "kind": "json",-- "from": [-- "public/js/context-cards.js"-- ]-- },-- {-- "method": "GET",-- "path": "/api/vods/1/live-info",-- "auth": "anon",-- "status": 502,-- "kind": "json",-- "from": [-- "public/js/app-media.js"-- ]-- },-- {-- "method": "GET",-- "path": "/api/vods/1/live-info",-- "auth": "user",-- "status": 502,-- "kind": "json",-- "from": [-- "public/js/app-media.js"-- ]-- },-- {-- "method": "GET",-- "path": "/api/vods/1",-- "auth": "anon",-- "status": 502,++ "status": 404,"kind": "json","from": [-- "public/js/app-media.js"++ "public/js/app-channel.js"]},{"method": "GET",-- "path": "/api/vods/1",++ "path": "/api/vods/stream/1/live","auth": "user",-- "status": 502,++ "status": 404,"kind": "json","from": [-- "public/js/app-media.js"++ "public/js/app-channel.js"]},{"method": "GET",-- "path": "/api/vods/1/memories",++ "path": "/api/vods/1/live-info","auth": "anon","status": 502,"kind": "json","from": [-- "public/js/app-media.js"++ "public/js/stream-player.js"]},{"method": "GET",-- "path": "/api/vods/1/memories",++ "path": "/api/vods/1/live-info","auth": "user","status": 502,"kind": "json","from": [-- "public/js/app-media.js"++ "public/js/stream-player.js"]},{@@@ -6798,7 +6181,18 @@},{"method": "GET",-- "path": "/css/style.css?v=57784f84656e",++ "path": "/css/style.core.css?v=39879bee1919",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "text",++ "from": [++ "page /"++ ]++ },++ {++ "method": "GET",++ "path": "/css/style.css?v=a417682204dd","auth": "anon","accept": "html","status": 200,@@@ -6809,7 +6203,7 @@},{"method": "GET",-- "path": "/css/features/home.css?v=1da8ab368854",++ "path": "/css/features/home.css?v=faa8fa066557","auth": "anon","accept": "html","status": 200,@@@ -6820,7 +6214,7 @@},{"method": "GET",-- "path": "/css/home-fx.css?v=8f135af68e76",++ "path": "/css/home-fx.css?v=e663894c29e3","auth": "anon","accept": "html","status": 200,@@@ -6842,7 +6236,7 @@},{"method": "GET",-- "path": "/css/i18n-star.css?v=365567ee3c2e",++ "path": "/css/i18n-star.css?v=c5b0b2261c10","auth": "anon","accept": "html","status": 200,@@@ -6886,7 +6280,7 @@},{"method": "GET",-- "path": "/css/cosmetics.css?v=73e2adf188e1",++ "path": "/css/cosmetics.css?v=5d33ae6cb97b","auth": "anon","accept": "html","status": 200,@@@ -6897,7 +6291,7 @@},{"method": "GET",-- "path": "/css/messenger.css?v=8d3312c1f857",++ "path": "/css/messenger.css?v=b546de06782d","auth": "anon","accept": "html","status": 200,@@@ -7117,7 +6511,7 @@},{"method": "GET",-- "path": "/shared/navbar.js?v=555cc6d59b78",++ "path": "/shared/navbar.js?v=70456901fbfe","auth": "anon","accept": "html","status": 200,@@@ -7128,7 +6522,7 @@},{"method": "GET",-- "path": "/js/app.js?v=a34333556690",++ "path": "/js/app.js?v=3dd6d6052d5b","auth": "anon","accept": "html","status": 200,@@@ -7139,7 +6533,7 @@},{"method": "GET",-- "path": "/js/ov-navbar-live.js?v=67c0ab5cc8f1",++ "path": "/js/ov-navbar-live.js?v=d54491455927","auth": "anon","accept": "html","status": 200,@@@ -7150,7 +6544,7 @@},{"method": "GET",-- "path": "/js/chat-shared.js?v=bd4b18592b1a",++ "path": "/js/chat-shared.js?v=045f792bf138","auth": "anon","accept": "html","status": 200,@@@ -7172,7 +6566,7 @@},{"method": "GET",-- "path": "/js/chat.js?v=214e3c481b2f",++ "path": "/js/chat.js?v=aede89fcff0c","auth": "anon","accept": "html","status": 200,@@@ -7183,7 +6577,7 @@},{"method": "GET",-- "path": "/js/vibes.js?v=aef505d77f34",++ "path": "/js/vibes.js?v=636a00a18df2","auth": "anon","accept": "html","status": 200,@@@ -7227,7 +6621,7 @@},{"method": "GET",-- "path": "/shared/footer.js?v=9f69d9ce17b9",++ "path": "/shared/footer.js?v=885f7fd5835b","auth": "anon","accept": "html","status": 200,@@@ -7238,7 +6632,7 @@},{"method": "GET",-- "path": "/js/footer-live.js?v=a10429d40713",++ "path": "/js/footer-live.js?v=02da2af6b629","auth": "anon","accept": "html","status": 200,@@@ -7271,7 +6665,7 @@},{"method": "GET",-- "path": "/shared/notification-ui.js?v=a889be53ea64",++ "path": "/shared/notification-ui.js?v=5eb3ef9fa337","auth": "anon","accept": "html","status": 200,@@@ -7304,7 +6698,7 @@},{"method": "GET",-- "path": "/js/app-home.js?v=1dc431184ccc",++ "path": "/js/app-home.js?v=63d6e4a54fd8","auth": "anon","accept": "html","status": 200,@@@ -7359,7 +6753,7 @@},{"method": "GET",-- "path": "/css/features/channel.css?v=63269f14afd5",++ "path": "/css/features/channel.css?v=f41f8d4b31d8","auth": "anon","accept": "html","status": 200,@@@ -7370,7 +6764,7 @@},{"method": "GET",-- "path": "/css/context-cards.css?v=f2468014839c",++ "path": "/css/context-cards.css?v=07823a1b5eb5","auth": "anon","accept": "html","status": 200,@@@ -7392,7 +6786,7 @@},{"method": "GET",-- "path": "/js/stream-player.js?v=fe62065746fd",++ "path": "/js/stream-player.js?v=00262645004f","auth": "anon","accept": "html","status": 200,@@@ -7414,7 +6808,7 @@},{"method": "GET",-- "path": "/js/controls.js?v=04c5242890ee",++ "path": "/js/context-cards.js?v=c5fd219fa47f","auth": "anon","accept": "html","status": 200,@@@ -7425,7 +6819,7 @@},{"method": "GET",-- "path": "/js/context-cards.js?v=edb4f671e62d",++ "path": "/js/emotes.js?v=346f6a37bfb3","auth": "anon","accept": "html","status": 200,@@@ -7436,7 +6830,7 @@},{"method": "GET",-- "path": "/js/emotes.js?v=0099deffc3c0",++ "path": "/js/gif-picker.js?v=fde186491f4e","auth": "anon","accept": "html","status": 200,@@@ -7447,7 +6841,7 @@},{"method": "GET",-- "path": "/js/gif-picker.js?v=5ee269f4e9c1",++ "path": "/js/chat-autocomplete.js?v=b726f226680f","auth": "anon","accept": "html","status": 200,@@@ -7458,7 +6852,7 @@},{"method": "GET",-- "path": "/js/chat-autocomplete.js?v=b726f226680f",++ "path": "/js/channel-uploads.js?v=5e4c26112cbf","auth": "anon","accept": "html","status": 200,@@@ -7469,7 +6863,7 @@},{"method": "GET",-- "path": "/js/channel-uploads.js?v=5e4c26112cbf",++ "path": "/js/channel-socials.js?v=4444b3116395","auth": "anon","accept": "html","status": 200,@@@ -7480,7 +6874,7 @@},{"method": "GET",-- "path": "/js/channel-socials.js?v=983c9f5760d8",++ "path": "/js/channel-chat-resize.js?v=3dfe687e2a68","auth": "anon","accept": "html","status": 200,@@@ -7491,7 +6885,7 @@},{"method": "GET",-- "path": "/js/channel-chat-resize.js?v=3dfe687e2a68",++ "path": "/js/channel-support.js?v=744f82e1cdc6","auth": "anon","accept": "html","status": 200,@@@ -7502,7 +6896,7 @@},{"method": "GET",-- "path": "/js/app-channel.js?v=024dbdf78e62",++ "path": "/js/app-channel.js?v=5aa423964298","auth": "anon","accept": "html","status": 200,@@@ -7601,16 +6995,6 @@"public/js/ai-viewers.js"]},-- {-- "method": "POST",-- "path": "/api/arena/beefs/1/hype",-- "auth": "anon",-- "status": 400,-- "kind": "json",-- "from": [-- "public/js/arena.js"-- ]-- },{"method": "POST","path": "/api/auth/avatar",@@@ -7728,20 +7112,9 @@"status": 401,"kind": "json","from": [-- "public/js/app-media.js","public/js/dashboard.js"]},-- {-- "method": "POST",-- "path": "/api/clips/1/recut",-- "auth": "anon",-- "status": 401,-- "kind": "json",-- "from": [-- "public/js/app-media.js"-- ]-- },{"method": "PUT","path": "/api/clips/1/title",@@@ -7749,7 +7122,6 @@"status": 401,"kind": "json","from": [-- "public/js/app-media.js","public/js/stream-player.js"]},@@@ -7760,7 +7132,6 @@"status": 401,"kind": "json","from": [-- "public/js/app-media.js","public/js/dashboard.js"]},@@@ -7844,136 +7215,6 @@"public/js/opencoins.js"]},-- {-- "method": "DELETE",-- "path": "/api/comments/1",-- "auth": "anon",-- "status": 401,-- "kind": "json",-- "from": [-- "public/js/app-media.js"-- ]-- },-- {-- "method": "PUT",-- "path": "/api/comments/1",-- "auth": "anon",-- "status": 401,-- "kind": "json",-- "from": [-- "public/js/app-media.js"-- ]-- },-- {-- "method": "POST",-- "path": "/api/comments/1/1",-- "auth": "anon",-- "status": 401,-- "kind": "json",-- "from": [-- "public/js/app-media.js"-- ]-- },-- {-- "method": "POST",-- "path": "/api/controls/configs",-- "auth": "anon",-- "status": 401,-- "kind": "json",-- "from": [-- "public/js/dashboard.js"-- ]-- },-- {-- "method": "POST",-- "path": "/api/controls/configs/deactivate",-- "auth": "anon",-- "status": 401,-- "kind": "json",-- "from": [-- "public/js/dashboard.js"-- ]-- },-- {-- "method": "DELETE",-- "path": "/api/controls/configs/1",-- "auth": "anon",-- "status": 401,-- "kind": "json",-- "from": [-- "public/js/dashboard.js"-- ]-- },-- {-- "method": "POST",-- "path": "/api/controls/configs/1/activate",-- "auth": "anon",-- "status": 401,-- "kind": "json",-- "from": [-- "public/js/dashboard.js"-- ]-- },-- {-- "method": "DELETE",-- "path": "/api/controls/configs/1/buttons/1",-- "auth": "anon",-- "status": 401,-- "kind": "json",-- "from": [-- "public/js/dashboard.js"-- ]-- },-- {-- "method": "POST",-- "path": "/api/controls/configs/1/buttons",-- "auth": "anon",-- "status": 401,-- "kind": "json",-- "from": [-- "public/js/dashboard.js"-- ]-- },-- {-- "method": "PUT",-- "path": "/api/controls/settings/channel",-- "auth": "anon",-- "status": 401,-- "kind": "json",-- "from": [-- "public/js/dashboard.js"-- ]-- },-- {-- "method": "POST",-- "path": "/api/controls/whitelist",-- "auth": "anon",-- "status": 401,-- "kind": "json",-- "from": [-- "public/js/dashboard.js"-- ]-- },-- {-- "method": "DELETE",-- "path": "/api/controls/whitelist/1",-- "auth": "anon",-- "status": 401,-- "kind": "json",-- "from": [-- "public/js/dashboard.js"-- ]-- },-- {-- "method": "PUT",-- "path": "/api/controls/1/config",-- "auth": "anon",-- "status": 401,-- "kind": "json",-- "from": [-- "public/js/broadcast.js"-- ]-- },{"method": "POST","path": "/api/cosmetics/equip",@@@ -8322,66 +7563,6 @@"public/js/broadcast.js"]},-- {-- "method": "POST",-- "path": "/api/onvif/cameras",-- "auth": "anon",-- "status": 401,-- "kind": "json",-- "from": [-- "public/js/onvif-dashboard.js"-- ]-- },-- {-- "method": "DELETE",-- "path": "/api/onvif/cameras/1",-- "auth": "anon",-- "status": 401,-- "kind": "json",-- "from": [-- "public/js/onvif-dashboard.js"-- ]-- },-- {-- "method": "PUT",-- "path": "/api/onvif/cameras/1",-- "auth": "anon",-- "status": 401,-- "kind": "json",-- "from": [-- "public/js/onvif-dashboard.js"-- ]-- },-- {-- "method": "POST",-- "path": "/api/onvif/cameras/1/presets",-- "auth": "anon",-- "status": 401,-- "kind": "json",-- "from": [-- "public/js/onvif-dashboard.js"-- ]-- },-- {-- "method": "DELETE",-- "path": "/api/onvif/cameras/1/presets/1",-- "auth": "anon",-- "status": 401,-- "kind": "json",-- "from": [-- "public/js/onvif-dashboard.js"-- ]-- },-- {-- "method": "POST",-- "path": "/api/onvif/discover",-- "auth": "anon",-- "status": 401,-- "kind": "json",-- "from": [-- "public/js/onvif-dashboard.js"-- ]-- },{"method": "POST","path": "/api/pastes",@@@ -8668,6 +7849,36 @@"public/js/broadcast.js"]},++ {++ "method": "POST",++ "path": "/api/rum",++ "auth": "anon",++ "status": 204,++ "kind": "empty",++ "from": [++ "public/js/ov-rum.js"++ ]++ },++ {++ "method": "PUT",++ "path": "/api/rundown/opt-out",++ "auth": "anon",++ "status": 401,++ "kind": "json",++ "from": [++ "public/js/rundown.js"++ ]++ },++ {++ "method": "POST",++ "path": "/api/rundown/react",++ "auth": "anon",++ "status": 400,++ "kind": "json",++ "from": [++ "public/js/rundown.js"++ ]++ },{"method": "POST","path": "/api/streams",@@@ -8804,36 +8015,6 @@"public/js/app-channel.js"]},-- {-- "method": "POST",-- "path": "/api/streams/voice-channels",-- "auth": "anon",-- "status": 401,-- "kind": "json",-- "from": [-- "public/js/voice-channels.js"-- ]-- },-- {-- "method": "POST",-- "path": "/api/streams/voice-channels/call-user",-- "auth": "anon",-- "status": 401,-- "kind": "json",-- "from": [-- "public/js/chat.js"-- ]-- },-- {-- "method": "POST",-- "path": "/api/streams/voice-channels/call-user/respond",-- "auth": "anon",-- "status": 401,-- "kind": "json",-- "from": [-- "public/js/chat.js"-- ]-- },{"method": "DELETE","path": "/api/streams/1",@@@ -8901,7 +8082,6 @@"status": 401,"kind": "json","from": [-- "public/js/app-media.js","public/js/broadcast.js","public/js/stream-player.js"]@@@ -8953,7 +8133,6 @@"status": 401,"kind": "json","from": [-- "public/js/app-media.js","public/js/dashboard.js"]},@@@ -8964,7 +8143,6 @@"status": 401,"kind": "json","from": [-- "public/js/app-media.js","public/js/dashboard.js"]},@@@ -1,7 +1,7 @@{"service": "live",-- "release": "81dc337dc7ae261ccc498f5894241f1f0e9cc52f",-- "recorded_at": "2026-10-08T18:28:45.009Z",++ "release": "f0fed5be6d1b25cfcabb1a3f3e2db347dfea88fb",++ "recorded_at": "2026-10-19T23:30:31.918Z","note": "N-1 fixture: written by `npm run n-1:record`, replayed by test/n-1.test.js","engine": "postgresql","migrations": [@@@ -12,6 +12,38 @@{"name": "0002_live.sql","sha256": "616fa09e397160ca847d72ac7f3d91824cf4c92d9318ea55b91d49501bc5dc59"++ },++ {++ "name": "0003_users_identity_expand.sql",++ "sha256": "57d14f58a8f1e139b9892b393c65c9e79c54f7079fbcc5bb530032acb8a2683e"++ },++ {++ "name": "0004_comment_thread_refs_numeric_optional.sql",++ "sha256": "6ec5f068b21d67ddb55134656c0d873d944b4a4bf7dab64729d04faec27d2f19"++ },++ {++ "name": "0005_drop_live_cosmetics.sql",++ "sha256": "3a1653ace7041e159358522dedfb46c91cfcdf9bb451046609bb89848ac4e351"++ },++ {++ "name": "0006_telemetry.sql",++ "sha256": "aadd653d2480dbb3264bd0f4898b9560bdf3160cd7b86eae854f910213613b69"++ },++ {++ "name": "0007_setup_tasks.sql",++ "sha256": "e14a4931df19751ec09dc65731abef52de6ced2704f4b3cfb618e14ec9354e93"++ },++ {++ "name": "0008_arena_drama.sql",++ "sha256": "d45a7c18d61e56c7c4cd3414e3846ec7b94ecf245dcf89c3b51d573ccf3e8ead"++ },++ {++ "name": "0009_channel_tips.sql",++ "sha256": "20352293553d3edbe49535cd48cc3f9a00eeda0bddf011f06e7ddd772c1e65be"++ },++ {++ "name": "0010_rundown.sql",++ "sha256": "7477f41b9d41a067f03adb79a5c198721ef88910774d8a1f52fefce8d8ddc251"}],"statements": []@@@ -82,11 +82,10 @@ const ANN = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPA';assert.strictEqual(await live.scan({ now: now + 5 * 60000 }), 0, 'the next scan starts where this one ended');assert.strictEqual(await live.refresh({ now: now + DAY }), 1, 'the daily refresh visits everyone with recent streams or followers');-- // ── live.loyalty (contracts 0.56.0, WS-K task 9): channel points and the Arena level, private ──++ // ── live.loyalty (contracts 0.56.0, WS-K task 9): channel points, private (the Arena's level went with the Arena) ──await d.prepare("INSERT INTO channel_points (user_id, streamer_id, balance, updated_at) VALUES (3, 1, 300, ?), (3, 2, 50, ?), (3, 3, 0, ?)").run(at(DAY), at(DAY), at(DAY));-- await d.prepare('INSERT INTO arena_trash_levels (user_id, xp, level) VALUES (3, 450, 3)').run();-- assert.deepStrictEqual(await live.loyaltyOf(3), { channel_points_total: 350, channels: [{ channel: 'ann', points: 300 }, { channel: 'bob', points: 50 }], arena_level: 3, arena_xp: 450 });-- assert.strictEqual(await live.loyaltyOf(2), null, 'no points and no Arena: nothing');++ assert.deepStrictEqual(await live.loyaltyOf(3), { channel_points_total: 350, channels: [{ channel: 'ann', points: 300 }, { channel: 'bob', points: 50 }] });++ assert.strictEqual(await live.loyaltyOf(2), null, 'no points: nothing');const before = puts.length;assert.deepStrictEqual(await live.push(3, { now }), ['live.loyalty'], 'a viewer with points gets only the loyalty record');const loyal = puts[before];@@@ -101,7 +100,6 @@ const ANN = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPA';assert.strictEqual(puts[puts.length - 1].data.channel_points_total, 360);// The scan picks up changed points; points gone after a write leave zeros, not a stale record.await d.prepare('UPDATE channel_points SET balance = 0, updated_at = ? WHERE user_id = 3').run(at(-10 * 60000)); // after the previous scan's window-- await d.prepare('DELETE FROM arena_trash_levels WHERE user_id = 3').run();assert.deepStrictEqual(await live.loyaltyOf(3), { channel_points_total: 0, channels: [] });assert.ok((await live.scan({ now: now + 45 * 60000 })) >= 1, 'the scan finds the changed points');assert.strictEqual(puts[puts.length - 1].data.channel_points_total, 0);nnew file mode 100644@@@ -0,0 +1,57 @@++'use strict';++/**++ * The Rundown's chat commands (server/rundown/chat-commands.js): the Arena's three, which Chat still forwards.++ * !hype, !beef beefs are gone: a pointer to The Rundown++ * !arena [user] the streamer's latest episode and topics, a streamer who opted out, nobody by that name++ * other commands not ours (the Arena's !topic, !bounty and !board are gone)++ */++const assert = require('assert');++const db = require('../server/db/database');++++(async () => {++await db.initDb();++const chat = require('../server/rundown/chat-commands');++const read = require('../server/rundown/read');++++const mk = async (username) => {++ await db.createUser({ username, email: `${username}@x`, password_hash: 'x', display_name: username[0].toUpperCase() + username.slice(1), 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 nova = await mk('nova'), quiet = await mk('quietguy'), viewer = await mk('viewer');++const liveId = Number((await db.createStream({ user_id: nova, title: 'Night', category: 'irl', protocol: 'rtmp' })).lastInsertRowid);++const ended = Number((await db.createStream({ user_id: nova, title: 'Fridges', category: 'irl', protocol: 'rtmp' })).lastInsertRowid);++await db.run("UPDATE streams SET is_live = 0, ended_at = ov_now() WHERE id = ?", [ended]);++const topic = (await db.run("INSERT INTO rundown_topics (slug, title, kind, first_seen_at, last_seen_at) VALUES ('fridges', 'Fridges', 'tech', ov_now(), ov_now()) RETURNING id")).lastInsertRowid;++await db.run("INSERT INTO rundown_segments (stream_id, user_id, topic_id, start_sec, end_sec, title, what, score, said_at) VALUES (?, ?, ?, 1, 9, 'Fridge take', 'nova on fridges.', 4, ov_now())", [ended, nova, topic]);++await db.run("INSERT INTO rundown_episodes (stream_id, user_id, title, recap, chapters_json, aired_at) VALUES (?, ?, 'The Fridge Files', 'x', '[]', ov_now())", [ended, nova]);++++const sent = [];++let pending = [];++// track() is how a caller that answers once waits for the command's replies (Chat's arena-command call).++const fakeChat = { sendTo: (ws, m) => sent.push(m.message), track: (p) => pending.push(p) };++const run = async (client, line) => { const parts = line.split(' '); const handled = await chat.handle(fakeChat, {}, client, parts[0], parts); await Promise.all(pending); pending = []; return handled; };++++for (const [n, cmd] of [['b1', '!topic anything'], ['b2', '!bounty nova'], ['b3', '!board']]) assert.strictEqual(await run({ anonId: n, streamId: liveId }, cmd), false, `${cmd.split(' ')[0]} is not ours`);++assert.strictEqual(await run({ user: { id: viewer, username: 'viewer' }, streamId: liveId, ip: '1.1.1.1' }, '!hype'), true);++assert.match(sent.pop(), /Beefs are gone.*\/rundown$/);++await run({ anonId: 'zz', streamId: liveId }, '!beef');++assert.match(sent.pop(), /Beefs are gone/);++await run({ anonId: 'zy', streamId: liveId }, '!arena');++assert.match(sent.pop(), /^📰 Nova — latest episode: “The Fridge Files” · talks about Fridges → .*\/rundown\/nova$/, 'the streamer whose chat it is');++await run({ anonId: 'zx', streamId: liveId }, '!arena quietguy');++assert.match(sent.pop(), /Nothing on The Rundown about Quietguy yet/);++await run({ anonId: 'zw', streamId: liveId }, '!arena nobody_here');++assert.match(sent.pop(), /Nobody called nobody_here here/);++await read.setOptOut(nova, true);++await run({ anonId: 'zv', streamId: liveId }, '!arena');++assert.match(sent.pop(), /Nova keeps themselves out of The Rundown/);++await run({ anonId: 'zv', streamId: liveId }, '!arena');++assert.match(sent.pop(), /one command every few seconds/, 'one command every few seconds per chatter');++assert.ok(quiet);++console.log('✅ chat: !hype / !beef point to The Rundown; !arena answers with the streamer\'s Rundown');++++console.log('rundown chat: ok');++process.exit(0);++})().catch((err) => { console.error(err); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,67 @@++'use strict';++// The Rundown's voice (server/rundown/voice.js, was the Arena's): one synthesis per (voice, text), cached on disk; equipped cosmetic voice wins; limits.++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-voice-'));++process.env.TTS_CACHE_PATH = path.join(tmp, 'tts-cache');++const db = require('../server/db/database');++const voice = require('../server/rundown/voice');++let calls = [];++voice._setSynth(async (v, text, username) => { calls.push({ sig: v.sig, text, username }); return { audio: Buffer.from(`AUDIO:${text}`).toString('base64'), mimeType: 'audio/mpeg', engine: 'espeak-ng', voiceId: 'auto:gary' }; });++++(async () => {++ await db.initDb();++ await db.createUser({ username: 'Goosely', email: 'g@x', password_hash: 'x', display_name: 'Goosely', stream_key: 'g'.repeat(32) });++ const user = await db.get('SELECT * FROM users WHERE username = ?', ['Goosely']);++ const v = await voice.voiceFor(user);++ assert.strictEqual(v.kind, 'auto'); assert.strictEqual(v.identityKey, 'user:goosely', 'same identity key chat uses');++ assert.strictEqual((await voice.voiceFor(null)).kind, 'announcer');++ const a = await voice.speak({ user, text: ' you think your chat is loud? ' });++ assert.strictEqual(a.cached, false); assert.strictEqual(a.mimeType, 'audio/mpeg'); assert.ok(fs.existsSync(a.path));++ assert.strictEqual(fs.readFileSync(a.path, 'utf8'), 'AUDIO:you think your chat is loud?', 'text is normalised before synthesis');++ const b = await voice.speak({ user, text: 'you think your chat is loud?' });++ assert.strictEqual(b.cached, true); assert.strictEqual(b.path, a.path); assert.strictEqual(calls.length, 1, 'second click never re-synthesizes');++ const c = await voice.speak({ user: null, text: 'you think your chat is loud?' });++ assert.notStrictEqual(c.path, a.path, 'same text in another voice is another file'); assert.strictEqual(calls.length, 2);++ assert.strictEqual((await db.get(`SELECT COUNT(*) AS n FROM ai_usage WHERE kind = 'tts'`)).n, 2, 'every synthesis is metered');++ await assert.rejects(voice.speak({ user, text: 'x' }), /Nothing to say/);++ await db.setSetting('arena_voice_daily_max', '2');++ await assert.rejects(voice.speak({ user, text: 'a brand new line' }), /hoarse|budget/, 'daily cap stops fresh synthesis');++ const d = await voice.speak({ user, text: 'you think your chat is loud?' });++ assert.strictEqual(d.cached, true, 'cache hits still work past the cap');++ console.log('✅ voice: cached per (voice, text), metered, capped');++++ let ok = 0; for (let i = 0; i < 12; i++) if (voice.allow('1.2.3.4', false)) ok++;++ assert.strictEqual(ok, 8, 'anonymous: 8 per minute'); assert.ok(voice.allow('1.2.3.4', true) === false || true);++ let ok2 = 0; for (let i = 0; i < 40; i++) if (voice.allow('5.6.7.8', true)) ok2++;++ assert.strictEqual(ok2, 30, 'signed in: 30 per minute');++ console.log('✅ voice: per-IP limits');++++ // HTTP: streams the file with a week of caching; announcer works without a user.++ const express = require('express'); const app = express(); app.use('/api/rundown', require('../server/rundown/routes'));++ const srv = await new Promise(r => { const s = app.listen(0, () => r(s)); });++ await db.setSetting('arena_voice_daily_max', '100');++ const res = await fetch(`http://127.0.0.1:${srv.address().port}/api/rundown/voice/goosely?t=${encodeURIComponent('you think your chat is loud?')}`);++ assert.strictEqual(res.status, 200); assert.strictEqual(res.headers.get('content-type'), 'audio/mpeg'); assert.ok(/max-age=604800/.test(res.headers.get('cache-control'))); assert.strictEqual(res.headers.get('x-cache'), 'HIT');++ assert.strictEqual((await res.text()), 'AUDIO:you think your chat is loud?');++ assert.strictEqual((await fetch(`http://127.0.0.1:${srv.address().port}/api/rundown/voice/nobody?t=hi`)).status, 404);++ assert.strictEqual((await fetch(`http://127.0.0.1:${srv.address().port}/api/rundown/voice/goosely?t=`)).status, 400);++ srv.close();++ console.log('✅ voice: HTTP streaming + caching headers');++++ // One-off clips (admin Test Voice / mod preview) get a same-origin URL from the same cache —++ // no blob:/data: needed in the browser, and the filename is traversal-proof.++ const st = voice.stash(Buffer.from('RIFFfakewav').toString('base64'), 'audio/wav');++ assert.ok(/^[a-f0-9]{32}\.wav$/.test(st.file) && fs.existsSync(path.join(voice.CACHE_DIR, st.file)));++ assert.strictEqual(voice.stash(Buffer.from('RIFFfakewav').toString('base64'), 'audio/wav').file, st.file, 'same clip → same file');++ assert.strictEqual(voice.stash('', 'audio/wav'), null);++ assert.strictEqual(voice.cachedByName('../../etc/passwd'), null, 'no traversal');++ assert.strictEqual(voice.cachedByName('zz.wav'), null);++ assert.ok(voice.cachedByName(st.file).mimeType === 'audio/wav');++ // An arena voice line lands in the clip cache by filename (the /api/tts clip route is Chat's now).++ const line = await voice.speak({ user, text: 'you think your chat is loud?' });++ assert.ok(voice.cachedByName(path.basename(line.path)), 'a spoken line is served from the clip cache');++ console.log('✅ one-off clip stash + arena voice cache');++ console.log('\n✅ All Rundown voice tests passed');++ process.exit(0);++})().catch(err => { console.error(err); process.exit(1); });
@@ -1,115 +1,8 @@
@@ -1,115 +1,8 @@
-> **The Arena is gone (2026-10-19).** It became [The Rundown](rundown.md): what streamers talked about, written up — no beefs, ratings, Lolcow Index or Clout Rankings. Its jobs are off and `/arena` redirects to `/rundown`; this page describes the old code until it is removed.
+# The Arena (retired 2026-10-19)
-# Arena — ARENA ALERT (v4, 2026-10-16)
+The Arena (`/arena`: ARENA ALERT, the Lolcow Index, Clout Rankings and mic-judged beefs) became **[The Rundown](rundown.md)**: what streamers talked about, written up, with no beefs, ratings or rankings. In the owner's words, the Arena was "kinda lame", and the Lolcow and Clout boards were "cringe".
-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.
-
-- **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** (3+ yells/screams/shouts within 2 min on the sound timeline AND a heated line of the streamer's own beside them, which is quoted: the sound timeline hears a game's or a match's crowd too, so noise alone is no story, and there are no tears stories), **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 when they go on (both sides have named each other), their win and forfeit (a forfeit story only when the side that went silent was live while on the clock). 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 (a rewrite that adds an accusing word the template does not use, such as a threat or an attack, is dropped and the template stays), 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, and the story is announced in chat as **ARENA ALERT** (their stream room, mirrored to global chat, while they are live; else global chat; at most 6 an hour).
-- **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, 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 fighter's whole name said on mic opens a beef, the other side is on the clock, silence is a forfeit.
-
-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>`.
-
-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
-
-The platform does not censor language. Offensive, provocative, taboo words — slurs included — are allowed, are never filtered, and are never a reason for the judge to score low. The only lines that don't count (`server/arena/arena-service.js` `isBannedText`, plus the judge's `flagged`) are **behaviour**: direct threats of violence (`kys`, "I'll kill you"), sexual content about minors, and doxxing (addresses, phone numbers). Those lines are dropped from the feed, quotes and scoring; nothing else is.
-
-## The roster + ratings (`server/arena/arena-service.js`)
-
-The roster is **whoever has been heard**: every streamer with transcribed speech in the last 45 days. A streamer with 9,000 viewers and no transcription is not a fighter. Ratings are 40–99 percentiles across the roster (a roster of one is a flat 70):
-
-| Stat | From | Weight |
-|---|---|---|
-| **Heat** | average judge score of their mic moments (30 d) | 0.22 |
-| **Aim** | callouts + beef hits per hour on mic (30 d) | 0.18 |
-| **Kills** | beefs won | 0.16 |
-| **Mouth** | share of stream time spent talking | 0.14 |
-| **Clapback** | beefs answered on the clock ÷ beefs they were called out in | 0.12 |
-| **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). 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).
-
-## The mic ledger (`server/arena/mic.js`)
-
-`arena_mic_moments` — one row per judged line: `kind` (`trash` for free-standing shit talk, `beef_hit` when it fed a beef), `target_user_id` + `beef_id` for callouts, `aimed_at` (free text: "chat", "the mods", a name…), `text`, `about`, `quality`, `announcer`, VOD id + second, `said_at`. `feed()` is the live feed; `micStats(userId)` is what the ratings read.
-
-**XP → Trash Level** (`arena_trash_levels`, level = 1 + XP ÷ 50): `trash` moment = quality × 0.8 · beef hit = quality · beef open +5 · beef win +40 (+20 upset) · hype received +1. Nothing else pays XP. The best line on record (highest quality) sticks to the profile.
-
-## The ears (`server/arena/listener.js`)
-
-Every 15 s, for every roster stream that is live **and** has a transcript line in the last 30 min:
-
-1. New speech lines are read.
-2. **Name detection** (`server/arena/names.js`) — a fighter is named only when their **whole** username or display name is said as it is spelled (camelCase/snake split, leet undone and spacing are fine: "mnss 22741", "grizzly bear"); pieces of a handle ("fix it" of finditfixit, "mnss"), nicknames (fighter name, `spoken_as`, a handle's first word) and fuzzy or phonetic sound-alikes never count (`findMentions(…, { wholeOnly: true })`). On 2026-10-18, 46 of the first 51 beefs had no whole name said (a League stream's "Minions" had matched mnss22741 phonetically); they were voided (`status = 'void'`, out of every record). The speaker never matches themselves.
-3. **Focus lock** — a name-drop locks the ears on that fighter for 2 min; everything said afterwards goes to the **beef judge** with the context of what was already said (`about_target`, `aimed_at_target`, quality, best verbatim line, announcer call). A hit, only in lines that say the target's name (what follows is context: a game's voice lines after one transcribed "Adam" once scored 107–0), → `beef.recordHit()`; every hit extends the lock 3 min (cap 20 min without a fresh name-drop); two off-target chunks drop it; a different name switches targets.
-4. **Free talk** — lines said while not locked pool for the **mic judge** (≥ 20 words, ≥ 30 s between calls per stream): `is_trash_talk`, quality, best line, `aimed_at`, announcer. Quality ≥ 4 → a `trash` moment in the feed. An `aimed_at` that is a roster fighter is dropped (nobody named them; a whole name said would have locked the ears instead), so free talk never opens a beef.
-5. One judge call per stream per tick; keyword heuristics when AI is off.
-
-`/arena/live/<username>` shows exactly what the ears hear: hot mic lines, the lock and its context, the last beef and mic judgements, their recent lines in the feed, open beefs.
-
-## Beefs (`server/arena/beef.js`)
-
-| Rule | Value |
-|---|---|
-| Opens when | the beef judge confirms ≥ 20 words aimed at a fighter after their whole name was said: that makes it **pending**, told only to the named fighter and shown nowhere; it **opens** (public, ARENA ALERT, chat) when they answer on their own stream naming the caller, and an unanswered callout ends **expired** with no forfeit, story or record (one-sided matches were greetings and mistranscriptions, 2026-10-18) |
-| Clock | the other side must answer on their own cam within **15 min if live**, **24 h if offline** (an offline clock tightens to 15 min the moment they go live) |
-| Answer | any judged hit from the side on the clock; every hit flips the clock |
-| Forfeit | clock runs out → the silent side loses |
-| Hard end | 24 h after opening → higher **total** wins (`total = Σ hit quality + crowd hype (max 10)`), equal = draw |
-| Upset | winner ranked ≥ 4 places below the loser (+20 XP) |
-| Rematch | same pair again → flagged, with the rivalry's record and **receipts** (best lines from earlier beefs) |
-| Crowd | `!hype` / Hype button: one per person per side — the only thing chat can do |
-
-Every hit is also written to the mic ledger as a `beef_hit` moment. Headlines (open + result) are AI-written with templates as fallback; the announcer's one-liner per hit comes from the judge.
-
-## Pages
-
-`/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>` — 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.
-
-## Chat commands (`server/arena/arena-chat.js`)
-
-`!hype` (the streamer's newest open beef) · `!beef` · `!arena [user]`. One command per person every 4 s. `!topic`, `!bounty` and `!board` no longer exist.
-
-## API (`/api/arena`)
-
-| Method | Path | Notes |
-|---|---|---|
-| GET | `/status` · `/fighters` · `/fighters/:user` · `/fighters/:user/stat/:stat` · `/live` | roster (`stats`, `stat_meta`, per-fighter `mic`, `last_line`), card (+ `beefs`, `rivalries`, `moments`, `best_lines`, `mic`), drill-down, live cams (`ears`, `last_moment`, `open_beefs`) |
-| GET | `/feed?limit&since` | the shit-talk feed |
-| POST | `/fighters/:user/refresh` | admin — regenerate persona |
-| GET | `/console/:user` | the ears: listener state, hot mic, level, mic stats, open beefs, recent moments |
-| GET | `/beefs` · `/beefs/:id` · POST `/beefs/:id/hype {side}` | one hype per person per side; anonymous by hashed IP |
-| GET | `/levels` | Trash Level ladder |
-| GET | `/me` | signed in: fighter brief, level, record, mic stats, open beefs, beefs where you are on the clock, recent moments |
-| GET | `/voice/:user?t=<text>` | the line in that user's chat TTS voice (cached on disk + a week in the browser) |
-
-## Settings
-
-| Key | Default | Effect |
-|---|---|---|
-| `arena_enabled` | `true` | `false` → API 404s |
-| `ai_timeline_enabled` | `false` | the audio transcription the whole Arena runs on — must be on |
-| `arena_vote_salt` | `JWT_SECRET` | salt for anonymous hype hashing |
-
-Background job (`server/arena/arena-job.js`): personas/portraits every 20 min (bounded, budget-aware) · listener every 15 s · beef clocks every 60 s.
-
-Tests: `node test/arena.test.js` (roster, mic ratings, filter, quotes) · `node test/arena-mic.test.js` (ledger, beefs, listener tick, chat, API) · `node test/arena-voice.test.js` — all on a temp DB, no AI needed.
-
-## Voice
-
-Every 🔊 in the Arena calls `GET /api/arena/voice/<username>?t=<line>` (`server/arena/voice.js`): synthesized once in the streamer's equipped cosmetic chat voice (else their per-identity chat voice), cached on disk and for a week in the browser. `announcer` reads headlines in `arena_announcer_voice`. `arena_voice_daily_max` (2000) caps fresh syntheses per day; per-IP limits are 8/min anonymous, 30/min signed in.
+- `/arena` and its pages redirect (301) to `/rundown`, and a fighter's `/arena/<username>` goes to `/rundown/<username>`.
+- Chat still forwards the Arena's commands. `!arena [user]` answers with the streamer's latest episode and topics, and `!hype` and `!beef` say beefs are gone (`server/rundown/chat-commands.js`).
+- The Rundown kept the Arena's opt-outs (`arena_opt_outs`), whole-name matching (`server/rundown/names.js`) and the line-in-their-chat-voice player (`server/rundown/voice.js`, setting `arena_voice_daily_max`).
+- The Arena's pages, styles, jobs, `/api/arena` and AI portraits were removed on 2026-10-19. Its tables (roster, mic moments, beefs, drama, trash levels) are dropped by a later contract migration. Until then, account export and deletion still cover them (`server/auth/account-data.js`).
@@ -909,7 +909,7 @@ router.post('/tts-voice/preview', permissions.requireGlobalMod, async (req, res)
@@ -909,7 +909,7 @@ router.post('/tts-voice/preview', permissions.requireGlobalMod, async (req, res)
voice: req.body?.voice, pitch: req.body?.pitch, speed: req.body?.speed, gap: req.body?.gap,
voice: req.body?.voice, pitch: req.body?.pitch, speed: req.body?.speed, gap: req.body?.gap,
});
});
if (!result?.audio) return res.status(503).json({ error: 'TTS unavailable (espeak-ng not installed?)' });
if (!result?.audio) return res.status(503).json({ error: 'TTS unavailable (espeak-ng not installed?)' });
- let url = null; try { const stashed = require('../arena/voice').stash(result.audio, result.mimeType); if (stashed) url = `/api/tts/audio/${stashed.file}`; } catch { /* */ }
+ let url = null; try { const stashed = require('../rundown/voice').stash(result.audio, result.mimeType); if (stashed) url = `/api/tts/audio/${stashed.file}`; } catch { /* */ }
res.json({ audio: result.audio, mimeType: result.mimeType || 'audio/wav', url });
res.json({ audio: result.audio, mimeType: result.mimeType || 'audio/wav', url });
} catch (err) {
} catch (err) {
console.error('[Mod] tts-voice preview error:', err.message);
console.error('[Mod] tts-voice preview error:', err.message);
@@ -32,7 +32,6 @@ const KIND_TO_WORKFLOW = {
@@ -32,7 +32,6 @@ const KIND_TO_WORKFLOW = {
ai_viewers_director: 'live.viewers.plan', ai_viewers_reply: 'live.viewers.reply', ai_viewers: 'live.viewers.line', ai_viewers_fold: 'live.viewers.fold',
ai_viewers_director: 'live.viewers.plan', ai_viewers_reply: 'live.viewers.reply', ai_viewers: 'live.viewers.line', ai_viewers_fold: 'live.viewers.fold',
chat_global: 'live.chat.insight', chat_user: 'live.chat.insight', chat_relay: 'live.chat.insight', chat_anon: 'live.chat.insight', combined_overview: 'live.chat.insight', session_titles: 'live.chat.insight',
chat_global: 'live.chat.insight', chat_user: 'live.chat.insight', chat_relay: 'live.chat.insight', chat_anon: 'live.chat.insight', combined_overview: 'live.chat.insight', session_titles: 'live.chat.insight',
moment_vod_rank: 'live.moments.rank', moment_pick: 'live.moments.pick', auto_clip_confirm: 'live.clips.confirm', hero_slogans: 'live.hero.slogans', easter_egg: 'live.easter_egg', home_star: 'live.home.star',
moment_vod_rank: 'live.moments.rank', moment_pick: 'live.moments.pick', auto_clip_confirm: 'live.clips.confirm', hero_slogans: 'live.hero.slogans', easter_egg: 'live.easter_egg', home_star: 'live.home.star',
- arena_persona: 'live.arena.persona', arena_quotes: 'live.arena.quotes', arena_scene: 'live.arena.scene', arena_beef_judge: 'live.arena.judge', arena_mic_judge: 'live.arena.judge', arena_headline: 'live.arena.headline',
status_check: 'live.status_check',
status_check: 'live.status_check',
};
};
const ROLES = ['chat', 'vision', 'director', 'summary', 'legacy'];
const ROLES = ['chat', 'vision', 'director', 'summary', 'legacy'];
deleted file mode 100644
deleted file mode 100644
@@ -1,59 +0,0 @@
@@ -1,59 +0,0 @@
-/**
- * OpenVibe.Live — The Rundown's chat commands (the Arena's three, which Chat still forwards to
- * /internal/chat-effects/arena-command; the Arena became The Rundown on 2026-10-19).
- *
- * !arena [user] what the streamer (or user) has been talking about on The Rundown + a link
- * !hype, !beef beefs are gone: a pointer to The Rundown
- *
- * Called from ChatServer.handleBangCommand; returns true when the command was handled. Replies go to
- * the sender as a system line.
- */
-'use strict';
-
-const db = require('../db/database');
-
-const COMMANDS = ['!hype', '!beef', '!arena'];
-const RATE_MS = 4000;
-const _last = new Map(); // voterKey → ms
-
-function voterKey(client) {
- if (client.user?.id) return `user:${client.user.id}`;
- if (client.anonId) return `anon:${client.anonId}`;
- return `ip:${String(client.ip || '')}`;
-}
-function limited(map, key, ms) {
- const now = Date.now();
- if (now - (map.get(key) || 0) < ms) return true;
- map.set(key, now);
- return false;
-}
-function base() { try { const c = require('../config'); return String(c.baseUrl || '').replace(/\/$/, ''); } catch { return ''; } }
-
-async function handle(chat, ws, client, cmd, parts) {
- if (!COMMANDS.includes(cmd)) return false;
- const reply = (message) => chat.sendTo(ws, { type: 'system', message });
- if (limited(_last, voterKey(client), RATE_MS)) { reply('Easy — one command every few seconds.'); return true; }
- const work = (async () => {
- try {
- if (cmd !== '!arena') return reply(`Beefs are gone. The Rundown writes up what streamers talk about instead → ${base()}/rundown`);
- const stream = client.streamId ? await db.getStreamById(client.streamId) : null;
- const target = parts[1] ? await db.get('SELECT id, username, display_name FROM users WHERE lower(username) = lower(?)', [String(parts[1]).replace(/^@/, '')])
- : (stream ? await db.get('SELECT id, username, display_name FROM users WHERE id = ?', [stream.user_id]) : null);
- if (!target) return reply(parts[1] ? `Nobody called ${String(parts[1]).slice(0, 30)} here.` : `What streamers talked about, written up → ${base()}/rundown`);
- const read = require('../rundown/read');
- const page = await read.streamer(target.username);
- const name = target.display_name || target.username;
- if (!page || page.opted_out) return reply(`${name} keeps themselves out of The Rundown.`);
- const ep = page.episodes[0];
- const topics = page.topics.slice(0, 3).map((t) => t.title);
- if (!ep && !topics.length) return reply(`Nothing on The Rundown about ${name} yet: it takes talk on a transcribed stream → ${base()}/rundown`);
- return reply(`📰 ${name}${ep ? ` — latest episode: “${ep.title}”` : ''}${topics.length ? ` · talks about ${topics.join(', ')}` : ''} → ${base()}/rundown/${encodeURIComponent(target.username)}`);
- } catch (err) {
- reply(`The Rundown: ${err.message}`);
- }
- })();
- if (typeof chat.track === 'function') chat.track(work);
- return true;
-}
-
-module.exports = { handle, COMMANDS, _voterKey: voterKey };
deleted file mode 100644
deleted file mode 100644
@@ -1,64 +0,0 @@
@@ -1,64 +0,0 @@
-/**
- * arena-job.js — keeps Arena personas (and, when enabled, portraits) warm for the roster
- * so the tab loads instantly instead of generating on first view.
- *
- * Every 20 min: refresh up to BATCH stale personas (24 h TTL) for the highest-ranked
- * fighters first, and up to IMAGE_BATCH stale portraits (7 d TTL). Bounded per tick so a
- * big roster cannot burn the AI budget in one go; no-ops entirely while AI is off or over
- * budget (the shared gate in server/ai/llm.js).
- */
-'use strict';
-
-const arena = require('./arena-service');
-
-const INTERVAL_MS = 20 * 60 * 1000;
-const CLOCK_MS = 60 * 1000;
-let _timer = null;
-let _clock = null;
-let _busy = false;
-
-// Personas and portraits are no longer generated: the Arena shows only what was said on mic.
-// The periodic tick now judges past speech (backfill) so the feed reflects the record, and
-// catches up anything the live listener missed.
-async function tick() {
- if (_busy || !await arena.arenaEnabled() || !await arena.aiOn()) return;
- _busy = true;
- try { await require('./backfill').run(); } catch (e) { console.warn('[Arena] backfill:', e.message); }
- finally { _busy = false; }
-}
-
-async function start() {
- if (_timer) return;
- await arena.ensureTables();
- _timer = setInterval(() => tick().catch(e => console.warn('[Arena] job:', e.message)), INTERVAL_MS);
- if (_timer.unref) _timer.unref();
- setTimeout(() => tick().catch(() => {}), 90_000).unref?.();
- // The ears: every 15 s the listener reads live transcripts — name-drops → beef judge, free talk → mic judge.
- try { require('./listener').start(); } catch (e) { console.warn('[Arena] listener not started:', e.message); }
- // Clocks: forfeit beefs whose clock ran out, hard-end the ones past 24 h.
- _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 (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); }
-}
-
-function stop() {
- if (_timer) { clearInterval(_timer); _timer = null; }
- if (_clock) { clearInterval(_clock); _clock = null; }
- try { require('./listener').stop(); } catch { /* */ }
-}
-
-module.exports = { start, stop, tick, housekeeping };
deleted file mode 100644
deleted file mode 100644
@@ -1,507 +0,0 @@
@@ -1,507 +0,0 @@
-/**
- * OpenVibe.Live — Arena (roster, ratings, personas, portraits, quotes) — Battle Cam mode
- *
- * The Arena is PURE MIC. Viewer counts, chat volume, followers, clips and tips do not exist
- * here; the only input is what a streamer says into the microphone (the continuous audio
- * transcription in stream_timeline_events), judged by listener.js and stored by mic.js.
- * This module owns the roster:
- *
- * roster → every streamer with transcribed speech in the last ACTIVE_DAYS days.
- * stats → seven 40–99 ratings, all from the mic ledger, as percentiles across the
- * roster: HEAT (how good the shit talk is), AIM (callouts + beef hits per
- * hour on mic), KILLS (beef wins), MOUTH (share of stream time talking),
- * CLAPBACK (answering when called out), STAMINA (minutes of speech), PACE
- * (words per minute) — plus an overall POWER carrying the mouth bonus
- * (recent XP + beef wins).
- * persona → AI "character select" bio written from their TRANSCRIPTS, cached 24 h.
- * quotes → AI-picked "things they actually said" from the transcripts, VOD-linked.
- * image → a character portrait drawn earlier from their own stream frames (no new ones).
- *
- * Nothing here touches the AI unless AI is enabled and within budget (server/ai/llm.js).
- */
-'use strict';
-
-const fs = require('fs');
-const path = require('path');
-const crypto = require('crypto');
-const db = require('../db/database');
-const llm = require('../ai/llm');
-
-const paths = require('../paths');
-const ARENA_DIR = paths.dir('ARENA_IMAGE_PATH', 'arena');
-const ACTIVE_DAYS = 45;
-const STATS_WINDOW_DAYS = 90;
-const PERSONA_TTL_MS = 24 * 60 * 60 * 1000;
-const QUOTES_TTL_MS = 24 * 60 * 60 * 1000;
-const RATING_MIN = 40;
-const MIN_QUOTE_LINES = 20;
-const TALK_BONUS_MAX = 12;
-const MIC_WINDOW_DAYS = 30;
-const STAT_KEYS = ['heat', 'aim', 'kills', 'mouth', 'clapback', 'stamina', 'pace'];
-const STAT_META = {
- heat: { label: 'Heat', desc: 'how good the shit talk is — average judge score of their mic moments (30d)' },
- aim: { label: 'Aim', desc: 'callouts + beef hits per hour on mic (30d)' },
- kills: { label: 'Kills', desc: 'beefs won' },
- mouth: { label: 'Mouth', desc: 'share of stream time spent talking' },
- clapback: { label: 'Clapback', desc: 'answering when called out — beefs answered on the clock' },
- stamina: { label: 'Stamina', desc: 'minutes of speech heard (90d)' },
- pace: { label: 'Pace', desc: 'words per minute on mic' },
-};
-const STAT_WEIGHTS = { heat: 0.22, aim: 0.18, kills: 0.16, mouth: 0.14, clapback: 0.12, stamina: 0.10, pace: 0.08 };
-const HYPE_PATTERNS = ["let's go", 'lets go', 'no way', 'oh my god', 'insane', 'clutch', 'holy', 'gg', 'unreal', 'what the', 'bro', 'chat,', 'chat ', 'yo ', 'welcome', 'lfg', 'poggers', 'pog'];
-
-// ── Behavior line (NOT a vocabulary filter) ────────────────
-// This platform does not censor words: offensive, controversial, provocative and taboo
-// language — slurs included — is allowed and is never a reason to hide transcript text
-// or void an Arena entry. What is filtered is BEHAVIOR, not speech: direct threats of
-// violence, incitement to self-harm, sexual content involving minors, and doxxing.
-// Matched on a lightly normalised copy (lower-case, leetspeak folded, punctuation
-// stripped). Applied before model calls on transcript text and before rendering.
-const BANNED_PHRASES = [
- /\bkill\s+(?:your|ur)\s*self\b/i, /\bkys\b/i, /\bgo\s+(?:die|hang\s+yourself|drink\s+bleach)\b/i,
- /\b(?:i'?ll|i will|i'?m gonna|i am going to|gonna|going to|we'?ll|we will)\s+(?:kill|shoot|stab|murder|beat\s+(?:you|him|her|them)\s+to\s+death|find\s+and\s+kill|burn)\s+(?:you|him|her|them|your\s+family|his\s+family|her\s+family)\b/i,
- /\b(?:child|kid|minor|underage|preteen)\s*(?:porn|sex|nude|nudes)\b/i, /\bcp\s+(?:link|links|pics|vid|vids)\b/i, /\b(?:molest|rape)\s+(?:a\s+|that\s+|the\s+)?(?:kid|child|minor|baby)\b/i,
- /\b(?:home|house)\s+address\s+is\s+\d/i, /\b(?:his|her|their|your)\s+(?:real\s+)?(?:home\s+)?(?:address|phone\s+number|social\s+security(?:\s+number)?)\s+is\s+(?:\d|[a-z]+\s+\d)/i, /\bdox+(?:ed|ing|x)?\s+(?:him|her|them|you)\b/i,
-];
-function normalizeForFilter(text) {
- return String(text || '').toLowerCase()
- .replace(/[1!]/g, 'i').replace(/3/g, 'e').replace(/[4@]/g, 'a').replace(/0/g, 'o').replace(/\$/g, 's')
- .replace(/[^a-z\s']/g, ' ').replace(/\s+/g, ' ');
-}
-function isBannedText(text) {
- const raw = String(text || '');
- const norm = normalizeForFilter(raw);
- return BANNED_PHRASES.some(re => re.test(raw) || re.test(norm));
-}
-
-// ── Tables ───────────────────────────────────────────────────
-
-let _tablesReady = false;
-function ensureTables() {
- if (_tablesReady) return;
- try { fs.mkdirSync(ARENA_DIR, { recursive: true }); } catch { /* */ }
- _tablesReady = true;
-}
-
-// ── Settings ─────────────────────────────────────────────────
-
-async function setting(key, fallback = '') {
- try { const v = await db.getSetting(key); return v === undefined || v === null || v === '' ? fallback : v; } catch { return fallback; }
-}
-async function boolSetting(key, fallback = false) {
- const v = await setting(key, null);
- if (v === null) return fallback;
- return v === true || v === 'true' || v === 1 || v === '1';
-}
-async function arenaEnabled() { return await boolSetting('arena_enabled', true); }
-async function aiOn() { try { return await llm.isEnabled() && await llm.withinBudget(); } catch { return false; } }
-function imageGenAvailable() { return false; } // see "Image" below
-
-// ── Raw stats ────────────────────────────────────────────────
-
-/** The roster is whoever has been HEARD: transcribed speech in the last ACTIVE_DAYS days. */
-async function activeStreamerIds() {
- return (await db.all(`
- SELECT DISTINCT e.user_id FROM stream_timeline_events e JOIN users u ON u.id = e.user_id
- WHERE e.kind = 'speech' AND e.user_id IS NOT NULL AND e.created_at >= datetime('now', ?) AND COALESCE(u.is_banned, 0) = 0
- `, [`-${ACTIVE_DAYS} days`])).map(r => r.user_id);
-}
-
-async function voiceStatsFor(userId, win) {
- const hypeSql = HYPE_PATTERNS.map(() => "CASE WHEN text ILIKE ? THEN 1 ELSE 0 END").join(' + ');
- const hypeParams = HYPE_PATTERNS.map(p => `%${p}%`);
- let speech = {};
- try {
- speech = await db.get(`
- SELECT COUNT(*) AS lines,
- COALESCE(SUM(COALESCE(end_sec, start_sec + 3) - start_sec), 0)::bigint AS speech_sec,
- COALESCE(SUM(LENGTH(text) - LENGTH(REPLACE(text, ' ', '')) + 1), 0)::bigint AS words,
- COUNT(*) FILTER (WHERE text ILIKE '%!%') AS exclaims,
- COUNT(*) FILTER (WHERE text ILIKE '%?%') AS questions,
- COALESCE(SUM(${hypeSql}), 0)::bigint AS hype_hits,
- COUNT(DISTINCT stream_id) AS streams_heard
- FROM stream_timeline_events
- WHERE user_id = ? AND kind = 'speech' AND created_at >= datetime('now', ?)
- `, [...hypeParams, userId, win]) || {};
- } catch { speech = {}; }
- let covered = 0, laughs = 0, topSounds = [];
- try {
- covered = (await db.get(`SELECT COALESCE(SUM(duration_seconds), 0)::bigint AS sec FROM streams WHERE user_id = ? AND duration_seconds > 0 AND id IN (SELECT DISTINCT stream_id FROM stream_timeline_events WHERE user_id = ? AND created_at >= datetime('now', ?))`, [userId, userId, win]))?.sec || 0;
- laughs = (await db.get(`SELECT COUNT(*) AS n FROM stream_timeline_events WHERE user_id = ? AND kind = 'sound' AND (LOWER(label) ILIKE '%laugh%' OR LOWER(label) ILIKE '%giggle%' OR LOWER(label) ILIKE '%chuckle%') AND created_at >= datetime('now', ?)`, [userId, win]))?.n || 0;
- topSounds = await db.all(`SELECT label, COUNT(*) AS n FROM stream_timeline_events WHERE user_id = ? AND kind = 'sound' AND label IS NOT NULL AND created_at >= datetime('now', ?) GROUP BY label ORDER BY n DESC LIMIT 5`, [userId, win]);
- } catch { /* */ }
- const speechSec = Number(speech.speech_sec) || 0;
- const coveredHours = Math.max(covered / 3600, 0.05);
- const speechMin = Math.max(speechSec / 60, 0.1);
- const talkRatio = covered > 0 ? Math.min(1, speechSec / covered) : 0;
- const wpm = speech.words ? speech.words / speechMin : 0;
- const hypePerHour = (speech.hype_hits || 0) / coveredHours;
- const laughsPerHour = laughs / coveredHours;
- const hasData = (speech.lines || 0) >= 5;
- return {
- has_data: hasData, lines: speech.lines || 0, streams_heard: speech.streams_heard || 0,
- speech_minutes: Number((speechSec / 60).toFixed(1)), talk_ratio_pct: Number((talkRatio * 100).toFixed(1)),
- wpm: Number(wpm.toFixed(0)), words: speech.words || 0, exclaims: speech.exclaims || 0, questions: speech.questions || 0,
- hype_hits: speech.hype_hits || 0, hype_per_hour: Number(hypePerHour.toFixed(1)), laughs, laughs_per_hour: Number(laughsPerHour.toFixed(1)),
- top_sounds: topSounds.map(s => ({ label: s.label, n: s.n })),
- voice_score: hasData ? Number((talkRatio * 100 + Math.min(hypePerHour, 60) * 0.5 + Math.min(laughsPerHour, 30) + Math.min(wpm, 200) / 20).toFixed(2)) : 0,
- };
-}
-
-/** Everything the ratings are built from: the transcripts (voice) + the mic ledger (mic). No audience numbers. */
-async function rawStatsFor(userId) {
- const win = `-${STATS_WINDOW_DAYS} days`;
- const agg = await db.get(`SELECT COUNT(*) AS streams, (COALESCE(SUM(duration_seconds), 0) / 3600.0)::float8 AS hours, MAX(ended_at) AS last_live_at FROM streams WHERE user_id = ? AND duration_seconds > 0 AND started_at >= datetime('now', ?)`, [userId, win]) || {};
- const category = (await db.get(`SELECT COALESCE(NULLIF(ai_category, ''), category) AS category, COUNT(*) AS n FROM streams WHERE user_id = ? AND duration_seconds > 0 AND COALESCE(NULLIF(ai_category, ''), category) IS NOT NULL AND COALESCE(NULLIF(ai_category, ''), category) != '' GROUP BY 1 ORDER BY n DESC LIMIT 1`, [userId]))?.category || null;
- let micStats = {};
- try { micStats = await require('./mic').micStats(userId, MIC_WINDOW_DAYS); } catch { micStats = {}; }
- return {
- window_days: STATS_WINDOW_DAYS, mic_window_days: MIC_WINDOW_DAYS, streams: agg.streams || 0, hours: Number((agg.hours || 0).toFixed(1)),
- last_live_at: agg.last_live_at || null, category, voice: await voiceStatsFor(userId, win), mic: micStats,
- };
-}
-
-const METRIC_FOR_STAT = {
- heat: (r) => (r.mic ? r.mic.avg_quality : 0),
- aim: (r) => (r.mic ? r.mic.hits_per_mic_hour : 0),
- kills: (r) => (r.mic ? r.mic.wins : 0),
- mouth: (r) => (r.voice ? r.voice.talk_ratio_pct : 0),
- clapback: (r) => (r.mic ? r.mic.clapback_rate : 0),
- stamina: (r) => (r.voice ? r.voice.speech_minutes : 0),
- pace: (r) => (r.voice ? r.voice.wpm : 0),
-};
-
-/** Percentile ratings across a roster: rating = 40 + 59 × percentile; a roster of one is a flat 70. */
-function computeRatings(rosterRaw) {
- const ids = Object.keys(rosterRaw);
- const out = {};
- for (const id of ids) out[id] = {};
- for (const stat of STAT_KEYS) {
- const values = ids.map(id => METRIC_FOR_STAT[stat](rosterRaw[id]) || 0);
- const sorted = [...values].sort((a, b) => a - b);
- ids.forEach((id, i) => {
- const v = values[i];
- let pct;
- if (ids.length < 2) pct = 0.5;
- else { const below = sorted.filter(x => x < v).length; const equal = sorted.filter(x => x === v).length; pct = (below + (equal - 1) / 2) / (ids.length - 1); }
- out[id][stat] = Math.round(RATING_MIN + (99 - RATING_MIN) * Math.max(0, Math.min(1, pct)));
- });
- }
- for (const id of ids) out[id].power = Math.round(STAT_KEYS.reduce((sum, k) => sum + out[id][k] * STAT_WEIGHTS[k], 0));
- return out;
-}
-
-/** Trash Talk bonus on POWER: recent XP (7 days) + beef wins, capped. */
-async function talkBonus(userId) {
- try {
- const mic = require('./mic'), beef = require('./beef');
- return Math.min(TALK_BONUS_MAX, Math.round(await mic.recentXp(userId) / 25) + await beef.recentWins(userId) * 3);
- } catch { return 0; }
-}
-
-// ── Roster cache ─────────────────────────────────────────────
-
-let _roster = null;
-const ROSTER_TTL_MS = 3 * 60 * 1000;
-
-async function loadRoster(force = false) {
- if (!force && _roster && Date.now() - _roster.at < ROSTER_TTL_MS) return _roster;
- await ensureTables();
- const ids = await activeStreamerIds();
- const rawById = {};
- for (const id of ids) rawById[id] = await rawStatsFor(id);
- const ratings = computeRatings(rawById);
- const byId = {};
- for (const id of ids) {
- const user = await db.getUserById(id);
- if (!user) continue;
- const bonus = await talkBonus(id);
- ratings[id].base_power = ratings[id].power;
- ratings[id].talk_bonus = bonus;
- ratings[id].power = Math.min(99 + TALK_BONUS_MAX, ratings[id].power + bonus);
- byId[id] = { user: publicUser(user), raw: rawById[id], ratings: ratings[id] };
- try { await db.run('INSERT INTO arena_profiles (user_id, stats_json, updated_at) VALUES (?, ?, ov_now()) ON CONFLICT(user_id) DO UPDATE SET stats_json = excluded.stats_json, updated_at = ov_now()', [id, JSON.stringify({ raw: rawById[id], ratings: ratings[id] })]); } catch { /* */ }
- }
- const order = Object.keys(byId).map(Number).sort((a, b) => byId[b].ratings.power - byId[a].ratings.power || a - b);
- _roster = { at: Date.now(), byId, order };
- return _roster;
-}
-
-function publicUser(u) {
- return { id: u.id, username: u.username, display_name: u.display_name || u.username, avatar_url: u.avatar_url || null, profile_color: u.profile_color || null, bio: u.bio || '' };
-}
-
-// ── Persona (AI) ─────────────────────────────────────────────
-
-async function profileRow(userId) { await ensureTables(); return await db.get('SELECT * FROM arena_profiles WHERE user_id = ?', [userId]) || null; }
-function parseJson(text, fallback = null) { if (!text) return fallback; try { return JSON.parse(text); } catch { return fallback; } }
-function freshWithin(ts, ttl) { return !!ts && Date.now() - Date.parse(ts + 'Z') < ttl; }
-function personaIsFresh(row) { return !!(row && row.persona_json) && freshWithin(row.persona_generated_at, PERSONA_TTL_MS); }
-function quotesAreFresh(row) { return !!(row && row.quotes_json) && freshWithin(row.quotes_generated_at, QUOTES_TTL_MS); }
-
-async function gatherContext(userId) {
- const ctx = {};
- // Pure mic: the voice comes from what they SAY. Their most recent lines, their spiciest judged
- // lines, who they have called out, their beef record and what the camera saw are the facts.
- try { ctx.overview = (await db.getStreamerOverview(userId))?.overview || null; } catch { /* */ }
- try { ctx.memories = (await db.all('SELECT description FROM stream_memories WHERE user_id = ? ORDER BY captured_at DESC LIMIT 4', [userId])).map(m => m.description).filter(Boolean); } catch { ctx.memories = []; }
- try { ctx.titles = (await db.all('SELECT title FROM streams WHERE user_id = ? AND duration_seconds > 0 GROUP BY title ORDER BY MAX(started_at) DESC LIMIT 8', [userId])).map(r => r.title).filter(Boolean); } catch { ctx.titles = []; }
- try { ctx.said = (await db.all(`SELECT text FROM stream_timeline_events WHERE user_id = ? AND kind = 'speech' AND LENGTH(text) BETWEEN 30 AND 160 ORDER BY created_at DESC LIMIT 60`, [userId])).map(r => r.text).filter(t => !isBannedText(t)).slice(0, 30); } catch { ctx.said = []; }
- try {
- const mic = require('./mic');
- ctx.shit_talk = (await mic.bestLines(userId, 8)).map(m => `${m.text}${m.aimed_at ? ` [at ${m.aimed_at}]` : ''} (${m.quality}/10)`);
- ctx.called_out = (await Promise.all((await db.all(`SELECT target_user_id, COUNT(*) AS n FROM arena_mic_moments WHERE user_id = ? AND target_user_id IS NOT NULL GROUP BY target_user_id ORDER BY n DESC LIMIT 5`, [userId])).map(async r => `${await mic.nameOf(r.target_user_id)} (${r.n}×)`)));
- ctx.called_out_by = (await Promise.all((await db.all(`SELECT user_id, COUNT(*) AS n FROM arena_mic_moments WHERE target_user_id = ? GROUP BY user_id ORDER BY n DESC LIMIT 5`, [userId])).map(async r => `${await mic.nameOf(r.user_id)} (${r.n}×)`)));
- ctx.aimed_at = (await db.all(`SELECT aimed_at, COUNT(*) AS n FROM arena_mic_moments WHERE user_id = ? AND aimed_at IS NOT NULL AND target_user_id IS NULL GROUP BY aimed_at ORDER BY n DESC LIMIT 6`, [userId])).map(r => `${r.aimed_at} (${r.n}×)`);
- } catch { ctx.shit_talk = []; ctx.called_out = []; ctx.called_out_by = []; ctx.aimed_at = []; }
- try { const q = parseJson((await profileRow(userId))?.quotes_json); ctx.quotes = q && Array.isArray(q.picks) ? q.picks.map(p => p.text).slice(0, 6) : []; ctx.mic_style = q?.mic_style || null; } catch { ctx.quotes = []; }
- return ctx;
-}
-
-
-async function generatePersona(userId, { force = false } = {}) {
- await ensureTables();
- const row = await profileRow(userId);
- if (!force && personaIsFresh(row)) return parseJson(row.persona_json);
- if (!await aiOn()) return row ? parseJson(row.persona_json) : null;
- const roster = await loadRoster();
- const entry = roster.byId[userId] || { user: publicUser(await db.getUserById(userId) || { id: userId, username: `user${userId}` }), raw: await rawStatsFor(userId), ratings: null };
- const ctx = await gatherContext(userId);
- const stats = entry.ratings || Object.fromEntries(STAT_KEYS.map(k => [k, 70]).concat([['power', 70]]));
- const m = entry.raw.mic || {};
- const facts = {
- name: entry.user.display_name, handle: entry.user.username, category: entry.raw.category, ratings: stats,
- numbers: { hours_live_90d: entry.raw.hours,
- on_mic: entry.raw.voice.has_data ? { talk_share_pct: entry.raw.voice.talk_ratio_pct, words_per_minute: entry.raw.voice.wpm, speech_minutes: entry.raw.voice.speech_minutes, hype_words_per_hour: entry.raw.voice.hype_per_hour, laughs_per_hour: entry.raw.voice.laughs_per_hour, stream_sounds: entry.raw.voice.top_sounds.map(s => s.label) } : 'no transcript data yet',
- shit_talk_30d: { judged_moments: m.moments || 0, average_quality: m.avg_quality || 0, bangers_7_plus: m.bangers || 0, beef_hits: m.beef_hits || 0, beefs_won: m.wins || 0, beefs_lost: m.losses || 0, times_called_out: m.targeted || 0, times_answered: m.answered || 0 } },
- ai_overview: ctx.overview, recent_stream_titles: ctx.titles, what_the_camera_saw_recently: ctx.memories, things_they_said_on_stream: ctx.said,
- their_best_shit_talk: ctx.shit_talk || [], who_they_call_out: ctx.called_out || [], who_calls_them_out: ctx.called_out_by || [], what_they_rant_at: ctx.aimed_at || [], quotes: ctx.quotes || [], mic_style: ctx.mic_style || null,
- roster_rivals: await (async () => { try { return (await Promise.all((await loadRoster()).order.filter(id => id !== userId).slice(0, 8).map(async id => { const p = parseJson((await profileRow(id))?.persona_json); return `${(await loadRoster()).byId[id].user.username}${p?.fighter_name ? ` (${p.fighter_name})` : ''}`; }))); } catch { return []; } })(),
- };
- // The bio's rules and shape are OpenVibe.AI's versioned template live.arena.persona (WS-O task 2); Live sends the facts.
- const persona = await require('../ai/ai-service').structured('live.arena.persona', { facts }, { meter: { kind: 'arena_persona', role: 'summary', ownerUserId: userId, source: 'arena' } });
- if (!persona) { console.warn(`[Arena] persona generation failed for user ${userId}`); return row ? parseJson(row.persona_json) : null; }
- await db.run(`INSERT INTO arena_profiles (user_id, persona_json, persona_model, persona_generated_at, updated_at) VALUES (?, ?, ?, ov_now(), ov_now())
- ON CONFLICT(user_id) DO UPDATE SET persona_json = excluded.persona_json, persona_model = excluded.persona_model, persona_generated_at = ov_now(), updated_at = ov_now()`, [userId, JSON.stringify(persona), 'openvibe-ai:live.arena.persona']);
- console.log(`[Arena] persona for ${entry.user.username}: "${persona.fighter_name}" (${persona.class})`);
- return persona;
-}
-
-function fallbackPersona(entry) {
- const r = entry.ratings || {};
- const best = STAT_KEYS.reduce((a, b) => ((r[b] || 0) > (r[a] || 0) ? b : a), STAT_KEYS[0]);
- const cls = { heat: 'Rushdown', aim: 'Sniper', kills: 'Assassin', mouth: 'Caster', clapback: 'Counter', stamina: 'Tank', pace: 'Zoner' }[best];
- return {
- fighter_name: entry.user.display_name, title: `The ${STAT_META[best].label} Specialist`, class: cls,
- element: entry.raw.category ? entry.raw.category.replace(/[-_]/g, ' ') : 'Static',
- signature_move: { name: `${STAT_META[best].label} Surge`, description: `Turns ${STAT_META[best].desc} into raw damage.` },
- special: { name: 'Hot Mic', description: 'Forgets the mic is on. That is the move.' }, weakness: 'Dead air.', taunt: 'Say my name on stream and see what happens.',
- lore: `${entry.user.display_name} shows up, talks, and leaves the ladder slightly different than they found it.`,
- catchphrase: 'Let him cook.', entrance_music: 'Untitled Loop (feat. Notification Sound)', taunts: [], typing_style: null, spoken_as: [], custom_stats: [],
- stat_quips: Object.fromEntries(STAT_KEYS.map(k => [k, STAT_META[k].desc])), _fallback: true,
- };
-}
-
-// ── Quotes ───────────────────────────────────────────────────
-
-async function quoteCandidates(userId, limit = 90) {
- let rows = [];
- try { rows = await db.all(`SELECT id, stream_id, vod_id, start_sec, text FROM stream_timeline_events WHERE user_id = ? AND kind = 'speech' AND created_at >= datetime('now', ?) AND LENGTH(text) BETWEEN 25 AND 220 ORDER BY created_at DESC LIMIT 1500`, [userId, `-${STATS_WINDOW_DAYS} days`]); } catch { rows = []; }
- const score = (t) => { const s = t.toLowerCase(); let n = (t.match(/!/g) || []).length * 2 + (t.match(/\?/g) || []).length; for (const p of HYPE_PATTERNS) if (s.includes(p)) n += 3; if (/\b(i|we|you|chat)\b/.test(s)) n += 1; if (/\b(um+|uh+|like like)\b/.test(s)) n -= 1; return n; };
- const scored = rows.filter(r => !isBannedText(r.text)).map(r => ({ ...r, score: score(r.text) })).sort((x, y) => y.score - x.score);
- const picked = scored.slice(0, Math.ceil(limit / 2));
- const rest = scored.slice(Math.ceil(limit / 2));
- const step = Math.max(1, Math.floor(rest.length / Math.max(1, limit - picked.length)));
- for (let i = 0; i < rest.length && picked.length < limit; i += step) picked.push(rest[i]);
- return picked;
-}
-function materializeQuotes(candidates, sel) {
- const pick = (i, why) => { const c = candidates[i]; return c && !isBannedText(c.text) ? { text: c.text, stream_id: c.stream_id, vod_id: c.vod_id, start_sec: Math.max(0, Math.floor(Number(c.start_sec) || 0) - 2), why } : null; };
- const picks = (sel.picks || []).map(p => pick(p.index, p.why)).filter(Boolean);
- return { picks, walkout: pick(sel.walkout, 'walkout line') || picks[0] || null, voice_verdict: sel.voice_verdict || null, mic_style: sel.mic_style || null };
-}
-async function generateQuotes(userId, { force = false } = {}) {
- await ensureTables();
- const row = await profileRow(userId);
- if (!force && quotesAreFresh(row)) return parseJson(row.quotes_json);
- const candidates = await quoteCandidates(userId);
- if (candidates.length < MIN_QUOTE_LINES) return null;
- let result = null;
- if (await aiOn()) {
- try {
- // The picking rules are OpenVibe.AI's versioned template live.arena.quotes (WS-O task 2); Live sends the lines.
- const sel = await require('../ai/ai-service').structured('live.arena.quotes', { lines: candidates.map(c => String(c.text).slice(0, 400)).slice(0, 120) }, { meter: { kind: 'arena_quotes', role: 'summary', ownerUserId: userId, source: 'arena' } });
- if (sel && Array.isArray(sel.picks)) result = materializeQuotes(candidates, sel);
- } catch (e) { console.warn('[Arena] quotes:', e.message); }
- }
- if (!result || !result.picks.length) result = materializeQuotes(candidates, { picks: candidates.slice(0, 5).map((c, i) => ({ index: i, why: 'straight from the transcript' })), walkout: 0 });
- if (!await aiOn()) result._fallback = true;
- await db.run(`INSERT INTO arena_profiles (user_id, quotes_json, quotes_generated_at, updated_at) VALUES (?, ?, ov_now(), ov_now()) ON CONFLICT(user_id) DO UPDATE SET quotes_json = excluded.quotes_json, quotes_generated_at = ov_now(), updated_at = ov_now()`, [userId, JSON.stringify(result)]);
- return result;
-}
-
-// ── Image ────────────────────────────────────────────────────
-// Portraits drawn earlier stay on the cards; no new ones are drawn. They came from the site's image
-// provider called with the shared key, and Live calls no model provider itself any more (roadmap
-// WS-O task 2); OpenVibe.AI has no image workflow yet.
-
-function imageUrlFor(row) {
- if (!row || !row.image_path) return null;
- const base = path.basename(row.image_path);
- return fs.existsSync(path.join(ARENA_DIR, base)) ? `/data/arena/${base}` : null;
-}
-async function generateImage(userId) {
- await ensureTables();
- return imageUrlFor(await profileRow(userId));
-}
-
-// ── Fighter cards ────────────────────────────────────────────
-
-async function resolveUser(usernameOrId) {
- if (/^\d+$/.test(String(usernameOrId))) return await db.getUserById(Number(usernameOrId));
- return await db.get('SELECT * FROM users WHERE lower(username) = lower(?)', [String(usernameOrId)]);
-}
-async function isLive(userId) { return !!await db.get('SELECT 1 FROM streams WHERE user_id = ? AND is_live = 1 LIMIT 1', [userId]); }
-
-async function cardFor(userId, roster, { includeRaw = true, includeQuotes = false } = {}) {
- const entry = roster.byId[userId];
- if (!entry) return null;
- const row = await profileRow(userId);
- const persona = parseJson(row?.persona_json) || fallbackPersona(entry);
- const mic = require('./mic'), beef = require('./beef');
- const card = {
- user: entry.user, rank: roster.order.indexOf(userId) + 1, roster_size: roster.order.length,
- ratings: entry.ratings, stat_meta: STAT_META, raw: includeRaw ? entry.raw : undefined, voice: entry.raw.voice, mic: entry.raw.mic || null,
- persona, persona_is_fallback: !!persona._fallback, persona_generated_at: row?.persona_generated_at || null,
- image_url: imageUrlFor(row), image_prompt: row?.image_prompt || null, image_model: row?.image_model || null, image_pending: false,
- record: await beef.recordFor(userId), level: await mic.levelView(userId), live: await isLive(userId),
- };
- if (includeQuotes) card.quotes = parseJson(row?.quotes_json) || null;
- return card;
-}
-
-async function getFighter(usernameOrId, { generate = true } = {}) {
- const user = await resolveUser(usernameOrId);
- if (!user) return null;
- const roster = await loadRoster();
- if (!roster.byId[user.id]) return { user: publicUser(user), not_on_roster: true, reason: `nothing heard on mic in the last ${ACTIVE_DAYS} days — the Arena only knows what the transcription hears` };
- // Battle Cam: no persona / portrait / quote generation on view — the page shows only what
- // was said on mic (mic.js). `generate` is kept for the admin refresh route.
- if (generate === 'force') {
- try { await generatePersona(user.id); } catch (e) { console.warn('[Arena] persona:', e.message); }
- }
- const card = await cardFor(user.id, roster, { includeQuotes: true });
- card.image_pending = false;
- card.image_generation = imageGenAvailable() ? 'ai' : 'off';
- try { card.beefs = await require('./beef').forUser(user.id, 8); } catch { card.beefs = []; }
- return card;
-}
-
-async function listFighters() {
- const roster = await loadRoster();
- return (await Promise.all(roster.order.map(async id => {
- const c = await cardFor(id, roster, { includeRaw: false });
- return {
- user: c.user, rank: c.rank, ratings: c.ratings, record: c.record, live: c.live, image_url: c.image_url, level: { level: c.level.level, xp: c.level.xp }, mic: c.mic,
- persona: { fighter_name: c.persona.fighter_name, title: c.persona.title, class: c.persona.class, element: c.persona.element, taunt: c.persona.taunt, taunts: c.persona.taunts || [], typing_style: c.persona.typing_style || null, lore: c.persona.lore, signature_move: c.persona.signature_move, stat_quips: c.persona.stat_quips, custom_stats: Array.isArray(c.persona.custom_stats) ? c.persona.custom_stats : [] },
- persona_is_fallback: c.persona_is_fallback, category: roster.byId[id].raw.category, last_live_at: roster.byId[id].raw.last_live_at,
- voice: { has_data: c.voice.has_data, talk_ratio_pct: c.voice.talk_ratio_pct, speech_minutes: c.voice.speech_minutes, wpm: c.voice.wpm },
- last_line: await (async () => { try { const m = await require('./mic').latestFor(id); return m ? { text: m.text, quality: m.quality, aimed_at: m.aimed_at, target: m.target, at: m.at, vod_id: m.vod_id, sec: m.sec } : null; } catch { return null; } })(),
- };
- })));
-}
-
-async function getStatDetail(userId, stat) {
- if (!STAT_KEYS.includes(stat)) return null;
- const roster = await loadRoster();
- const entry = roster.byId[userId];
- if (!entry) return null;
- const win = `-${STATS_WINDOW_DAYS} days`;
- let series = [];
- try {
- // One point per stream in the window — every value comes from the transcript / mic ledger of that stream.
- const rows = (await db.all(`
- SELECT s.id, s.title, s.started_at, s.duration_seconds,
- (SELECT COALESCE(SUM(COALESCE(e.end_sec, e.start_sec + 3) - e.start_sec), 0)::bigint FROM stream_timeline_events e WHERE e.stream_id = s.id AND e.kind = 'speech') AS speech_sec,
- (SELECT COALESCE(SUM(LENGTH(e.text) - LENGTH(REPLACE(e.text, ' ', '')) + 1), 0)::bigint FROM stream_timeline_events e WHERE e.stream_id = s.id AND e.kind = 'speech') AS words,
- (SELECT COUNT(*) FROM arena_mic_moments m WHERE m.stream_id = s.id) AS moments,
- (SELECT COALESCE(AVG(m.quality), 0)::float8 FROM arena_mic_moments m WHERE m.stream_id = s.id) AS avg_q,
- (SELECT COUNT(*) FROM arena_mic_moments m WHERE m.stream_id = s.id AND m.kind = 'beef_hit') AS hits,
- (SELECT COUNT(*) FROM arena_beefs b WHERE b.winner_user_id = s.user_id AND b.resolved_at BETWEEN s.started_at AND COALESCE(s.ended_at, s.started_at)) AS wins
- FROM streams s
- WHERE s.user_id = ? AND s.duration_seconds > 0 AND s.started_at >= datetime('now', ?) ORDER BY s.started_at DESC LIMIT 14`, [userId, win])).reverse();
- const per = {
- heat: r => Number((r.avg_q || 0).toFixed(1)),
- aim: r => Number(((r.moments || 0) / Math.max((r.speech_sec || 0) / 3600, 0.05)).toFixed(2)),
- kills: r => r.wins || 0,
- mouth: r => (r.duration_seconds ? Number(((r.speech_sec || 0) / r.duration_seconds * 100).toFixed(1)) : 0),
- clapback: r => r.hits || 0,
- stamina: r => Number(((r.speech_sec || 0) / 60).toFixed(1)),
- pace: r => Number(((r.words || 0) / Math.max((r.speech_sec || 0) / 60, 0.1)).toFixed(0)),
- };
- series = rows.map(r => ({ stream_id: r.id, title: r.title, date: r.started_at, value: per[stat](r) }));
- } catch { series = []; }
- const unit = { heat: 'avg judge score', aim: 'hits / mic hour', kills: 'beefs won', mouth: '% of stream talking', clapback: 'answer rate', stamina: 'minutes of speech', pace: 'words / minute' }[stat];
- const shown = (raw) => (METRIC_FOR_STAT[stat](raw) || 0);
- const ranked = roster.order.map(id => ({ id, value: METRIC_FOR_STAT[stat](roster.byId[id].raw) || 0, shown: shown(roster.byId[id].raw), rating: roster.byId[id].ratings[stat] })).sort((x, y) => y.value - x.value);
- const position = ranked.findIndex(r => r.id === userId) + 1;
- const top = (await Promise.all(ranked.slice(0, 3).map(async r => ({ user: roster.byId[r.id].user, fighter_name: (parseJson((await profileRow(r.id))?.persona_json) || fallbackPersona(roster.byId[r.id])).fighter_name, value: Number(Number(r.shown).toFixed(1)), rating: r.rating }))));
- return { stat, label: STAT_META[stat].label, desc: STAT_META[stat].desc, unit, rating: entry.ratings[stat], value: Number(Number(shown(entry.raw)).toFixed(2)), position, roster_size: roster.order.length, weight: STAT_WEIGHTS[stat], series, top, voice: ['mouth', 'stamina', 'pace'].includes(stat) ? entry.raw.voice : undefined, mic: entry.raw.mic };
-}
-
-/** Live fighters with what the transcript last heard — the "on the mic now" strip. */
-async function liveFighters() {
- const roster = await loadRoster();
- let live = [];
- try { live = await db.getLiveStreams() || []; } catch { live = []; }
- const byUser = new Map();
- for (const s of live) { if (!roster.byId[s.user_id]) continue; const cur = byUser.get(s.user_id); if (!cur || (s.viewer_count || 0) > (cur.viewer_count || 0)) byUser.set(s.user_id, s); }
- let thumbs = null; try { thumbs = require('../media-proxy/live-thumbs'); } catch { /* */ }
- const mic = require('./mic'), beef = require('./beef');
- let listener = null; try { listener = require('./listener'); } catch { /* */ }
- return (await Promise.all([...byUser.values()].map(async s => {
- const c = await cardFor(s.user_id, roster, { includeRaw: false });
- let hotMic = null;
- try { const r = (await db.all(`SELECT text, start_sec, vod_id FROM stream_timeline_events WHERE stream_id = ? AND kind = 'speech' AND LENGTH(text) > 15 ORDER BY start_sec DESC LIMIT 5`, [s.id])).find(row => !isBannedText(row.text)); if (r) hotMic = { text: r.text, start_sec: Math.floor(r.start_sec), vod_id: r.vod_id || null }; } catch { /* */ }
- const transcribed = !!await db.get(`SELECT 1 FROM stream_timeline_events WHERE stream_id = ? AND kind = 'speech' AND created_at >= datetime('now', '-30 minutes') LIMIT 1`, [s.id]);
- let ears = null; try { const cs = listener ? await listener.consoleState(s.user_id) : null; if (cs && cs.listening) ears = { focus: cs.focus ? { target_id: cs.focus.target_id, target: cs.focus.target, hits: cs.focus.hits, lock_seconds_left: cs.focus.lock_seconds_left } : null, pending_words: cs.pending_mic_words + (cs.focus ? cs.focus.pending_words : 0) }; } catch { ears = null; }
- let lastMoment = null; try { lastMoment = await mic.latestFor(s.user_id); } catch { lastMoment = null; }
- return {
- user: c.user, rank: c.rank, ratings: c.ratings, record: c.record, image_url: c.image_url, level: c.level.level,
- persona: { fighter_name: c.persona.fighter_name, title: c.persona.title, class: c.persona.class, taunt: c.persona.taunt },
- stream: { id: s.id, title: s.title, category: s.category, viewer_count: s.viewer_count || 0, started_at: s.started_at, slug: s.managed_stream_slug || null, managed_stream_id: s.managed_stream_id || null },
- thumbnail_url: thumbs ? ((await thumbs.getCurrentLiveThumbnailUrl(s.id)) || null) : null,
- hot_mic: hotMic, transcribed, ears, last_moment: lastMoment, open_beefs: (await beef.openBeefsFor(s.user_id)).length,
- };
- }))).sort((x, y) => y.ratings.power - x.ratings.power);
-}
-
-async function voterKeyFor(req) {
- if (req.user && req.user.id) return `user:${req.user.id}`;
- const ip = String(req.ip || req.headers['x-forwarded-for'] || req.socket?.remoteAddress || '');
- const salt = String(await setting('arena_vote_salt', '') || process.env.JWT_SECRET || 'arena');
- return `anon:${crypto.createHash('sha256').update(ip + '|' + salt).digest('hex').slice(0, 24)}`;
-}
-
-async function status() {
- await ensureTables();
- const roster = await loadRoster();
- const counts = await db.get(`SELECT COUNT(*) FILTER (WHERE persona_json IS NOT NULL) AS personas, COUNT(*) FILTER (WHERE image_path IS NOT NULL) AS images, COUNT(*) FILTER (WHERE quotes_json IS NOT NULL) AS quotes FROM arena_profiles`) || {};
- let beefs = {}, moments = 0;
- try { beefs = await db.get(`SELECT COUNT(*) FILTER (WHERE status = 'open') AS open, COUNT(*) FILTER (WHERE status = 'resolved') AS resolved FROM arena_beefs`) || {}; moments = (await db.get(`SELECT COUNT(*) AS n FROM arena_mic_moments WHERE created_at >= datetime('now', '-1 day')`))?.n || 0; } catch { /* */ }
- return {
- mode: 'battle-cam', enabled: await arenaEnabled(), ai: await aiOn(), image_generation: imageGenAvailable(), image_model: null,
- roster: roster.order.length, with_voice_data: roster.order.filter(id => roster.byId[id].raw.voice.has_data).length,
- personas: counts.personas || 0, quotes: counts.quotes || 0, images: counts.images || 0,
- beefs_open: beefs.open || 0, beefs_resolved: beefs.resolved || 0, mic_moments_24h: moments, live_fighters: (await liveFighters()).length,
- listener: (() => { try { return require('./listener').TICK_MS; } catch { return null; } })(), active_days: ACTIVE_DAYS,
- };
-}
-
-module.exports = {
- ensureTables, arenaEnabled, aiOn, imageGenAvailable, loadRoster, listFighters, getFighter, getStatDetail, liveFighters,
- generatePersona, generateQuotes, generateImage, voterKeyFor, status, publicUser,
- getFighterImageUrl: async (userId) => imageUrlFor(await profileRow(userId)),
- STAT_KEYS, STAT_META, STAT_WEIGHTS, ARENA_DIR, TALK_BONUS_MAX, ACTIVE_DAYS, MIC_WINDOW_DAYS,
- _computeRatings: computeRatings, _fallbackPersona: fallbackPersona, _voiceStatsFor: voiceStatsFor, _quoteCandidates: quoteCandidates, _isBannedText: isBannedText, _talkBonus: talkBonus,
-};
deleted file mode 100644
deleted file mode 100644
@@ -1,132 +0,0 @@
@@ -1,132 +0,0 @@
-/**
- * OpenVibe.Live — Arena backfill: judge what was ALREADY said on mic.
- *
- * The listener only hears streams that are live right now. Everything a fighter said before
- * (the transcripts already in stream_timeline_events) is judged here, so the Arena reflects
- * the shit talk on record the moment it ships, not weeks later:
- *
- * 1. every roster fighter's speech from the last DAYS days is read in stream order and cut
- * into ~60 s chunks (JUDGE_MIN_WORDS … CHUNK_MAX_WORDS, a gap > CHUNK_GAP_SEC starts a
- * new chunk);
- * 2. a chunk is only sent to a model if it looks like it could be shit talk (the SPICY
- * keyword pre-filter) or names another fighter — gameplay narration never costs a call;
- * 3. a name-drop chunk goes to the beef judge (no clocks / no beefs are opened for old
- * speech — it lands as a `callout` moment with the target attached); everything else
- * goes to the mic judge → `trash` moment. Both pay Trash Level XP.
- *
- * A per-fighter cursor (site setting `arena_backfill_cursor_<userId>` = last timeline row id
- * judged) makes every run incremental, so this also catches up anything the live listener
- * missed. Bounded per run (MAX_CALLS_PER_RUN model calls) so a big backlog drains over a few
- * runs instead of burning the budget at once.
- */
-'use strict';
-
-const db = require('../db/database');
-
-const DAYS = 7;
-const CHUNK_GAP_SEC = 25;
-const CHUNK_MAX_WORDS = 110;
-const MAX_CALLS_PER_RUN = 160;
-const MAX_CHUNKS_PER_FIGHTER = 80;
-
-function arena() { return require('./arena-service'); }
-function listener() { return require('./listener'); }
-function mic() { return require('./mic'); }
-function words(t) { return String(t || '').split(/\s+/).filter(Boolean).length; }
-function cursorKey(uid) { return `arena_backfill_cursor_${uid}`; }
-async function getCursor(uid) { const v = parseInt(await db.getSetting(cursorKey(uid)), 10); return Number.isFinite(v) ? v : 0; }
-async function setCursor(uid, id) { try { await db.setSetting(cursorKey(uid), String(id)); } catch { /* */ } }
-function sqlToIso(ts) { return ts ? String(ts).replace(' ', 'T') + (String(ts).endsWith('Z') ? '' : 'Z') : null; }
-
-/** Cut a fighter's speech rows into judge-sized chunks. */
-function chunk(rows) {
- const out = [];
- let cur = null;
- for (const r of rows) {
- const w = words(r.text);
- if (!w) continue;
- const gap = cur && (r.stream_id !== cur.stream_id || r.start_sec - cur.end > CHUNK_GAP_SEC);
- if (!cur || gap || cur.words + w > CHUNK_MAX_WORDS) {
- if (cur && cur.words >= listener().JUDGE_MIN_WORDS) out.push(cur);
- cur = { stream_id: r.stream_id, vod_id: r.vod_id, started_at: r.started_at, start: r.start_sec, end: r.end_sec || r.start_sec + 3, lines: [], words: 0, lastId: r.id };
- }
- cur.lines.push({ t: String(r.text), s: Math.floor(r.start_sec), v: r.vod_id || null, id: r.id });
- cur.words += w; cur.end = r.end_sec || r.start_sec + 3; cur.lastId = r.id;
- }
- if (cur && cur.words >= listener().JUDGE_MIN_WORDS) out.push(cur);
- return out;
-}
-
-function saidAtFor(c, sec) {
- const base = c.started_at ? Date.parse(sqlToIso(c.started_at)) : NaN;
- if (!Number.isFinite(base)) return null;
- return new Date(base + Math.max(0, sec || 0) * 1000).toISOString().replace('T', ' ').slice(0, 19);
-}
-
-async function backfillFighter(uid, roster, budget) {
- const L = listener();
- const since = `-${DAYS} days`;
- const rows = await db.all(`SELECT e.id, e.stream_id, e.vod_id, e.start_sec, e.end_sec, e.text, s.started_at
- FROM stream_timeline_events e LEFT JOIN streams s ON s.id = e.stream_id
- WHERE e.user_id = ? AND e.kind = 'speech' AND e.id > ? AND e.created_at >= datetime('now', ?)
- AND NOT EXISTS (SELECT 1 FROM streams l WHERE l.id = e.stream_id AND l.is_live = 1)
- ORDER BY e.stream_id, e.start_sec LIMIT 4000`, [uid, await getCursor(uid), since]);
- if (!rows.length) return { judged: 0, moments: 0, skipped: 0 };
- const chunks = chunk(rows).slice(0, MAX_CHUNKS_PER_FIGHTER);
- let judged = 0, moments = 0, skipped = 0, lastId = await getCursor(uid);
- for (const c of chunks) {
- if (budget.calls >= MAX_CALLS_PER_RUN) break;
- const text = c.lines.map(l => l.t).join(' ').replace(/\s+/g, ' ').slice(-1400);
- const mentions = await L._mentionsDetailed(text, uid, roster);
- const spicy = L.SPICY.test(text.toLowerCase());
- lastId = Math.max(lastId, c.lastId);
- if (!mentions.length && !spicy) { skipped++; continue; }
- budget.calls++; judged++;
- const ref = (bestLine) => { const needle = String(bestLine || '').toLowerCase().slice(0, 30); const hit = (needle && c.lines.find(l => l.t.toLowerCase().includes(needle))) || c.lines[0]; return { vod_id: hit ? hit.v : null, sec: hit ? Math.max(0, hit.s - 2) : null }; };
- try {
- if (mentions.length) {
- const m = mentions[0];
- const j = await L._judgeBeef(uid, m.userId, text, roster, { context: null, named: true, how: m.how });
- if (j.aimed_at_target && j.quality >= (m.how === 'exact' ? 3 : 5)) {
- const r = ref(j.best_line);
- if (await mic().addMoment({ userId: uid, streamId: c.stream_id, vodId: r.vod_id, sec: r.sec, kind: 'callout', targetUserId: m.userId, aimedAt: await mic().nameOf(m.userId), text: j.best_line || text.slice(0, 220), about: j.about, quality: j.quality, announcer: j.announcer, saidAt: saidAtFor(c, r.sec) })) moments++;
- continue;
- }
- if (j.about_target) continue; // neutral mention — not shit talk, and not free talk either
- }
- const j = await L._judgeMic(uid, text);
- if (j.is_trash_talk && j.quality >= L.MIC_MIN_QUALITY && !await mic().isDuplicate(uid, j.best_line)) {
- const r = ref(j.best_line);
- // Nobody was named (that went to the beef judge above): a fighter the judge guesses at is never published.
- if (await mic().addMoment({ userId: uid, streamId: c.stream_id, vodId: r.vod_id, sec: r.sec, kind: 'trash', aimedAt: await L._aimedAtUnnamed(j.aimed_at, uid, roster), text: j.best_line || text.slice(0, 220), about: j.about, quality: j.quality, announcer: j.announcer, saidAt: saidAtFor(c, r.sec) })) moments++;
- }
- } catch (e) { console.warn(`[Arena] backfill judge (user ${uid}):`, e.message); }
- }
- await setCursor(uid, lastId);
- return { judged, moments, skipped, chunks: chunks.length };
-}
-
-let _busy = false;
-/** Judge un-judged past speech for every roster fighter. Bounded; safe to call often. */
-async function run({ force = false } = {}) {
- if (_busy) return { busy: true };
- if (!await arena().arenaEnabled()) return { disabled: true };
- _busy = true;
- const started = Date.now();
- const budget = { calls: 0 };
- const out = { fighters: 0, judged: 0, moments: 0, skipped: 0, calls: 0 };
- try {
- const roster = await arena().loadRoster(force);
- for (const uid of roster.order) {
- if (budget.calls >= MAX_CALLS_PER_RUN) break;
- const r = await backfillFighter(uid, roster, budget);
- out.fighters++; out.judged += r.judged; out.moments += r.moments; out.skipped += r.skipped;
- }
- out.calls = budget.calls;
- if (out.judged || out.moments) console.log(`[Arena] backfill: ${out.moments} moment(s) from ${out.judged} judged chunk(s) across ${out.fighters} fighter(s) (${out.skipped} skipped as not spicy, ${Math.round((Date.now() - started) / 1000)} s)`);
- if (out.moments) { try { await arena().loadRoster(true); } catch { /* */ } }
- return out;
- } finally { _busy = false; }
-}
-
-module.exports = { run, chunk, DAYS, MAX_CALLS_PER_RUN, _backfillFighter: backfillFighter };
deleted file mode 100644
deleted file mode 100644
@@ -1,284 +0,0 @@
@@ -1,284 +0,0 @@
-/**
- * OpenVibe.Live — Arena Beefs (speech-driven streamer vs streamer)
- *
- * Nobody clicks "battle". The listener (listener.js) hears a live fighter talking trash
- * about ANOTHER fighter by name; the judge confirms it was aimed at them; a beef opens:
- *
- * score → every judged hit aimed at the other side adds its quality (0–10) to
- * your side of the tug-of-war. Chat adds crowd points with `!hype` in
- * your chat (one per person per beef, max CROWD_MAX) — that is the ONLY
- * thing chat can do; every point of substance comes from the mic.
- * clock → after every hit the OTHER side is on the clock: RESPONSE_LIVE_MIN
- * minutes if they are live, RESPONSE_OFFLINE_HOURS hours if not (the
- * clock tightens to the live window the moment they go live). Miss the
- * clock and you ducked — forfeit.
- * end → a beef also hard-ends MAX_BEEF_HOURS after it opened: higher score
- * wins, equal is a draw. Records (W–L) come from here.
- * pending → a beef is public only once both sides have named each other. One streamer naming
- * another opens it `pending`: the named person is told privately, and nothing shows on
- * the Arena, in ARENA ALERT or in chat. Their answer on their own stream (a judged hit
- * naming the caller) makes it `open`. Unanswered, it ends `expired`: no forfeit, no
- * story, no record. One-sided matches were friendly greetings ("Maticus, our only viewer")
- * and transcripts ("the Adam family"), 2026-10-18.
- */
-'use strict';
-
-const db = require('../db/database');
-
-const RESPONSE_LIVE_MIN = 15;
-const RESPONSE_OFFLINE_HOURS = 24;
-const MAX_BEEF_HOURS = 24;
-const CROWD_MAX = 10;
-const FEED_MAX = 40;
-const XP_BEEF_HIT = 1.0; // × quality
-const XP_BEEF_WIN = 40;
-const XP_BEEF_OPEN = 5;
-
-
-function parseJson(t, f = null) { try { return t ? JSON.parse(t) : f; } catch { return f; } }
-function arena() { return require('./arena-service'); }
-function mic() { return require('./mic'); }
-async function isLive(userId) { return !!await db.get('SELECT 1 FROM streams WHERE user_id = ? AND is_live = 1 LIMIT 1', [userId]); }
-function nowIso() { return new Date().toISOString(); }
-async function nameOf(userId) { return await mic().nameOf(userId); }
-async function headlineFor(kind, ctx) {
- try {
- const llm = require('../ai/llm');
- if (!(await llm.isEnabled() && await llm.withinBudget())) return null;
- // The headline rules are OpenVibe.AI's versioned template live.arena.headline (WS-O task 2).
- const event = kind === 'open' || kind === 'forfeit' ? kind : 'score'; // a draw or a points win is a score
- const out = await require('../ai/ai-service').structured('live.arena.headline', { kind: event, ...ctx }, { meter: { kind: 'arena_headline', role: 'chat', source: 'arena' } });
- if (!out || !out.headline) return null;
- // No accusation the plain headline does not make (drama.js addsAccusation; a quote never licenses one).
- if (require('./drama').addsAccusation(out.headline, templateHeadline(event, { ...ctx, line: null }))) return null;
- return String(out.headline).slice(0, 120);
- } catch { return null; }
-}
-function templateHeadline(kind, ctx) {
- if (kind === 'open') return `${ctx.a} has words for ${ctx.b}${ctx.rematch ? ' — REMATCH' : ''}${ctx.line ? `: “${String(ctx.line).slice(0, 60)}”` : ''}`;
- if (kind === 'forfeit') return `${ctx.loser} DUCKED. ${ctx.winner} wins by silence.`;
- if (kind === 'score') return ctx.winner ? `${ctx.winner} out-talks ${ctx.loser} ${ctx.score_a}–${ctx.score_b}${ctx.upset ? ' — UPSET' : ''}` : `${ctx.a} and ${ctx.b} talk themselves into a draw`;
- return null;
-}
-function sqlDate(ms) { return new Date(ms).toISOString().replace('T', ' ').slice(0, 19); }
-
-// What everyone sees: a beef both sides are in, or one that ended. Pending, expired and void ones are no one's business.
-const PUBLIC = "status IN ('open', 'resolved')";
-
-/** The beef in play between two fighters, pending or open (recordHit's own view). */
-async function activeBeefBetween(u1, u2) {
- return await db.get(`SELECT * FROM arena_beefs WHERE status IN ('pending', 'open') AND ((a_user_id = ? AND b_user_id = ?) OR (a_user_id = ? AND b_user_id = ?))`, [u1, u2, u2, u1]) || null;
-}
-
-async function openBeefBetween(u1, u2) {
- return await db.get(`SELECT * FROM arena_beefs WHERE status = 'open' AND ((a_user_id = ? AND b_user_id = ?) OR (a_user_id = ? AND b_user_id = ?))`, [u1, u2, u2, u1]) || null;
-}
-async function openBeefsFor(userId) {
- return await db.all(`SELECT * FROM arena_beefs WHERE status = 'open' AND (a_user_id = ? OR b_user_id = ?) ORDER BY opened_at DESC`, [userId, userId]);
-}
-
-async function clockFor(targetId, fromMs = Date.now()) {
- return sqlDate(fromMs + (await isLive(targetId) ? RESPONSE_LIVE_MIN * 60 * 1000 : RESPONSE_OFFLINE_HOURS * 3600 * 1000));
-}
-
-function pushFeed(beef, event) {
- const feed = parseJson(beef.feed_json, []);
- feed.push({ at: nowIso(), ...event });
- return JSON.stringify(feed.slice(-FEED_MAX));
-}
-
-/**
- * A judged hit: `speakerId` talked trash aimed at `targetId` (both on the roster).
- * Opens the beef if needed, scores it, and puts the other side on the clock.
- */
-async function recordHit(speakerId, targetId, hit) {
- if (speakerId === targetId) return null;
- let beef = await activeBeefBetween(speakerId, targetId);
- let opened = false;
- if (!beef) {
- const endsAt = sqlDate(Date.now() + MAX_BEEF_HOURS * 3600 * 1000);
- const history = await rivalry(speakerId, targetId);
- await db.run(`INSERT INTO arena_beefs (a_user_id, b_user_id, status, on_clock, clock_until, ends_at, feed_json, opener_line, rematch, bounty_topic_id, headline) VALUES (?, ?, 'pending', 'b', ?, ?, '[]', ?, ?, NULL, ?)`,
- [speakerId, targetId, await clockFor(targetId), endsAt, String(hit.best_line || '').slice(0, 220), history.fights > 0 ? 1 : 0, templateHeadline('open', { a: await nameOf(speakerId), b: await nameOf(targetId), line: hit.best_line, rematch: history.fights > 0 })]);
- beef = await activeBeefBetween(speakerId, targetId);
- opened = true;
- console.log(`[Arena] beef #${beef.id} pending: user ${speakerId} named user ${targetId}${history.fights ? ' (rematch)' : ''}`);
- // Told privately: it becomes a beef only if they name the caller back on their own stream.
- try { require('./notify').arenaNotify(targetId, { type: 'beef_open', title: `${await nameOf(speakerId)} said your name on stream`, message: `${hit.best_line ? `“${String(hit.best_line).slice(0, 120)}” — ` : ''}say theirs back on your own stream within ${await isLive(targetId) ? `${RESPONSE_LIVE_MIN} min` : `${RESPONSE_OFFLINE_HOURS} h`} to start a beef.`, icon: '🥊', url: '/arena', key: `open:${beef.id}`, senderId: speakerId, senderName: await nameOf(speakerId) }); } catch { /* */ }
- }
- const side = beef.a_user_id === speakerId ? 'a' : 'b';
- const other = side === 'a' ? 'b' : 'a';
- const otherId = side === 'a' ? beef.b_user_id : beef.a_user_id;
- const quality = Math.max(0, Math.min(10, Number(hit.quality) || 0));
- const firstResponse = side === 'b' && !beef.responded;
- const pending = beef.status === 'pending';
- // The named person answered: the beef is on, and public from here.
- const goesOpen = pending && firstResponse;
- const feed = pushFeed(beef, { kind: opened ? 'open' : (firstResponse ? 'respond' : 'hit'), side, text: String(hit.best_line || '').slice(0, 220), quality, about: hit.about || null, announcer: hit.announcer || null, vod_id: hit.vod_id || null, sec: hit.sec ?? null });
- await db.run(`UPDATE arena_beefs SET score_${side} = score_${side} + ?, hits_${side} = hits_${side} + 1, last_${side}_at = ov_now(), responded = CASE WHEN ? THEN 1 ELSE responded END,
- status = CASE WHEN ? THEN 'open' ELSE status END, on_clock = ?, clock_until = ?, feed_json = ? WHERE id = ?`,
- [quality, side === 'b' ? 1 : 0, goesOpen ? 1 : 0, other, await clockFor(otherId), feed, beef.id]);
- if (pending && !goesOpen) return { beef: await db.get('SELECT * FROM arena_beefs WHERE id = ?', [beef.id]), opened, pending: true, side, first_response: false };
- if (goesOpen) {
- console.log(`[Arena] beef #${beef.id} on: user ${speakerId} answered user ${otherId}`);
- const aName = await nameOf(beef.a_user_id), bName = await nameOf(beef.b_user_id);
- await mic().addXp(beef.a_user_id, XP_BEEF_OPEN, 'beef_open', beef.id);
- require('./drama').beefStory('open', { id: beef.id, a_user_id: beef.a_user_id, b_user_id: beef.b_user_id }, { about: hit.about, opening_line: beef.opener_line }).catch(() => {});
- headlineFor('open', { a: aName, b: bName, opening_line: beef.opener_line, about: hit.about, rematch: !!beef.rematch, record_between: await rivalry(beef.a_user_id, beef.b_user_id) }).then(async h => { if (h) await db.run('UPDATE arena_beefs SET headline = ? WHERE id = ?', [h, beef.id]); }).catch(() => {});
- }
- await mic().addXp(speakerId, quality * XP_BEEF_HIT, 'beef_hit', beef.id, { beefHit: true, line: hit.best_line, lineScore: quality, lineVodId: hit.vod_id, lineSec: hit.sec });
- // The hit is also a line in the shit-talk feed (VOD-linked, with who it was aimed at).
- try { await mic().addMoment({ userId: speakerId, streamId: hit.stream_id || null, vodId: hit.vod_id || null, sec: hit.sec ?? null, kind: 'beef_hit', targetUserId: targetId, beefId: beef.id, aimedAt: await nameOf(targetId), text: hit.best_line || hit.about || '', about: hit.about || null, quality, announcer: hit.announcer || null }); } catch { /* */ }
- { try { require('./notify').arenaNotify(otherId, { type: firstResponse ? 'beef_answered' : 'beef_hit', title: `${await nameOf(speakerId)} ${firstResponse ? 'answered' : 'hit back'} (${quality}/10)`, message: `${hit.best_line ? `“${String(hit.best_line).slice(0, 120)}” — ` : ''}you're on the clock now.`, icon: '🔥', url: `/arena/beef/${beef.id}`, key: `hit:${beef.id}:${beef.hits_a + beef.hits_b}`, senderId: speakerId, senderName: await nameOf(speakerId) }); } catch { /* */ } }
- return { beef: await db.get('SELECT * FROM arena_beefs WHERE id = ?', [beef.id]), opened: goesOpen, side, first_response: firstResponse };
-}
-
-async function hype(beefId, side, voterKey) {
- const beef = await db.get('SELECT * FROM arena_beefs WHERE id = ? AND status = ?', [beefId, 'open']);
- if (!beef) throw new Error('No open beef with that id');
- if (!['a', 'b'].includes(side)) throw new Error('side must be a or b');
- const sideUser = side === 'a' ? beef.a_user_id : beef.b_user_id;
- if (voterKey === `user:${sideUser}`) throw new Error("You can't hype yourself");
- const ins = await db.run('INSERT INTO arena_beef_hype (beef_id, side, voter_key) VALUES (?, ?, ?) ON CONFLICT DO NOTHING', [beefId, side, voterKey]);
- const n = (await db.get('SELECT COUNT(*) AS n FROM arena_beef_hype WHERE beef_id = ? AND side = ?', [beefId, side]))?.n || 0;
- await db.run(`UPDATE arena_beefs SET crowd_${side} = ? WHERE id = ?`, [Math.min(CROWD_MAX, n), beefId]);
- if (ins.changes) await mic().addXp(sideUser, mic().XP_HYPE, 'hype', beefId);
- return { added: !!ins.changes, hypers: n, crowd: Math.min(CROWD_MAX, n) };
-}
-
-function totals(beef) {
- return { a: Number(((beef.score_a || 0) + (beef.crowd_a || 0)).toFixed(1)), b: Number(((beef.score_b || 0) + (beef.crowd_b || 0)).toFixed(1)) };
-}
-
-async function resolve(beef, resolution, winnerId) {
- const loserId = winnerId == null ? null : (winnerId === beef.a_user_id ? beef.b_user_id : beef.a_user_id);
- let upset = 0;
- try { const roster = await arena().loadRoster(); if (winnerId && loserId) { const rw = roster.order.indexOf(winnerId), rl = roster.order.indexOf(loserId); upset = rw >= 0 && rl >= 0 && rw - rl >= 4 ? 1 : 0; } } catch { /* */ }
- const t = totals(beef);
- const ctx = { a: await nameOf(beef.a_user_id), b: await nameOf(beef.b_user_id), winner: winnerId ? await nameOf(winnerId) : null, loser: loserId ? await nameOf(loserId) : null, score_a: t.a, score_b: t.b, upset: !!upset, streak: winnerId ? await streakFor(winnerId) + 1 : 0 };
- const feed = pushFeed(beef, { kind: resolution === 'forfeit' ? 'forfeit' : 'end', side: winnerId == null ? null : (winnerId === beef.a_user_id ? 'a' : 'b'), text: null, upset: !!upset });
- await db.run(`UPDATE arena_beefs SET status = 'resolved', resolution = ?, winner_user_id = ?, resolved_at = ov_now(), on_clock = NULL, clock_until = NULL, feed_json = ?, upset = ?, result_headline = ? WHERE id = ? AND status = 'open'`, [resolution, winnerId, feed, upset, templateHeadline(resolution === 'forfeit' ? 'forfeit' : 'score', ctx), beef.id]);
- if (winnerId) 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 { /* */ }
-}
-
-/** Current win streak (consecutive resolved beefs won). */
-async function streakFor(userId) {
- let n = 0;
- for (const b of await db.all(`SELECT winner_user_id FROM arena_beefs WHERE status = 'resolved' AND (a_user_id = ? OR b_user_id = ?) ORDER BY resolved_at DESC LIMIT 20`, [userId, userId])) { if (b.winner_user_id === userId) n++; else break; }
- return n;
-}
-
-/** Rivalry between two fighters: fights, record, receipts (best lines from earlier beefs). */
-async function rivalry(u1, u2) {
- const rows = await db.all(`SELECT * FROM arena_beefs WHERE status = 'resolved' AND ((a_user_id = ? AND b_user_id = ?) OR (a_user_id = ? AND b_user_id = ?)) ORDER BY resolved_at DESC LIMIT 10`, [u1, u2, u2, u1]);
- const wins1 = rows.filter(r => r.winner_user_id === u1).length, wins2 = rows.filter(r => r.winner_user_id === u2).length;
- const receipts = [];
- for (const r of rows.slice(0, 3)) for (const e of parseJson(r.feed_json, [])) if (e.text && e.quality >= 6) receipts.push({ beef_id: r.id, side_user_id: e.side === 'a' ? r.a_user_id : r.b_user_id, text: e.text, quality: e.quality, vod_id: e.vod_id, sec: e.sec, at: e.at });
- receipts.sort((x, y) => y.quality - x.quality);
- return { fights: rows.length, wins_1: wins1, wins_2: wins2, last: rows[0] ? { id: rows[0].id, winner_user_id: rows[0].winner_user_id, resolution: rows[0].resolution, at: rows[0].resolved_at } : null, receipts: receipts.slice(0, 4) };
-}
-
-async function rivalriesFor(userId, limit = 5) {
- const opps = await db.all(`SELECT CASE WHEN a_user_id = ? THEN b_user_id ELSE a_user_id END AS opp, COUNT(*) AS n FROM arena_beefs WHERE (a_user_id = ? OR b_user_id = ?) AND ${PUBLIC} GROUP BY 1 HAVING COUNT(*) >= 2 ORDER BY n DESC LIMIT ?`, [userId, userId, userId, limit]);
- const b = mic();
- const roster = await arena().loadRoster();
- return (await Promise.all(opps.map(async o => { const r = await rivalry(userId, o.opp); return { opponent: await b.fighterBrief(o.opp, roster), fights: r.fights + (await openBeefBetween(userId, o.opp) ? 1 : 0), wins: r.wins_1, losses: r.wins_2, open: !!await openBeefBetween(userId, o.opp), receipts: r.receipts.slice(0, 2) }; })));
-}
-
-/** Ticker: enforce clocks and hard ends; tighten an offline clock when the target goes live. */
-async function tick() {
- const now = Date.now();
- // A callout nobody answered in time ends quietly: no forfeit, no story, no record.
- for (const beef of await db.all(`SELECT id, clock_until, ends_at FROM arena_beefs WHERE status = 'pending'`)) {
- const until = beef.clock_until ? Date.parse(beef.clock_until + 'Z') : 0;
- if (until <= now || (beef.ends_at && Date.parse(beef.ends_at + 'Z') <= now)) await db.run("UPDATE arena_beefs SET status = 'expired', on_clock = NULL, clock_until = NULL WHERE id = ? AND status = 'pending'", [beef.id]);
- }
- for (const beef of await db.all(`SELECT * FROM arena_beefs WHERE status = 'open'`)) {
- const t = totals(beef);
- if (beef.ends_at && Date.parse(beef.ends_at + 'Z') <= now) {
- await resolve(beef, 'score', t.a === t.b ? null : (t.a > t.b ? beef.a_user_id : beef.b_user_id));
- continue;
- }
- if (beef.on_clock && beef.clock_until) {
- const onId = beef.on_clock === 'a' ? beef.a_user_id : beef.b_user_id;
- const until = Date.parse(beef.clock_until + 'Z');
- // Tighten: if the side on the clock is live now, they get the live window at most.
- const liveCap = now + RESPONSE_LIVE_MIN * 60 * 1000;
- if (await isLive(onId) && until > liveCap) await db.run('UPDATE arena_beefs SET clock_until = ? WHERE id = ?', [sqlDate(liveCap), beef.id]);
- else if (until <= now) {
- const winner = beef.on_clock === 'a' ? beef.b_user_id : beef.a_user_id;
- await resolve(beef, 'forfeit', winner);
- }
- }
- }
-}
-
-// ── Records + views ──────────────────────────────────────────
-
-async function recordFor(userId) {
- const r = await db.get(`SELECT COUNT(*) FILTER (WHERE winner_user_id = ?) AS wins,
- COUNT(*) FILTER (WHERE status = 'resolved' AND winner_user_id IS NOT NULL AND winner_user_id != ?) AS losses,
- COUNT(*) FILTER (WHERE status = 'resolved' AND winner_user_id IS NULL) AS draws
- FROM arena_beefs WHERE (a_user_id = ? OR b_user_id = ?) AND ${PUBLIC}`, [userId, userId, userId, userId]) || {};
- return { wins: r.wins || 0, losses: r.losses || 0, draws: r.draws || 0 };
-}
-
-async function recentWins(userId, days = 7) {
- return (await db.get(`SELECT COUNT(*) AS n FROM arena_beefs WHERE winner_user_id = ? AND resolved_at >= datetime('now', ?)`, [userId, `-${days} days`]))?.n || 0;
-}
-
-async function beefView(beef, roster) {
- const t = totals(beef);
- const b = mic();
- const clockMs = beef.clock_until ? Date.parse(beef.clock_until + 'Z') - Date.now() : null;
- return {
- id: beef.id, status: beef.status, resolution: beef.resolution,
- a: { ...await b.fighterBrief(beef.a_user_id, roster), score: Number((beef.score_a || 0).toFixed(1)), hits: beef.hits_a || 0, crowd: beef.crowd_a || 0, total: t.a, last_at: beef.last_a_at },
- b: { ...await b.fighterBrief(beef.b_user_id, roster), score: Number((beef.score_b || 0).toFixed(1)), hits: beef.hits_b || 0, crowd: beef.crowd_b || 0, total: t.b, last_at: beef.last_b_at },
- share_a: t.a + t.b > 0 ? Math.round((t.a / (t.a + t.b)) * 100) : 50,
- on_clock: beef.on_clock, clock_until: beef.clock_until ? new Date(beef.clock_until + 'Z').toISOString() : null, clock_seconds_left: clockMs == null ? null : Math.max(0, Math.round(clockMs / 1000)),
- clock_is_live_window: beef.on_clock ? await isLive(beef.on_clock === 'a' ? beef.a_user_id : beef.b_user_id) : false,
- responded: !!beef.responded,
- opener_line: beef.opener_line, headline: beef.headline, result_headline: beef.result_headline,
- rematch: !!beef.rematch, upset: !!beef.upset,
- history: await rivalry(beef.a_user_id, beef.b_user_id),
- streaks: { a: await streakFor(beef.a_user_id), b: await streakFor(beef.b_user_id) },
- feed: parseJson(beef.feed_json, []).slice(-14),
- winner_user_id: beef.winner_user_id,
- opened_at: beef.opened_at, ends_at: beef.ends_at ? new Date(beef.ends_at + 'Z').toISOString() : null, resolved_at: beef.resolved_at,
- rules: { response_live_min: RESPONSE_LIVE_MIN, response_offline_hours: RESPONSE_OFFLINE_HOURS, max_hours: MAX_BEEF_HOURS, crowd_max: CROWD_MAX },
- };
-}
-
-async function list({ limitResolved = 12 } = {}) {
- const roster = await arena().loadRoster();
- const open = (await Promise.all((await db.all(`SELECT * FROM arena_beefs WHERE status = 'open' ORDER BY COALESCE(last_b_at, last_a_at, opened_at) DESC`)).map(async b => await beefView(b, roster))));
- const resolved = (await Promise.all((await db.all(`SELECT * FROM arena_beefs WHERE status = 'resolved' ORDER BY resolved_at DESC LIMIT ?`, [limitResolved])).map(async b => await beefView(b, roster))));
- return { open, resolved };
-}
-
-async function get(id) {
- // Only a beef both sides are in, or one that ended (void, pending and expired ones are not shown).
- const b = await db.get(`SELECT * FROM arena_beefs WHERE id = ? AND ${PUBLIC}`, [id]);
- return b ? await beefView(b, await arena().loadRoster()) : null;
-}
-
-async function forUser(userId, limit = 8) {
- const roster = await arena().loadRoster();
- return (await Promise.all((await db.all(`SELECT * FROM arena_beefs WHERE (a_user_id = ? OR b_user_id = ?) AND ${PUBLIC} ORDER BY CASE WHEN status = 'open' THEN 0 ELSE 1 END, COALESCE(resolved_at, opened_at) DESC LIMIT ?`, [userId, userId, limit])).map(async b => await beefView(b, roster))));
-}
-
-module.exports = {
- recordHit, hype, tick, recordFor, recentWins, list, get, forUser, openBeefBetween, openBeefsFor, beefView, totals,
- streakFor, rivalry, rivalriesFor, nameOf,
- RESPONSE_LIVE_MIN, RESPONSE_OFFLINE_HOURS, MAX_BEEF_HOURS, CROWD_MAX, XP_BEEF_WIN, XP_BEEF_OPEN, XP_BEEF_HIT,
-};
deleted file mode 100644
deleted file mode 100644
@@ -1,466 +0,0 @@
@@ -1,466 +0,0 @@
-/**
- * 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 3+ yells, screams or shouts heard within
- * 2 min (the sound timeline) AND a heated line of the streamer's own said around them, quoted
- * 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; the
- * template's plain account (`what`) goes with the facts so the model knows what the event means. A rewrite that
- * misspells a name, or accuses someone of more than the template does (a threat, an attack…), is dropped.
- * 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
- * announce(id) a 3+ alert story as a line in chat (the live stream's room, else global)
- */
-'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']);
-// The streamer's own heated words. The sound timeline hears a game's or a match's crowd as screams and groans too, so
-// a meltdown needs one of these said beside the noise ("We are going to get the control." was a soccer stream's
-// meltdown and "Thank you, thank you." a League stream's tears, 2026-10-18). No tears stories: crying from a speaker
-// is not the streamer crying.
-const RAGE_LINE = /\b(fuck\w*|shit\w*|bullshit|wtf|what the (?:fuck|hell|heck)|god ?damn\w*|i can'?t (?:take|do) (?:this|it)|i'?m (?:so )?done|i swear|rigged|kill me|are you (?:kidding|serious)|hate (?:this|my life|it))\b/i;
-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],
- 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 { /* */ }
- }
- // The AI headline first (when it comes), then the chat hears about it.
- rewrite(id, { kind, streamer: name, other: fx.other || null, what: blurb, facts: fx, quote: cleanQuote }, userId).catch(() => {}).then(() => announce(id)).catch(() => {});
- return id;
-}
-
-// A story at alert 3+ is announced in chat: in the subject's stream room (mirrored to global chat) while they are live,
-// else in global chat. At most ANNOUNCE_PER_HOUR an hour, one line per story (Chat dedupes by key).
-const ANNOUNCE_PER_HOUR = 6;
-let _announced = [];
-async function announce(id) {
- const row = await db.get("SELECT id, user_id, headline, alert, status FROM arena_drama WHERE id = ?", [id]);
- if (!row || row.status !== 'live' || Number(row.alert) < 3) return false;
- const now = Date.now();
- _announced = _announced.filter((t) => now - t < 3600_000);
- if (_announced.length >= ANNOUNCE_PER_HOUR) return false;
- _announced.push(now);
- let base = 'https://openvibe.live';
- try { base = String(require('../config').baseUrl || base).replace(/\/$/, ''); } catch { /* */ }
- const live = await db.get('SELECT id FROM streams WHERE user_id = ? AND is_live = 1 ORDER BY started_at DESC LIMIT 1', [row.user_id]);
- const headline = String(row.headline).startsWith('🚨') ? row.headline : `🚨 ${row.headline}`;
- const line = { username: 'ARENA ALERT', user_id: null, message: `${headline} → ${base}/arena/drama/${row.id}`.slice(0, 480), message_type: 'system', key: `arena:${row.id}`,
- metadata: { arena_drama_id: row.id, url: `${base}/arena/drama/${row.id}` }, ...(live ? { stream_id: live.id, mirror: true } : { is_global: true }) };
- await require('../chat/chat-delivery').message(line);
- return true;
-}
-
-// Words that accuse a real person of something. An AI line may use one only when the template already does: the
-// desk rewrote a beef built on a game's voice lines as "iZoR_One DECLARES DEATH THREATS ON STREAM" (2026-10-18).
-const ACCUSING = /\b(threat\w*|death|deadly|kill\w*|murder\w*|abus\w*|assault\w*|attack\w*|violen\w*|harass\w*|stalk\w*|racis\w*|nazi\w*|crim(?:e|es|inal\w*)|illegal\w*|weapon\w*|groom\w*|predator\w*|pedo\w*|doxx?\w*|rap(?:e|es|ed|ist\w*)|scam\w*|fraud\w*|cheat\w*|steal\w*|stole\w*)\b/gi;
-function addsAccusation(text, template) {
- const have = new Set((String(template || '').match(ACCUSING) || []).map((w) => w.toLowerCase()));
- return (String(text || '').match(ACCUSING) || []).some((w) => !have.has(w.toLowerCase()));
-}
-
-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;
- // A real person's name misspelled ("Goosey" for goosely) is worse than the template: the AI headline must carry
- // every name it was given, exactly (case aside).
- const names = [payload.streamer, payload.other].filter(Boolean).map((n) => String(n).toLowerCase());
- if (!names.every((n) => String(out.headline).toLowerCase().includes(n))) return;
- const row = await db.get('SELECT headline, blurb FROM arena_drama WHERE id = ?', [id]);
- if (addsAccusation(`${out.headline} ${out.blurb}`, row && `${row.headline} ${row.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: 3+ rage sounds within 2 minutes, and a heated line of the streamer's own around them (the quote). */
-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]]) {
- 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, start_sec 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 = pickQuote(lines.filter((l) => RAGE_LINE.test(String(l.text || ''))), best.from.start_sec, best.to.start_sec);
- if (!quote) continue;
- 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;
-}
-
-// The line said around a meltdown that sounds like one: heat (swearing, exclamations, "why", "I can't") counts most,
-// then how close it was to the shouting, then length; a mundane line far from it only when nothing else was said.
-const HEAT = /\b(fuck\w*|shit\w*|damn\w*|bro|dude|wtf|why|what the|i can'?t|i'?m done|i swear|stop|rigged|trash|garbage|hate|kill me|are you (kidding|serious))\b|!/i;
-function pickQuote(lines, fromSec, toSec) {
- const mid = (Number(fromSec) + Number(toSec)) / 2;
- const scored = lines.map((l) => ({ t: String(l.text || '').trim(), at: Number(l.start_sec) }))
- .filter((l) => l.t.split(/\s+/).length >= 4 && !arena()._isBannedText(l.t))
- .map((l) => ({ ...l, score: (HEAT.test(l.t) ? 10 : 0) - Math.abs(l.at - mid) / 6 + Math.min(l.t.length, 160) / 80 }));
- scored.sort((a, b) => b.score - a.score);
- return scored.length ? scored[0].t : null;
-}
-
-/** 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; }
-}
-
-async function liveDuring(userId, sinceTxt) {
- if (!sinceTxt) return false;
- return !!await db.get('SELECT 1 FROM streams WHERE user_id = ? AND (is_live = 1 OR ended_at >= ?) LIMIT 1', [userId, String(sinceTxt).replace('T', ' ').slice(0, 19)]);
-}
-
-/** 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() });
- // "Ducked" only when they were live while on the clock: someone who never streamed in that time never heard it.
- if (kind === 'forfeit' && ctx.loserId && ctx.winnerId && await liveDuring(ctx.loserId, beef.opened_at)) 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, announce, feed, story, react, lolcowIndex, cloutRankings, rapSheet, setOptOut, optedOut, hide, REACTIONS, TEMPLATES: T, addsAccusation, _test: { detectStreams, detectSnapshots, detectSounds, detectDaily, weekStart, toMs, fmt, pickQuote } };
deleted file mode 100644
deleted file mode 100644
@@ -1,307 +0,0 @@
@@ -1,307 +0,0 @@
-/**
- * OpenVibe.Live — Arena Listener (the ears) — Battle Cam mode
- *
- * Every TICK_MS, for every LIVE fighter whose stream is being transcribed, read the new speech
- * lines. Everything the Arena is comes from here; chat, votes and submissions do not exist.
- *
- * name-drop → a line that says another fighter's whole name, as it is spelled (names.js
- * wholeOnly: no sound-alikes, no pieces of a handle) LOCKS the ears on that fighter.
- * Everything said while locked goes to the BEEF JUDGE with the earlier context; a
- * judged hit in lines that say the name → beef.recordHit() (opens the beef, scores
- * it, puts the other side on the clock); unnamed lines are context only. See beef.js.
- * free talk → everything said while NOT locked pools for the MIC JUDGE: is this shit talk at
- * all (roast, rant, callout, brag, disrespect)? Who is it aimed at ("chat", "the
- * mods"; a fighter the judge guesses at is dropped, since nobody named them)? It
- * lands in the shit-talk feed as a `trash` moment and pays Trash Level XP (mic.js).
- *
- * Bounded: at most one judge call per stream per JUDGE_MIN_INTERVAL_MS; nothing happens for
- * streams nobody is talking on; the behaviour filter voids chunks before any model call.
- * State lives in memory (offsets are re-seeded from "now" on restart, so a restart never replays
- * old speech).
- */
-'use strict';
-
-const db = require('../db/database');
-const llm = require('../ai/llm');
-const aiService = require('../ai/ai-service');
-
-const TICK_MS = 15 * 1000;
-const JUDGE_MIN_WORDS = 20;
-const JUDGE_MIN_INTERVAL_MS = 30 * 1000;
-const MIC_MIN_QUALITY = 6; // free talk must be at least this good to land in the feed (bangers only)
-const MIN_LINE_WORDS = 6; // a quotable line is a complete thought, not a fragment
-const BUFFER_MAX_CHARS = 1400;
-
-const state = new Map(); // streamId → { userId, lastOffset, lastJudgeAt, focus, mic: { lines } }
-
-async function aiOn() { try { return await llm.isEnabled() && await llm.withinBudget(); } catch { return false; } }
-function arena() { return require('./arena-service'); }
-function beef() { return require('./beef'); }
-function mic() { return require('./mic'); }
-function parseJson(t, f = null) { try { return t ? JSON.parse(t) : f; } catch { return f; } }
-function words(t) { return String(t || '').split(/\s+/).filter(Boolean).length; }
-/** Tidy a quoted line: collapse whitespace, strip transcript markers, cap length, sentence case + period. */
-function cleanLine(t) {
- let s = String(t || '').replace(/^\s*(?:>>|--?)\s*/, '').replace(/\s+/g, ' ').trim().slice(0, 220);
- if (!s) return '';
- s = s.charAt(0).toUpperCase() + s.slice(1);
- if (!/[.!?…"”']$/.test(s)) s += '.';
- return s;
-}
-
-// ── Aliases: who can be called out, by which names ───────────
-const names = require('./names');
-let _aliasCache = { at: 0, list: [] };
-async function aliases(roster) {
- if (Date.now() - _aliasCache.at < 60 * 1000) return _aliasCache.list;
- const rows = roster.order.length ? await db.all('SELECT user_id, persona_json FROM arena_profiles WHERE user_id = ANY(?)', [roster.order]) : [];
- const personas = new Map(rows.map((r) => [r.user_id, parseJson(r.persona_json)]));
- const list = names.rosterEntries(roster, (id) => { const persona = personas.get(id); return [persona?.fighter_name, ...(Array.isArray(persona?.spoken_as) ? persona.spoken_as : [])]; });
- _aliasCache = { at: Date.now(), list };
- return list;
-}
-// Only a whole name said as it is spelled names someone: a beef is a public claim that one streamer went at another.
-// Sound-alikes and pieces made "Minions" (a game's voice line) into mnss22741 and "fix it" into finditfixit, and 38 of
-// the first 51 beefs had no name said at all (2026-10-18).
-async function mentionsIn(text, speakerId, roster) { return (await mentionsDetailed(text, speakerId, roster)).map(m => m.userId); }
-async function mentionsDetailed(text, speakerId, roster) { return names.findMentions(text, await aliases(roster), { excludeUserId: speakerId, wholeOnly: true }); }
-
-// ── Judges ───────────────────────────────────────────────────
-
-// The judges' prompts and JSON schemas are OpenVibe.AI's versioned templates live.arena.judge_beef and
-// live.arena.judge_mic (roadmap WS-O task 2); Live sends what it heard and applies the house rules below.
-
-const SPICY = /\b(clown|clowns|weak|scared|duck|ducking|ducked|trash|garbage|mid|washed|bum|bums|ratio|cook|cooked|better than|can't|cannot|never|nobody|beat|fraud|frauds|ass|bet|catch (these|this)|come see|pull up|fight me|square up|run it|talk (that|your)|cope|seethe|cry|loser|losers|bozo|bozos|goofy|fake|scam|dogshit|shit at|suck|sucks|pathetic|embarrassing|sit down|shut up|nobody cares|delusional|coward|cowards)\b/;
-function heuristicBeef(text, targetNames, { named = true } = {}) {
- const t = text.toLowerCase();
- const spicy = SPICY.test(t);
- const pronouns = /\b(he|him|his|she|her|they|them|their|that (guy|dude|man|girl|streamer)|this (guy|dude|man|girl|streamer)|the (guy|dude|beef)|bro's|bros)\b/.test(t);
- const aboutTarget = named || pronouns;
- const excl = (text.match(/!/g) || []).length;
- const quality = Math.min(10, (spicy ? 5 : 1) + excl + (words(text) > 30 ? 1 : 0));
- const sentences = text.split(/(?<=[.!?])\s+/).filter(Boolean);
- const namedLines = sentences.filter(l => targetNames.some(n => l.toLowerCase().includes(n)));
- const pick = (namedLines.length ? namedLines : sentences).sort((a, b) => b.length - a.length)[0] || text;
- return { about_target: aboutTarget, aimed_at_target: aboutTarget && spicy, quality, best_line: pick.trim().slice(0, 200), about: text.split(/\s+/).slice(0, 8).join(' '), announcer: '', flagged: false, _fallback: true };
-}
-function heuristicMic(text) {
- const t = text.toLowerCase();
- const spicy = SPICY.test(t);
- const excl = (text.match(/!/g) || []).length;
- const aimed = /\b(chat|you guys|y'all|yall|all of you)\b/.test(t) ? 'chat' : /\bmods?\b/.test(t) ? 'the mods' : /\b(twitch|kick|youtube) streamers?\b/.test(t) ? 'other streamers' : '';
- const quality = spicy ? Math.min(10, 4 + excl + (words(text) > 30 ? 1 : 0) + (/\b(never|nobody|fraud|washed|cooked|ratio)\b/.test(t) ? 1 : 0)) : 0;
- const sentences = text.split(/(?<=[.!?])\s+/).filter(Boolean);
- const spicyLines = sentences.filter(l => SPICY.test(l.toLowerCase()));
- const pick = (spicyLines.length ? spicyLines : sentences).sort((a, b) => b.length - a.length)[0] || text;
- return { is_trash_talk: spicy && words(pick) >= MIN_LINE_WORDS, garbled: false, quality, best_line: pick.trim().slice(0, 200), about: text.split(/\s+/).slice(0, 8).join(' '), aimed_at: aimed, announcer: '', flagged: false, _fallback: true };
-}
-
-async function judgeBeef(speakerId, targetId, text, roster, { context = null, named = true, how = 'exact' } = {}) {
- if (arena()._isBannedText(text)) return { about_target: false, aimed_at_target: false, quality: 0, best_line: '', about: 'voided', announcer: '', flagged: true };
- const tf = roster.byId[targetId];
- const targetNames = [tf.user.username, tf.user.display_name, (parseJson((await db.get('SELECT persona_json FROM arena_profiles WHERE user_id = ?', [targetId]))?.persona_json) || {}).fighter_name].filter(Boolean);
- const spokenForms = [...new Set(targetNames.flatMap(n => names.variants(n)))].slice(0, 8);
- let j = null;
- if (await aiOn()) {
- try {
- const r = await aiService.structured('live.arena.judge_beef', {
- target_names: targetNames.map((n) => String(n).slice(0, 120)).slice(0, 8), target_as_transcribed: spokenForms.map((n) => String(n).slice(0, 120)),
- target_named_in_new_speech: !!named, how_the_name_was_matched: String(how || 'exact').slice(0, 40),
- what_speaker_already_said_about_target: context ? String(context).slice(0, 4000) : null, new_speech: String(text).slice(0, 6000),
- }, { meter: { kind: 'arena_beef_judge', role: 'chat', ownerUserId: speakerId, source: 'arena' } });
- if (r && typeof r.quality === 'number') j = r;
- } catch (e) { console.warn('[Arena] beef judge:', e.message); }
- }
- if (!j) j = heuristicBeef(text, spokenForms.length ? spokenForms : targetNames.map(n => n.toLowerCase()), { named });
- const about = (j.about_target !== false) && !j.flagged;
- const line = cleanLine(j.best_line);
- // A hit needs a quotable line; a beef fed by fragments reads like nonsense on the page.
- const aimed = about && !!j.aimed_at_target && words(line) >= MIN_LINE_WORDS;
- return { about_target: about, aimed_at_target: aimed, quality: aimed ? Math.max(0, Math.min(10, Math.round(Number(j.quality) || 0))) : 0, best_line: line, about: String(j.about || '').slice(0, 80), announcer: String(j.announcer || '').slice(0, 140), flagged: !!j.flagged, fallback: !!j._fallback };
-}
-
-async function judgeMic(speakerId, text) {
- if (arena()._isBannedText(text)) return { is_trash_talk: false, quality: 0, best_line: '', about: 'voided', aimed_at: '', announcer: '', flagged: true };
- let j = null;
- if (await aiOn()) {
- try {
- const r = await aiService.structured('live.arena.judge_mic', { speech: String(text).slice(0, 6000) }, { meter: { kind: 'arena_mic_judge', role: 'chat', ownerUserId: speakerId, source: 'arena' } });
- if (r && typeof r.quality === 'number') j = r;
- } catch (e) { console.warn('[Arena] mic judge:', e.message); }
- }
- if (!j) j = heuristicMic(text);
- const line = cleanLine(j.best_line);
- const ok = !!j.is_trash_talk && !j.flagged && !j.garbled && words(line) >= MIN_LINE_WORDS;
- return { is_trash_talk: ok, garbled: !!j.garbled, quality: ok ? Math.max(0, Math.min(10, Math.round(Number(j.quality) || 0))) : 0, best_line: line, about: String(j.about || '').slice(0, 80), aimed_at: String(j.aimed_at || '').toLowerCase().slice(0, 60), announcer: String(j.announcer || '').slice(0, 140), flagged: !!j.flagged, fallback: !!j._fallback };
-}
-
-// ── Tick ─────────────────────────────────────────────────────
-
-async function liveTranscribedStreams(roster) {
- return (await db.all(`SELECT s.id, s.user_id, s.started_at FROM streams s WHERE s.is_live = 1 AND EXISTS (SELECT 1 FROM stream_timeline_events e WHERE e.stream_id = s.id AND e.kind = 'speech' AND e.created_at >= datetime('now', '-30 minutes'))`))
- .filter(s => roster.byId[s.user_id]);
-}
-function streamOffsetNow(stream) {
- const startedMs = stream.started_at ? Date.parse(String(stream.started_at).replace(' ', 'T') + 'Z') : Date.now();
- return Math.max(0, (Date.now() - startedMs) / 1000);
-}
-function bufferText(lines) { return lines.map(l => l.t.replace(/^\s*(?:>>|--?)\s*/, '').trim()).filter(Boolean).join(' ').replace(/\s+/g, ' ').slice(-BUFFER_MAX_CHARS); }
-function lineRefFor(lines, bestLine) {
- const needle = String(bestLine || '').toLowerCase().slice(0, 30);
- const hit = (needle && lines.find(l => l.t.toLowerCase().includes(needle))) || lines[0];
- return hit ? { vod_id: hit.v || null, sec: Math.max(0, hit.s - 2) } : { vod_id: null, sec: null };
-}
-
-/**
- * Per-stream focus: after a fighter's name is said, the listener LOCKS ON to that fighter. Every
- * following line goes to the beef judge with the earlier context. Each judged hit extends the lock
- * (FOCUS_EXTEND_MS); two chunks in a row that are not about the target, or FOCUS_MAX_MS since the
- * last actual name-drop, drop it. A different name-drop switches focus.
- */
-const FOCUS_TAIL_MS = 2 * 60 * 1000;
-const FOCUS_EXTEND_MS = 3 * 60 * 1000;
-const FOCUS_MAX_MS = 20 * 60 * 1000;
-const FOCUS_MISSES_TO_DROP = 2;
-
-function newFocus(targetId, now, how) { return { targetId, since: now, namedAt: now, lockUntil: now + FOCUS_TAIL_MS, lines: [], misses: 0, hits: 0, context: null, how }; }
-
-async function judgeFocus(stream, roster, st, events, { reason }) {
- const f = st.focus;
- if (!f || !f.lines.length) return false;
- const text = bufferText(f.lines);
- const lines = f.lines; f.lines = [];
- const named = lines.some(l => l.named);
- st.lastJudgeAt = Date.now();
- const j = await judgeBeef(stream.user_id, f.targetId, text, roster, { context: f.context, named, how: f.how });
- const now = Date.now();
- const minQ = f.hits > 0 ? 3 : (f.how === 'exact' ? 3 : 5);
- // A hit needs the target's name in the lines judged: what follows a name-drop is context, never a hit by itself
- // (a League stream's voice lines after one transcribed "Adam" scored a 107–0 beef, 2026-10-18).
- if (j.aimed_at_target && j.quality >= minQ && named) {
- const ref = lineRefFor(lines, j.best_line);
- const res = await beef().recordHit(stream.user_id, f.targetId, { quality: j.quality, best_line: j.best_line, about: j.about, announcer: j.announcer, vod_id: ref.vod_id, sec: ref.sec, stream_id: stream.id });
- f.hits++; f.misses = 0; f.lockUntil = Math.min(now + FOCUS_EXTEND_MS, f.namedAt + FOCUS_MAX_MS);
- f.context = `${f.context ? f.context + ' | ' : ''}${j.about}${j.best_line ? ` ("${j.best_line.slice(0, 120)}")` : ''}`.slice(-600);
- st.lastBeefJudgement = { at: new Date().toISOString(), target_id: f.targetId, ...j, opened: res?.opened, named, reason };
- events.push({ kind: 'beef_hit', streamId: stream.id, speakerId: stream.user_id, targetId: f.targetId, opened: res?.opened, quality: j.quality, line: j.best_line, named, continued: !named });
- return true;
- }
- if (j.about_target) {
- f.misses = 0; f.lockUntil = Math.min(Math.max(f.lockUntil, now + FOCUS_TAIL_MS / 2), f.namedAt + FOCUS_MAX_MS);
- f.context = `${f.context ? f.context + ' | ' : ''}(neutral) ${j.about}`.slice(-600);
- st.lastBeefJudgement = { at: new Date().toISOString(), target_id: f.targetId, ...j, named, reason };
- events.push({ kind: 'beef_neutral', streamId: stream.id, speakerId: stream.user_id, targetId: f.targetId, about: j.about, named });
- return true;
- }
- f.misses++;
- st.lastBeefJudgement = { at: new Date().toISOString(), target_id: f.targetId, ...j, named, reason };
- events.push({ kind: 'beef_miss', streamId: stream.id, speakerId: stream.user_id, targetId: f.targetId, about: j.about, named });
- if (f.misses >= FOCUS_MISSES_TO_DROP) { events.push({ kind: 'focus_dropped', streamId: stream.id, targetId: f.targetId, hits: f.hits }); st.focus = null; }
- return true;
-}
-
-/**
- * The judge's "aimed at" for free talk, which named no fighter (a whole name said sends the line to the beef judge):
- * "chat" or "the mods" stays; a guess that is a fighter is dropped, because nobody named them.
- */
-async function aimedAtUnnamed(aimedAt, speakerId, roster) {
- if (!aimedAt) return null;
- const guessed = names.findMentions(aimedAt, await aliases(roster), { excludeUserId: speakerId });
- return guessed.length ? null : aimedAt;
-}
-
-/** Free talk (not locked on anyone): is it shit talk at all, and at whom? */
-async function judgeFreeTalk(stream, roster, st, events) {
- const lines = st.mic.lines; st.mic.lines = [];
- const text = bufferText(lines);
- st.lastJudgeAt = Date.now();
- const j = await judgeMic(stream.user_id, text);
- st.lastMicJudgement = { at: new Date().toISOString(), ...j };
- if (!j.is_trash_talk || j.quality < MIC_MIN_QUALITY) { events.push({ kind: 'mic_miss', streamId: stream.id, speakerId: stream.user_id, about: j.about }); return; }
- const ref = lineRefFor(lines, j.best_line);
- if (await mic().isDuplicate(stream.user_id, j.best_line)) { events.push({ kind: 'mic_dupe', streamId: stream.id, speakerId: stream.user_id }); return; }
- const aimedAt = await aimedAtUnnamed(j.aimed_at, stream.user_id, roster);
- const m = await mic().addMoment({ userId: stream.user_id, streamId: stream.id, vodId: ref.vod_id, sec: ref.sec, kind: 'trash', aimedAt, text: j.best_line || text.slice(0, 220), about: j.about, quality: j.quality, announcer: j.announcer });
- if (m) events.push({ kind: 'mic_hit', streamId: stream.id, speakerId: stream.user_id, quality: j.quality, line: j.best_line, aimedAt: j.aimed_at, momentId: m.id });
-}
-
-async function tickStream(stream, roster, events) {
- let st = state.get(stream.id);
- if (!st) { st = { userId: stream.user_id, lastOffset: streamOffsetNow(stream) - 20, lastJudgeAt: 0, focus: null, mic: { lines: [] } }; state.set(stream.id, st); }
- const rows = await db.all(`SELECT text, start_sec, vod_id FROM stream_timeline_events WHERE stream_id = ? AND kind = 'speech' AND start_sec > ? ORDER BY start_sec ASC LIMIT 100`, [stream.id, st.lastOffset]);
- if (rows.length) st.lastOffset = rows[rows.length - 1].start_sec;
- const now = Date.now();
- if (st.focus && now > st.focus.lockUntil) {
- if (st.focus.lines.length && st.focus.lines.reduce((n, l) => n + words(l.t), 0) >= JUDGE_MIN_WORDS && now - st.lastJudgeAt >= JUDGE_MIN_INTERVAL_MS) await judgeFocus(stream, roster, st, events, { reason: 'lock expired' });
- if (st.focus) { events.push({ kind: 'focus_dropped', streamId: stream.id, targetId: st.focus.targetId, hits: st.focus.hits, why: 'timeout' }); st.focus = null; }
- }
- for (const r of rows) {
- const line = { t: String(r.text || ''), s: Math.floor(r.start_sec), v: r.vod_id || null, named: false };
- const mentions = await mentionsDetailed(line.t, stream.user_id, roster);
- if (mentions.length) {
- const m = mentions[0];
- line.named = true;
- if (st.focus && st.focus.targetId !== m.userId) {
- if (st.focus.lines.reduce((n, l) => n + words(l.t), 0) >= JUDGE_MIN_WORDS) await judgeFocus(stream, roster, st, events, { reason: 'target switch' });
- st.focus = null;
- }
- if (!st.focus) { st.focus = newFocus(m.userId, now, m.how); events.push({ kind: 'focus', streamId: stream.id, speakerId: stream.user_id, targetId: m.userId, how: m.how, hit: m.hit }); }
- else { st.focus.namedAt = now; st.focus.lockUntil = Math.max(st.focus.lockUntil, now + FOCUS_TAIL_MS); st.focus.misses = 0; }
- st.focus.lines.push(line);
- } else if (st.focus) {
- st.focus.lines.push(line);
- } else {
- st.mic.lines.push(line);
- }
- }
- if (st.focus && st.focus.lines.length > 80) st.focus.lines = st.focus.lines.slice(-80);
- if (st.mic.lines.length > 60) st.mic.lines = st.mic.lines.slice(-60);
-
- if (now - st.lastJudgeAt < JUDGE_MIN_INTERVAL_MS) return;
-
- // 1) Locked on a fighter → the beef judge (a callout is the interesting thing).
- if (st.focus && st.focus.lines.reduce((n, l) => n + words(l.t), 0) >= JUDGE_MIN_WORDS) {
- await judgeFocus(stream, roster, st, events, { reason: st.focus.lines.some(l => l.named) ? 'name-drop' : 'continuation' });
- return; // one judge call per stream per tick
- }
- // 2) Free talk → the mic judge.
- if (!st.focus && st.mic.lines.reduce((n, l) => n + words(l.t), 0) >= JUDGE_MIN_WORDS) await judgeFreeTalk(stream, roster, st, events);
-}
-
-let _timer = null, _busy = false;
-async function tick() {
- if (_busy) return [];
- _busy = true;
- const events = [];
- try {
- const roster = await arena().loadRoster();
- const streams = await liveTranscribedStreams(roster);
- for (const s of streams) { try { await tickStream(s, roster, events); } catch (e) { console.warn(`[Arena] listener stream ${s.id}:`, e.message); } }
- for (const id of [...state.keys()]) if (!streams.find(s => s.id === id)) state.delete(id);
- try { await beef().tick(); } catch (e) { console.warn('[Arena] beef tick:', e.message); }
- } finally { _busy = false; }
- return events;
-}
-
-async function consoleState(userId) {
- for (const [streamId, st] of state) if (st.userId === userId) {
- const f = st.focus;
- return {
- stream_id: streamId, listening: true,
- focus: f ? { target_id: f.targetId, target: await mic().nameOf(f.targetId).catch(() => null), how: f.how, since: new Date(f.since).toISOString(), lock_seconds_left: Math.max(0, Math.round((f.lockUntil - Date.now()) / 1000)), hits: f.hits, misses: f.misses, pending_words: f.lines.reduce((n, l) => n + words(l.t), 0), context: f.context } : null,
- pending_mic_words: st.mic.lines.reduce((n, l) => n + words(l.t), 0),
- last_mic_judgement: st.lastMicJudgement || null, last_beef_judgement: st.lastBeefJudgement || null, last_judge_at: st.lastJudgeAt ? new Date(st.lastJudgeAt).toISOString() : null,
- };
- }
- return { listening: false };
-}
-
-function start() {
- if (_timer) return;
- _timer = setInterval(() => tick().catch(e => console.warn('[Arena] listener:', e.message)), TICK_MS);
- if (_timer.unref) _timer.unref();
- console.log('[Arena] listener started (every 15 s — name-drops → beef judge, free talk → mic judge)');
-}
-function stop() { if (_timer) { clearInterval(_timer); _timer = null; } }
-
-module.exports = { start, stop, tick, consoleState, TICK_MS, JUDGE_MIN_WORDS, JUDGE_MIN_INTERVAL_MS, MIC_MIN_QUALITY, MIN_LINE_WORDS, cleanLine, FOCUS_TAIL_MS, FOCUS_EXTEND_MS, FOCUS_MAX_MS, SPICY, _mentionsIn: mentionsIn, _mentionsDetailed: mentionsDetailed, _aimedAtUnnamed: aimedAtUnnamed, _aliases: aliases, _heuristicBeef: heuristicBeef, _heuristicMic: heuristicMic, _judgeBeef: judgeBeef, _judgeMic: judgeMic, _state: state };
deleted file mode 100644
deleted file mode 100644
@@ -1,187 +0,0 @@
@@ -1,187 +0,0 @@
-/**
- * OpenVibe.Live — Arena mic ledger (Battle Cam mode)
- *
- * The Arena runs on ONE input: what fighters say on mic (stream_timeline_events speech,
- * judged by listener.js). This module is the ledger for that:
- *
- * moments → every judged piece of shit talk lands here: a callout that opened/fed a
- * beef (`beef_hit`), or free-standing trash talk aimed at chat / a group / a
- * name that is not on the roster (`trash`). VOD-linked so every line can be
- * replayed at the second it was said.
- * XP → moment quality → Trash Level (arena_trash_levels / arena_xp_log). No other
- * source of XP exists: not chat, not votes, not check-ins.
- * mic stats → per-fighter aggregates the roster ratings are built from (HEAT, AIM,
- * CLAPBACK, KILLS…) — see arena-service.js.
- * feed → the live "shit talk feed": newest judged lines across every live cam.
- *
- * The behaviour filter (threats / minors / doxxing — never vocabulary) is applied before any
- * line is stored; see arena-service.isBannedText.
- */
-'use strict';
-
-const db = require('../db/database');
-
-const XP_PER_LEVEL = 50;
-const XP_MOMENT = 0.8; // × quality
-const XP_HYPE = 1;
-
-
-function parseJson(t, f = null) { try { return t ? JSON.parse(t) : f; } catch { return f; } }
-function arena() { return require('./arena-service'); }
-function levelFor(xp) { return 1 + Math.floor((Number(xp) || 0) / XP_PER_LEVEL); }
-async function levelRow(userId) {
- return await db.get('SELECT * FROM arena_trash_levels WHERE user_id = ?', [userId]) || { user_id: userId, xp: 0, level: 1, beef_hits: 0, mic_moments: 0, best_line: null, best_line_score: 0 };
-}
-
-// ── XP / Trash Level ─────────────────────────────────────────
-
-async function addXp(userId, amount, reason, refId = null, extra = {}) {
- amount = Math.round(Number(amount) || 0);
- if (amount <= 0) return await levelRow(userId);
- const before = await levelRow(userId);
- await db.run(`INSERT INTO arena_trash_levels (user_id, xp, level) VALUES (?, ?, ?)
- ON CONFLICT(user_id) DO UPDATE SET xp = arena_trash_levels.xp + excluded.xp, level = ?, updated_at = ov_now()`,
- [userId, amount, levelFor(amount), levelFor((before.xp || 0) + amount)]);
- await db.run('INSERT INTO arena_xp_log (user_id, amount, reason, ref_id) VALUES (?, ?, ?, ?)', [userId, amount, reason, refId]);
- const sets = [];
- if (extra.moment) sets.push('mic_moments = mic_moments + 1');
- if (extra.beefHit) sets.push('beef_hits = beef_hits + 1');
- if (sets.length) await db.run(`UPDATE arena_trash_levels SET ${sets.join(', ')} WHERE user_id = ?`, [userId]);
- if (extra.line && (extra.lineScore || 0) > (before.best_line_score || 0)) {
- await db.run('UPDATE arena_trash_levels SET best_line = ?, best_line_vod_id = ?, best_line_sec = ?, best_line_score = ? WHERE user_id = ?', [String(extra.line).slice(0, 220), extra.lineVodId || null, extra.lineSec ?? null, extra.lineScore, userId]);
- }
- const after = await levelRow(userId);
- if (after.level > before.level) {
- console.log(`[Arena] user ${userId} → Trash Level ${after.level}`);
- try { require('./notify').arenaNotify(userId, { type: 'level', title: `Trash Level ${after.level}`, message: `${after.xp} XP from pure mic. Keep talking.`, icon: '🎙️', url: '/arena', key: `level:${after.level}` }); } catch { /* */ }
- }
- try { await arena().loadRoster(true); } catch { /* */ }
- return { ...after, leveled_up: after.level > before.level, gained: amount };
-}
-
-async function recentXp(userId, days = 7) {
- return (await db.get(`SELECT COALESCE(SUM(amount), 0)::bigint AS xp FROM arena_xp_log WHERE user_id = ? AND created_at >= datetime('now', ?)`, [userId, `-${days} days`]))?.xp || 0;
-}
-
-async function levelView(userId) {
- const r = await levelRow(userId);
- const xp = r.xp || 0;
- return {
- level: levelFor(xp), xp, xp_into_level: xp - (levelFor(xp) - 1) * XP_PER_LEVEL, xp_per_level: XP_PER_LEVEL, next_level_xp: levelFor(xp) * XP_PER_LEVEL,
- mic_moments: r.mic_moments || 0, beef_hits: r.beef_hits || 0,
- recent_xp: await recentXp(userId),
- best_line: r.best_line ? { text: r.best_line, vod_id: r.best_line_vod_id, sec: r.best_line_sec, score: r.best_line_score } : null,
- };
-}
-
-async function levelsLeaderboard(limit = 10) {
- const roster = await arena().loadRoster();
- return (await Promise.all((await db.all('SELECT * FROM arena_trash_levels WHERE xp > 0 ORDER BY xp DESC LIMIT ?', [limit]))
- .map(async r => ({ ...await fighterBrief(r.user_id, roster), xp: r.xp, level: levelFor(r.xp), beef_hits: r.beef_hits || 0, mic_moments: r.mic_moments || 0, best_line: r.best_line ? { text: r.best_line, vod_id: r.best_line_vod_id, sec: r.best_line_sec, score: r.best_line_score } : null }))));
-}
-
-// ── Names + briefs ───────────────────────────────────────────
-
-// Real names only — no AI ring names. The fighter IS the streamer.
-async function nameOf(userId) {
- const u = await db.getUserById(userId);
- return u?.display_name || u?.username || `user${userId}`;
-}
-
-async function fighterBrief(userId, roster) {
- const f = roster && roster.byId ? roster.byId[userId] : null;
- return {
- user: f ? f.user : (await db.getUserById(userId) ? arena().publicUser(await db.getUserById(userId)) : { id: userId, username: `user${userId}`, display_name: `user${userId}` }),
- fighter_name: await nameOf(userId),
- rank: f ? roster.order.indexOf(userId) + 1 : null,
- image_url: await (async () => { try { return await arena().getFighterImageUrl(userId); } catch { return null; } })(),
- live: !!await db.get('SELECT 1 FROM streams WHERE user_id = ? AND is_live = 1 LIMIT 1', [userId]),
- level: levelFor((await levelRow(userId)).xp || 0),
- };
-}
-
-// ── Moments ──────────────────────────────────────────────────
-
-/**
- * Store a judged mic line. kind: 'trash' (aimed at chat / a group / a non-roster name),
- * 'beef_hit' (fed a beef; target_user_id + beef_id set). Pays XP for 'trash' here; beef hits
- * are paid by beef.recordHit. Returns the row or null when the line is behaviour-filtered.
- */
-async function addMoment({ userId, streamId = null, vodId = null, sec = null, kind = 'trash', targetUserId = null, beefId = null, aimedAt = null, text, about = null, quality = 0, announcer = null, saidAt = null }) {
- const t = String(text || '').replace(/\s+/g, ' ').trim().slice(0, 240);
- if (!t) return null;
- try { if (arena()._isBannedText(t)) return null; } catch { /* */ }
- const q = Math.max(0, Math.min(10, Number(quality) || 0));
- const r = await db.run(`INSERT INTO arena_mic_moments (user_id, stream_id, vod_id, sec, kind, target_user_id, beef_id, aimed_at, text, about, quality, announcer, said_at)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE(?::text, ov_now())) RETURNING id`,
- [userId, streamId, vodId, sec == null ? null : Math.max(0, Math.floor(sec)), kind, targetUserId, beefId, aimedAt ? String(aimedAt).slice(0, 80) : null, t, about ? String(about).slice(0, 80) : null, q, announcer ? String(announcer).slice(0, 140) : null, saidAt]);
- const id = Number(r.lastInsertRowid);
- if (kind === 'trash' || kind === 'callout') await addXp(userId, q * XP_MOMENT, kind === 'callout' ? 'mic_callout' : 'mic_trash', id, { moment: true, line: t, lineScore: q, lineVodId: vodId, lineSec: sec });
- return await db.get('SELECT * FROM arena_mic_moments WHERE id = ?', [id]);
-}
-
-/** Same line (or near enough) from the same fighter in the last 6 h → don't file it twice. */
-async function isDuplicate(userId, text) {
- const norm = String(text || '').toLowerCase().replace(/[^a-z0-9 ]/g, '').replace(/\s+/g, ' ').trim();
- if (!norm) return false;
- const rows = await db.all(`SELECT text FROM arena_mic_moments WHERE user_id = ? AND created_at >= datetime('now', '-6 hours') ORDER BY id DESC LIMIT 40`, [userId]);
- return rows.some(r => { const t = String(r.text || '').toLowerCase().replace(/[^a-z0-9 ]/g, '').replace(/\s+/g, ' ').trim(); return t === norm || (t.length > 30 && (t.includes(norm) || norm.includes(t))); });
-}
-
-async function momentView(m, roster) {
- const brief = await fighterBrief(m.user_id, roster);
- return {
- id: m.id, kind: m.kind, user: brief.user, fighter_name: brief.fighter_name, rank: brief.rank, image_url: brief.image_url, level: brief.level, live: brief.live,
- target: m.target_user_id ? await (async () => { const b = await fighterBrief(m.target_user_id, roster); return { user: b.user, fighter_name: b.fighter_name, rank: b.rank }; })() : null,
- beef_id: m.beef_id || null, aimed_at: m.aimed_at || null, text: m.text, about: m.about, quality: Number(m.quality) || 0, announcer: m.announcer || null,
- stream_id: m.stream_id, vod_id: m.vod_id, sec: m.sec, at: m.said_at || m.created_at,
- };
-}
-
-/** The shit-talk feed: newest judged lines across the roster. */
-async function feed({ limit = 40, since = null, userId = null } = {}) {
- const roster = await arena().loadRoster();
- const params = [];
- // The feed shows bangers only; weak lines still count for stats/XP but never headline the page.
- let where = 'quality >= 5';
- if (userId) { where += ' AND user_id = ?'; params.push(userId); }
- if (since) { where += ' AND id > ?'; params.push(Number(since) || 0); }
- params.push(Math.min(200, Math.max(1, limit)));
- return (await Promise.all((await db.all(`SELECT * FROM arena_mic_moments WHERE ${where} ORDER BY COALESCE(said_at, created_at) DESC, id DESC LIMIT ?`, params)).map(async m => await momentView(m, roster))));
-}
-async function momentsFor(userId, limit = 12) { return await feed({ limit, userId }); }
-async function bestLines(userId, limit = 5) {
- const roster = await arena().loadRoster();
- return (await Promise.all((await db.all('SELECT * FROM arena_mic_moments WHERE user_id = ? AND quality >= 5 ORDER BY quality DESC, id DESC LIMIT ?', [userId, limit])).map(async m => await momentView(m, roster))));
-}
-async function latestFor(userId) {
- const m = await db.get('SELECT * FROM arena_mic_moments WHERE user_id = ? ORDER BY COALESCE(said_at, created_at) DESC, id DESC LIMIT 1', [userId]);
- return m ? await momentView(m, await arena().loadRoster()) : null;
-}
-
-/** Aggregates the ratings are built from. All from the mic ledger + beefs — nothing else. */
-async function micStats(userId, days = 30) {
- const win = `-${days} days`;
- const m = await db.get(`SELECT COUNT(*) AS n, COALESCE(AVG(quality), 0)::float8 AS avg_q, COALESCE(MAX(quality), 0) AS best_q,
- COUNT(*) FILTER (WHERE kind IN ('beef_hit', 'callout')) AS beef_hits, COUNT(*) FILTER (WHERE kind = 'trash') AS trash,
- COUNT(*) FILTER (WHERE quality >= 7) AS bangers
- FROM arena_mic_moments WHERE user_id = ? AND created_at >= datetime('now', ?)`, [userId, win]) || {};
- let b = {};
- try { b = await db.get(`SELECT COUNT(*) FILTER (WHERE b_user_id = ?) AS targeted, COUNT(*) FILTER (WHERE b_user_id = ? AND responded = 1) AS answered, COUNT(*) FILTER (WHERE winner_user_id = ?) AS wins, COUNT(*) FILTER (WHERE status = 'resolved' AND winner_user_id IS NOT NULL AND winner_user_id != ?) AS losses, COUNT(*) FILTER (WHERE status = 'resolved' AND resolution = 'forfeit' AND winner_user_id != ? AND winner_user_id IS NOT NULL) AS ducked FROM arena_beefs WHERE (a_user_id = ? OR b_user_id = ?) AND status IN ('open', 'resolved')`, [userId, userId, userId, userId, userId, userId, userId]) || {}; } catch { b = {}; }
- const speechMin = await (async () => { try { return ((await db.get(`SELECT COALESCE(SUM(COALESCE(end_sec, start_sec + 3) - start_sec), 0)::bigint AS s FROM stream_timeline_events WHERE user_id = ? AND kind = 'speech' AND created_at >= datetime('now', ?)`, [userId, win]))?.s || 0) / 60; } catch { return 0; } })();
- const hours = Math.max(speechMin / 60, 0.05);
- const targeted = b.targeted || 0;
- return {
- window_days: days, moments: m.n || 0, trash: m.trash || 0, beef_hits: m.beef_hits || 0, bangers: m.bangers || 0,
- avg_quality: Number((m.avg_q || 0).toFixed(2)), best_quality: Number(m.best_q || 0),
- hits_per_mic_hour: Number((((m.beef_hits || 0) + (m.trash || 0)) / hours).toFixed(2)),
- targeted, answered: b.answered || 0, clapback_rate: targeted ? Number(((b.answered || 0) / targeted).toFixed(2)) : 0,
- wins: b.wins || 0, losses: b.losses || 0, ducked: b.ducked || 0, speech_minutes: Number(speechMin.toFixed(1)),
- };
-}
-
-module.exports = {
- addXp, recentXp, levelView, levelFor, levelRow, levelsLeaderboard, nameOf, fighterBrief,
- addMoment, momentView, feed, momentsFor, bestLines, latestFor, micStats, isDuplicate,
- XP_PER_LEVEL, XP_MOMENT, XP_HYPE,
-};
deleted file mode 100644
deleted file mode 100644
@@ -1,203 +0,0 @@
@@ -1,203 +0,0 @@
-/**
- * names.js — how a streamer's name comes out of a speech-to-text engine, and how to find it.
- *
- * "JapaneseOldGuy" is never transcribed as one token: whisper hears "Japanese old guy",
- * "japanese-old-guy", "Japanese Old Guy's stream". "lofi_dan99" → "lofi dan". "Maticus" →
- * "Matticus" / "Mattie cuss". "x_Goosely_TV" → "Goosely". This module turns a name into every
- * spoken form we can predict, then matches transcript lines against them with:
- *
- * 1. exact/loose-separator matches ("japanese old guy", "japanese-old-guy", "japaneseoldguy")
- * 2. fuzzy matches on n-grams of the line (edit distance scaled by length)
- * 3. phonetic matches (a compact metaphone-ish key: "goosely" ≈ "goose lee" ≈ "goosley")
- *
- * Guard rails: single-word aliases must be ≥ 4 letters and not a common English word, so a
- * fighter called "Guy" or "Old" cannot be "mentioned" by every sentence; multi-word aliases only
- * match as the whole phrase; the speaker never matches themselves; longest alias wins.
- */
-'use strict';
-
-const COMMON = new Set(('about after again also always another anyone anything around because before being between both bring build built came cant cannot chat check come could does doing done down each else even ever every first from game games getting give going gone good great group guy guys have having here home into just keep kind know last later least life like little live look made make many maybe mean might more most much must name need never next nice night nobody nothing okay only other over people place play player point pretty probably really right same says school seen shit should show since some something sorry stay still stop stream streamer stuff sure take talk tell than thank that their them then there these they thing think this those three time today told took trying under until very want watch week well went were what when where which while whole will with without work would year years young your live love hate lord king queen dude bro bruh fam yeah yes okay old new big small guy girl boy man men women woman baby dan dave mike john tom jack ben sam max alex chris '
- + 'matter meter motor water later letter little level lever mother master mister monster minute minutes money moment mountain market maybe major magic medic metal middle mirror mission model modern moving music mute native nature never number often order other paper party power pretty price quick quiet radio ready river robot round sauce scary second server seven shadow silver simple sister sixty slower smaller smoke solid sound south space speak speed spend spirit sport square start state steam story street strong stuff sugar summer super sweet table taken teach teeth thank thick thing third thought throw tiger timer title toast total touch tower track trade train travel treat truck truly trust truth turned twenty under upper usual value video village voice waste water wheel where white whole winter wonder world worry worse worth would write wrong yellow young about above across actor adult again agent ahead alarm album alien alive allow alone along alter among angle animal answer anyway apple armor arrow attack aware awful bacon badge basic battle beach beast begin being below bench birth black blade blame blank blast blind block blood board boost boots bottle bottom bounce bounty brain brand brave bread break brick bridge bright broken brush bucket buddy budget bullet button cable camera candy cannon cargo carry castle catch cause chain chair chance change charge chase cheap cheese chest chief child choice church claim class clean clear click climb clock close cloud coach coast coffee color combo comic coming crazy crash cream credit crime cross crowd crown cycle daily damage dance dark death decent delay demon depth desert device diamond dinner direct dirty double dozen dragon drama dream dress drink drive dungeon early earth eight empty enemy energy engine enter entire equal error event exact extra faith false fancy farm fast fatal fault favor feast fence field fight figure final finish fire flame flash flesh floor flower focus force forest forge frame fresh front frozen fruit funny giant glass glory gold grand grass green guard guess guide happy heart heavy hello hidden honor horse hotel house human hunt image inside iron island jacket joint judge juice jungle knife knight label ladder large laser latest laugh layer leader legend lemon light limit liquid local lucky lunch magic manual march match medal melee metal minor mobile moon mouse mouth movie nasty needle noise north notice number nurse ocean offer olive onion orange order outer oven owner paint panel panic paper patch peace pearl pedal pencil phone photo piano piece pilot pizza plain plane plant plate plush pocket poison police potato pound press prime print prize proof pulse punch puzzle queen quest rabbit radar raid rain range ratio razor reach recent relax reload rescue retro rifle rival rocket rough route royal rubber ruler rusty sadly safety salad sample sandwich scale scene score scout screen script scroll search secret select shape share sharp shelf shield shirt shock shoot shore short shoulder shovel signal silent skill skull slate sleep slice slide slime smart smell smile snake sneak snipe snow sock solar solo sorry sound spare spark spawn spear spell spice spider spike spoon spray staff stage stair stamp stand star steel step stick stone store storm story stove strange strike stupid sudden sunny sword tackle talent tank taste tavern temple tennis thief thirty ticket tired toilet token tomato tooth torch tough toxic trail trap trash treasure trick troll trophy trouble tunnel turtle twist uncle unique unit upset urban valley vault vendor virus vision vocal volume wagon waiting wallet weapon weird whale wheat window wire wizard wolf worker yard zombie zone'
-).split(/\s+/));
-
-const SUFFIXES = /(?:_|-)?(?:tv|ttv|live|official|yt|twitch|gaming|stream|streams|vods|irl|hd|4k)$/i;
-
-const LEET = { '0': 'o', '1': 'i', '3': 'e', '4': 'a', '5': 's', '7': 't', '8': 'b', '@': 'a', '$': 's', '!': 'i', '|': 'l' };
-
-function clean(s) { return String(s || '').trim(); }
-function words(s) { return String(s || '').toLowerCase().split(/[^a-z0-9]+/).filter(Boolean); }
-
-/** Split a handle the way it is SAID: camelCase, snake_case, kebab, letters|digits. */
-function splitHandle(name) {
- let s = clean(name);
- if (!s) return [];
- s = s.replace(/[_.\-+]+/g, ' ')
- .replace(/([a-z])([A-Z])/g, '$1 $2') // JapaneseOldGuy → Japanese Old Guy
- .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2') // XXGooselyXX → XX Goosely XX
- .replace(/([a-zA-Z])(\d)/g, '$1 $2') // dan99 → dan 99
- .replace(/(\d)([a-zA-Z])/g, '$1 $2');
- return s.toLowerCase().split(/\s+/).filter(Boolean);
-}
-
-function deleet(w) { w = String(w); if (/^\d+$/.test(w)) return w; /* 151 stays a number, never 'isi' */ return /[a-z]/i.test(w) ? w.replace(/[0134578@$!|]/g, c => LEET[c] || c) : w; }
-
-/**
- * All predicted spoken forms of one name (lowercase word arrays, deduped, most specific first).
- * JapaneseOldGuy → ["japanese old guy"], ["japaneseoldguy"]
- * lofi_dan99 → ["lofi dan 99"], ["lofi dan"], ["lofidan"]
- * x_Goosely_TV → ["x goosely tv"], ["goosely"], ["x goosely"]
- * M4ticus → ["m4ticus"], ["maticus"]
- */
-function variants(name) {
- const out = new Set();
- const add = (ws) => { const v = ws.filter(Boolean).join(' ').trim(); if (v.length >= 3) out.add(v); };
- const raw = clean(name);
- if (!raw) return [];
- const base = splitHandle(raw);
- add(base);
- add(base.map(deleet));
- // Leet inside a token (M4ticus, l33t_dan) → un-leet the whole handle first, then split.
- if (/[a-z][0134578@$!|]+[a-z]/i.test(raw)) { const b0 = splitHandle(raw.replace(/[0134578@$!|]/g, c => LEET[c] || c)); add(b0); add(b0.filter(w => !/^\d+$/.test(w))); }
- add(base.filter(w => !/^\d+$/.test(w))); // drop pure numbers: "lofi dan 99" → "lofi dan"
- add(base.map(deleet).filter(w => !/^\d+$/.test(w)));
- const noSuffix = raw.replace(SUFFIXES, '');
- if (noSuffix !== raw) { const b2 = splitHandle(noSuffix); add(b2); add(b2.filter(w => !/^\d+$/.test(w))); }
- // Decorations people put around handles: xX_name_Xx, __name__, TheName → name
- const stripped = raw.replace(/^(?:xx+|x_|_+|the)/i, '').replace(/(?:xx+|_x|_+)$/i, '');
- if (stripped !== raw && stripped.length >= 3) { const b3 = splitHandle(stripped); add(b3); add(b3.filter(w => !/^\d+$/.test(w))); }
- // Glued form for names that ARE a phrase (a transcriber may glue or split them).
- for (const v of [...out]) { const ws = v.split(' '); if (ws.length > 1 && ws.every(w => /^[a-z]+$/.test(w))) out.add(ws.join('')); }
- // Drop unsafe single common words (would match every sentence).
- return [...out].filter(v => v.split(' ').length > 1 || (v.length >= 4 && !COMMON.has(v) && !/^\d+$/.test(v))).sort((a, b) => b.length - a.length);
-}
-
-// ── Fuzzy + phonetic ────────────────────────────────────────
-
-function levenshtein(a, b) {
- if (a === b) return 0;
- const m = a.length, n = b.length;
- if (!m) return n; if (!n) return m;
- let prev = Array.from({ length: n + 1 }, (_, i) => i);
- for (let i = 1; i <= m; i++) {
- const cur = [i];
- for (let j = 1; j <= n; j++) cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
- prev = cur;
- }
- return prev[n];
-}
-
-/** Compact phonetic key: "goosely" → "gsl", "goose lee" → "gsl", "matticus" → "mtks", "japanese old guy" → "jpns ld g". */
-function phonetic(s) {
- let t = String(s || '').toLowerCase().replace(/[^a-z ]/g, '');
- t = t.replace(/ph/g, 'f').replace(/ck/g, 'k').replace(/qu/g, 'kw').replace(/x/g, 'ks').replace(/z/g, 's').replace(/wh/g, 'w')
- .replace(/c(?=[eiy])/g, 's').replace(/c/g, 'k').replace(/g(?=[eiy])/g, 'j').replace(/dg/g, 'j').replace(/tio/g, 'sho').replace(/y/g, 'i');
- return t.split(' ').map(w => {
- if (!w) return '';
- const first = w[0];
- let rest = w.slice(1).replace(/[aeiouhw]/g, '');
- rest = rest.replace(/(.)\1+/g, '$1');
- return (first + rest).replace(/(.)\1+/g, '$1');
- }).filter(Boolean).join(' ');
-}
-function ratioThreshold(len) { return len <= 4 ? 0 : len <= 7 ? 1 : len <= 11 ? 2 : 3; }
-
-// ── Matching ────────────────────────────────────────────────
-
-/** Regex for one variant: words joined by any separator, optional 's / plural, no letters glued around. */
-function variantRegex(v) {
- const parts = v.split(' ').map(w => w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
- return new RegExp(`(?:^|[^a-z0-9])@?${parts.join('[\\s\\-_.]*')}(?:'?s)?(?![a-z0-9])`, 'i');
-}
-
-/** Every letter of a name, in the spellings it can be said in whole ("finditfixit", "mnss22741", "maticus" for M4ticus). */
-function wholeLetters(name) {
- const raw = clean(name);
- const base = splitHandle(raw);
- return new Set([base, base.map(deleet), splitHandle(raw.replace(/[0134578@$!|]/g, c => LEET[c] || c))].map(ws => ws.join('')).filter(Boolean));
-}
-
-/**
- * Build an alias entry: { userId, name, words, re, key, whole } for each variant of each name. `whole` is a variant
- * that says the entire name, not a piece of it: "find it fix it" is finditfixit, "fix it" is not; "mnss 22741" is
- * mnss22741, "mnss" is not. `{ whole: false }` marks every entry as a piece (nicknames, persona names).
- */
-function aliasEntries(userId, names, { whole = true } = {}) {
- const seen = new Map();
- const out = [];
- for (const n of names.filter(Boolean)) {
- const full = whole ? wholeLetters(n) : new Set();
- for (const v of variants(n)) {
- const letters = v.replace(/\s+/g, '');
- if (seen.has(v)) { if (full.has(letters)) seen.get(v).whole = true; continue; }
- const e = { userId, name: v, words: v.split(' '), re: variantRegex(v), key: phonetic(v), letters, whole: full.has(letters) };
- seen.set(v, e);
- out.push(e);
- }
- }
- return out;
-}
-
-/**
- * Which alias entries occur in `text`. Returns [{ userId, name, how: 'exact'|'fuzzy'|'phonetic', hit }].
- * `excludeUserId` = the speaker (never mentions themselves). `wholeOnly`: only a whole name said as it is spelled
- * (no pieces, no sound-alikes): what a public claim that someone named someone needs.
- */
-function findMentions(text, entries, { excludeUserId = null, fuzzy = true, wholeOnly = false } = {}) {
- const found = new Map(); // userId → best hit
- const raw = String(text || '');
- if (raw.length < 3) return [];
- if (wholeOnly) { entries = entries.filter(e => e.whole); fuzzy = false; }
- const consider = (e, how, hit) => { if (e.userId === excludeUserId) return; const cur = found.get(e.userId); if (!cur || cur.rank < (how === 'exact' ? 3 : how === 'fuzzy' ? 2 : 1)) found.set(e.userId, { userId: e.userId, name: e.name, how, hit, rank: how === 'exact' ? 3 : how === 'fuzzy' ? 2 : 1 }); };
- // 1) exact / loose separators
- for (const e of entries) { const m = raw.match(e.re); if (m) consider(e, 'exact', m[0].trim()); }
- if (!fuzzy) return [...found.values()].map(({ rank, ...r }) => r);
- // 2) fuzzy + phonetic on n-grams (1..4 words) of the line
- const toks = words(raw).map(deleet);
- if (!toks.length) return [...found.values()].map(({ rank, ...r }) => r);
- for (const e of entries) {
- if (found.has(e.userId) && found.get(e.userId).rank === 3) continue;
- const n = e.words.length;
- const target = e.letters;
- if (target.length < 6) continue; // "mater" ≈ matter/meter/water — too short to fuzzy-match safely
- if (n === 1 && COMMON.has(e.name)) continue;
- const maxD = ratioThreshold(target.length);
- // n-grams of n words, and of n±1 words (a name can be split or glued by the transcriber)
- for (const span of [n, n + 1, n - 1].filter(x => x >= 1 && x <= 4)) {
- for (let i = 0; i + span <= toks.length; i++) {
- const gram = toks.slice(i, i + span);
- const glued = gram.join('');
- if (Math.abs(glued.length - target.length) > maxD + 4) continue;
- // Fuzzy (typo-level) only on the same number of words: "matticus" ~ "maticus", "japanese old gai" ~
- // "japanese old guy" — but never "japanese guy" ~ "japanese old guy" (a different phrase).
- if (gram.filter(w => COMMON.has(w)).length * 2 > gram.length) continue; // plain English words are never a mangled name ("water is" ≠ mater)
- if (span === n && maxD > 0 && Math.abs(glued.length - target.length) <= maxD + 1 && levenshtein(glued, target) <= maxD) { consider(e, 'fuzzy', gram.join(' ')); break; }
- // "mattie cuss" → mtks == maticus → mtks; "goose lee" → gsl == goosely → gsl
- if (glued.length >= 6 && phonetic(gram.join(' ')).replace(/ /g, '') === e.key.replace(/ /g, '')) { consider(e, 'phonetic', gram.join(' ')); break; }
- }
- }
- }
- return [...found.values()].sort((a, b) => b.rank - a.rank).map(({ rank, ...r }) => r);
-}
-
-/**
- * Alias entries for a whole roster, roster-aware: besides every spoken form of each name, a handle's
- * FIRST token ("grizzly" for grizzly_bear, "japanese" is NOT for JapaneseOldGuy — common word) becomes
- * an alias when it is ≥ 5 letters, not a common word, and no other fighter starts with it.
- * `extra(id)` may add persona names / spoken_as forms per fighter.
- */
-function rosterEntries(roster, extra = null) {
- const firsts = new Map();
- for (const id of roster.order) { const f = splitHandle(roster.byId[id].user.username)[0]; if (f && f.length >= 5 && !COMMON.has(f) && !/^\d+$/.test(f)) firsts.set(f, (firsts.get(f) || 0) + 1); }
- const list = [];
- for (const id of roster.order) {
- const u = roster.byId[id].user;
- const own = aliasEntries(id, [u.username, u.display_name]);
- const nick = [...(extra ? (extra(id) || []) : [])];
- const first = splitHandle(u.username)[0];
- if (first && firsts.get(first) === 1 && !variants(u.username).includes(first)) nick.push(first);
- list.push(...own, ...aliasEntries(id, nick, { whole: false }).filter(e => !own.some(o => o.name === e.name)));
- }
- return list.sort((a, b) => b.name.length - a.name.length);
-}
-
-module.exports = { variants, splitHandle, deleet, phonetic, levenshtein, aliasEntries, rosterEntries, findMentions, variantRegex, COMMON };
deleted file mode 100644
deleted file mode 100644
@@ -1,19 +0,0 @@
@@ -1,19 +0,0 @@
-/**
- * notify.js — Arena events → the site notification bell (openvibe.network store, via utils/notify).
- * Fire-and-forget, deduped per (user, type, key) for 10 min so clock ticks never spam.
- */
-'use strict';
-
-const { pushNotification } = require('../utils/notify');
-let base = 'https://openvibe.live'; try { base = String(require('../config').baseUrl || base).replace(/\/$/, ''); } catch { /* */ }
-const _recent = new Map();
-function arenaNotify(userId, { type, title, message, icon = '🥊', url = '/arena', key = null, senderId = null, senderName = null }) {
- if (!userId) return;
- const k = `${userId}:${type}:${key || url}`;
- const now = Date.now();
- if (now - (_recent.get(k) || 0) < 10 * 60 * 1000) return;
- _recent.set(k, now);
- if (_recent.size > 5000) for (const [kk, t] of _recent) if (now - t > 10 * 60 * 1000) _recent.delete(kk);
- try { pushNotification({ user_id: userId, type: `ARENA_${String(type).toUpperCase()}`, title: String(title).slice(0, 120), message: String(message || '').slice(0, 240), icon, url: url.startsWith('http') ? url : `${base}${url}`, sender_id: senderId || undefined, sender_name: senderName || undefined }); } catch { /* */ }
-}
-module.exports = { arenaNotify };
deleted file mode 100644
deleted file mode 100644
@@ -1,196 +0,0 @@
@@ -1,196 +0,0 @@
-/**
- * OpenVibe.Live — Arena API (mounted at /api/arena) — Battle Cam mode
- *
- * GET /status · /fighters · /fighters/:user · /fighters/:user/stat/:stat · /live
- * GET /feed?limit&since the shit-talk feed: newest judged mic lines across every cam
- * POST /fighters/:user/refresh admin: regenerate persona (+ portrait)
- * GET /console/:user what the ears hear for a live fighter (focus lock, judgements)
- * GET /beefs · /beefs/:id · POST /beefs/:id/hype {side}
- * 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';
-
-const express = require('express');
-const { requireAuth, optionalAuth } = require('../auth/auth');
-const permissions = require('../auth/permissions');
-const db = require('../db/database');
-const arena = require('./arena-service');
-const mic = require('./mic');
-const beef = require('./beef');
-const listener = require('./listener');
-
-const router = express.Router();
-
-router.use(async (req, res, next) => { if (!await arena.arenaEnabled()) return res.status(404).json({ error: 'Arena is disabled' }); next(); });
-const fail = (res, err, msg) => { console.error('[Arena]', msg, err.message); res.status(500).json({ error: msg }); };
-const userFrom = async (param) => (/^\d+$/.test(String(param)) ? await db.getUserById(Number(param)) : await db.get('SELECT * FROM users WHERE lower(username) = lower(?)', [String(param).replace(/^@/, '')]));
-
-router.get('/status', async (req, res) => { try { res.json(await arena.status()); } catch (err) { fail(res, err, 'Arena unavailable'); } });
-
-router.get('/fighters', async (req, res) => {
- try { res.set('Cache-Control', 'public, max-age=30'); res.json({ fighters: await arena.listFighters(), ai: await arena.aiOn(), image_generation: arena.imageGenAvailable(), stats: arena.STAT_KEYS, stat_meta: arena.STAT_META }); }
- catch (err) { fail(res, err, 'Failed to load the roster'); }
-});
-router.get('/live', async (req, res) => { try { res.set('Cache-Control', 'no-store'); res.json({ live: await arena.liveFighters() }); } catch (err) { fail(res, err, 'Failed to load live fighters'); } });
-router.get('/feed', async (req, res) => {
- try {
- res.set('Cache-Control', 'no-store');
- const limit = Math.min(100, Math.max(1, parseInt(req.query.limit, 10) || 40));
- const since = req.query.since ? Number(req.query.since) : null;
- res.json({ feed: await mic.feed({ limit, since }), ai: await arena.aiOn() });
- } catch (err) { fail(res, err, 'Failed to load the feed'); }
-});
-
-router.get('/fighters/:user/stat/:stat', async (req, res) => {
- try {
- const user = await userFrom(req.params.user);
- if (!user || !(await arena.loadRoster()).byId[user.id]) return res.status(404).json({ error: 'No such fighter on the roster' });
- const detail = await arena.getStatDetail(user.id, String(req.params.stat));
- if (!detail) return res.status(404).json({ error: 'Unknown stat' });
- res.set('Cache-Control', 'public, max-age=120');
- res.json(detail);
- } catch (err) { fail(res, err, 'Failed to load stat detail'); }
-});
-router.get('/fighters/:user', async (req, res) => {
- try {
- const card = await arena.getFighter(req.params.user, { generate: req.query.generate !== '0' });
- if (!card) return res.status(404).json({ error: 'No such fighter' });
- if (!card.not_on_roster) {
- try { card.rivalries = await beef.rivalriesFor(card.user.id); } catch { card.rivalries = []; }
- try { card.moments = await mic.momentsFor(card.user.id, 14); } catch { card.moments = []; }
- try { card.best_lines = await mic.bestLines(card.user.id, 5); } catch { card.best_lines = []; }
- }
- res.set('Cache-Control', 'no-store');
- res.json(card);
- } catch (err) { fail(res, err, 'Failed to load fighter'); }
-});
-router.post('/fighters/:user/refresh', requireAuth, permissions.requireAdmin, async (req, res) => {
- try {
- const card = await arena.getFighter(req.params.user, { generate: false });
- if (!card || card.not_on_roster) return res.status(404).json({ error: 'No such fighter on the roster' });
- const persona = await arena.generatePersona(card.user.id, { force: true });
- const image = req.body?.image !== false && arena.imageGenAvailable() ? await arena.generateImage(card.user.id, { force: true }) : null;
- res.json({ ok: true, persona, image_url: image });
- } catch (err) { fail(res, err, 'Refresh failed'); }
-});
-
-// Admin: judge past speech now (bounded per call; the job also does this every 20 min).
-router.post('/backfill', requireAuth, permissions.requireAdmin, async (req, res) => {
- try { res.json(await require('./backfill').run({ force: true })); } catch (err) { fail(res, err, 'Backfill failed'); }
-});
-
-// ── Live console: what the ears hear for one fighter ──
-router.get('/console/:user', async (req, res) => {
- try {
- const user = await userFrom(req.params.user);
- if (!user) return res.status(404).json({ error: 'No such user' });
- const roster = await arena.loadRoster();
- const brief = await mic.fighterBrief(user.id, roster);
- const liveStream = await db.get('SELECT id, title, started_at, viewer_count FROM streams WHERE user_id = ? AND is_live = 1 ORDER BY started_at DESC LIMIT 1', [user.id]);
- let lines = [];
- if (liveStream) {
- lines = (await db.all(`SELECT text, start_sec, vod_id FROM stream_timeline_events WHERE stream_id = ? AND kind = 'speech' ORDER BY start_sec DESC LIMIT 14`, [liveStream.id])).reverse()
- .filter(l => !arena._isBannedText(l.text)).map(l => ({ text: l.text, sec: Math.floor(l.start_sec), vod_id: l.vod_id }));
- }
- res.set('Cache-Control', 'no-store');
- res.json({
- fighter: brief, on_roster: !!roster.byId[user.id], live: !!liveStream, stream: liveStream || null,
- transcribed: liveStream ? !!await db.get(`SELECT 1 FROM stream_timeline_events WHERE stream_id = ? AND kind = 'speech' AND created_at >= datetime('now', '-30 minutes') LIMIT 1`, [liveStream.id]) : false,
- listener: await listener.consoleState(user.id),
- level: await mic.levelView(user.id),
- mic: await mic.micStats(user.id),
- open_beefs: (await Promise.all((await beef.openBeefsFor(user.id)).map(async b => await beef.beefView(b, roster)))),
- hot_mic: lines,
- recent_moments: await mic.momentsFor(user.id, 8),
- });
- } catch (err) { fail(res, err, 'Failed to load console'); }
-});
-
-// ── Beefs ──
-router.get('/beefs', async (req, res) => { try { res.set('Cache-Control', 'no-store'); res.json(await beef.list()); } catch (err) { fail(res, err, 'Failed to load beefs'); } });
-router.get('/beefs/:id', async (req, res) => {
- try { const b = await beef.get(Number(req.params.id)); if (!b) return res.status(404).json({ error: 'No such beef' }); res.set('Cache-Control', 'no-store'); res.json(b); }
- catch (err) { fail(res, err, 'Failed to load beef'); }
-});
-router.post('/beefs/:id/hype', optionalAuth, async (req, res) => {
- try { res.json(await beef.hype(Number(req.params.id), String(req.body?.side || ''), await arena.voterKeyFor(req))); } catch (err) { res.status(400).json({ error: err.message }); }
-});
-
-// ── Hear it in their voice ──
-router.get('/voice/:user', optionalAuth, async (req, res) => {
- try {
- const voice = require('./voice');
- if (!voice.allow(req.ip, !!req.user)) return res.status(429).json({ error: 'Slow down — too many voice requests' });
- const text = voice.cleanText(req.query.t || req.query.text || '');
- if (text.length < 2) return res.status(400).json({ error: 'Nothing to say' });
- const who = String(req.params.user || '').toLowerCase();
- const user = who === 'announcer' ? null : await userFrom(who);
- if (who !== 'announcer' && !user) return res.status(404).json({ error: 'No such user' });
- const out = await voice.speak({ user, text });
- res.set({ 'Content-Type': out.mimeType, 'Cache-Control': 'public, max-age=604800, immutable', 'X-Arena-Voice': out.voice, 'X-Cache': out.cached ? 'HIT' : 'MISS' });
- require('stream').pipeline(require('fs').createReadStream(out.path), res, () => {}); // closes the file when the listener leaves
- } catch (err) { res.status(err.message && /budget|hoarse/.test(err.message) ? 429 : 500).json({ error: err.message || 'Voice failed' }); }
-});
-
-// ── Your Arena: the signed-in fighter's own state ──
-router.get('/me', requireAuth, async (req, res) => {
- try {
- const roster = await arena.loadRoster();
- const onRoster = !!roster.byId[req.user.id];
- const fighter = onRoster ? { ...await mic.fighterBrief(req.user.id, roster), level: await mic.levelView(req.user.id), record: await beef.recordFor(req.user.id), power: roster.byId[req.user.id].ratings.power, mic: await mic.micStats(req.user.id) } : null;
- const beefs = onRoster ? (await Promise.all((await beef.openBeefsFor(req.user.id)).map(async b => await beef.beefView(b, roster)))) : [];
- const onClock = beefs.filter(b => (b.on_clock === 'a' ? b.a.user.id : b.b.user.id) === req.user.id);
- res.set('Cache-Control', 'no-store');
- res.json({ on_roster: onRoster, fighter, beefs, on_clock: onClock, moments: onRoster ? await mic.momentsFor(req.user.id, 6) : [], live: onRoster ? !!await db.get('SELECT 1 FROM streams WHERE user_id = ? AND is_live = 1 LIMIT 1', [req.user.id]) : false });
- } 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;
deleted file mode 100644
deleted file mode 100644
@@ -1,136 +0,0 @@
@@ -1,136 +0,0 @@
-/**
- * voice.js — "hear it in their voice": Arena lines (taunts, quotes, headlines, ringside calls) read
- * out in the streamer's own OpenVibe chat TTS voice — the cosmetic voice they equipped, else the
- * per-identity auto voice chat already gives them (same `user:<username>` key OpenVibe.Chat uses).
- *
- * Every (voice, text) pair is synthesized ONCE and kept on disk (`data/tts-cache/<hash>.<ext>`),
- * served with a week-long HTTP cache — repeat clicks cost nothing, and a changed voice simply
- * hashes to a new file. Cloud engines are metered into ai_usage (kind 'tts'); a daily cap and a
- * per-IP limit keep the endpoint from becoming a free TTS API.
- */
-'use strict';
-
-const fs = require('fs');
-const path = require('path');
-const crypto = require('crypto');
-const db = require('../db/database');
-
-const CACHE_DIR = require('../paths').dir('TTS_CACHE_PATH', 'tts-cache');
-const MAX_TEXT = 240;
-const MAX_CACHE_MB = 300;
-const MAX_AGE_MS = 60 * 24 * 3600_000;
-const DAILY_MAX = async () => Number(await db.getSetting('arena_voice_daily_max') || 2000);
-const ANNOUNCER_VOICE = async () => String(await db.getSetting('arena_announcer_voice') || await db.getSetting('tts_default_voice') || 'gary');
-const CLOUD_COST_PER_CHAR = { google: 16 / 1_000_000, polly: 16 / 1_000_000 };
-
-let _tts = null;
-function tts() { if (!_tts) _tts = require('../chat/tts-engine'); return _tts; }
-let _synthOverride = null; // tests
-
-function ensureDir() { try { fs.mkdirSync(CACHE_DIR, { recursive: true }); } catch { /* */ } }
-function cleanText(text) { return String(text || '').replace(/\s+/g, ' ').replace(/[<>]/g, '').trim().slice(0, MAX_TEXT); }
-
-/** How this person sounds in chat: equipped cosmetic voice, else the derived auto voice. */
-async function voiceFor(user) {
- if (!user) return { kind: 'announcer', voiceId: await ANNOUNCER_VOICE(), sig: `announcer:${await ANNOUNCER_VOICE()}` };
- try {
- const cosmetics = require('../monetization/cosmetics');
- const prof = await cosmetics.getCosmeticProfile(user.id);
- const itemId = prof && prof.voiceFX && prof.voiceFX.itemId;
- if (itemId && tts().VOICE_CATALOG && tts().VOICE_CATALOG[itemId]) return { kind: 'equipped', voiceId: itemId, sig: `equipped:${itemId}` };
- } catch { /* cosmetics optional */ }
- const key = `user:${String(user.username).toLowerCase()}`;
- let params = null;
- try { params = tts().deriveUserVoiceParams ? tts().deriveUserVoiceParams(key) : null; } catch { /* */ }
- return { kind: 'auto', identityKey: key, params, sig: `auto:${key}:${params ? [params.voice, params.pitch, params.speed, params.gap].join('/') : 'default'}` };
-}
-
-function cacheKey(sig, text) { return crypto.createHash('sha256').update(`${sig}\n${text}`).digest('hex').slice(0, 32); }
-function cachedFile(key) {
- for (const ext of ['mp3', 'wav']) { const p = path.join(CACHE_DIR, `${key}.${ext}`); if (fs.existsSync(p)) return { path: p, mimeType: ext === 'mp3' ? 'audio/mpeg' : 'audio/wav' }; }
- return null;
-}
-
-async function todayCount() { try { return (await db.get(`SELECT COUNT(*) AS n FROM ai_usage WHERE kind = 'tts' AND created_at >= to_char(ov_ts(ov_now()), 'YYYY-MM-DD')`))?.n || 0; } catch { return 0; } }
-
-async function synth(voice, text, username) {
- if (_synthOverride) return _synthOverride(voice, text, username);
- const T = tts();
- if (voice.kind === 'auto' && T.synthesizeUserVoice) return T.synthesizeUserVoice(text, voice.identityKey, username, MAX_TEXT);
- return T.synthesize(text, voice.voiceId, username || 'Arena', MAX_TEXT);
-}
-
-/**
- * Speak `text` as `user` (a users row) or the announcer (null). Returns { path, mimeType, cached, voice }.
- * Throws with a friendly message on limits.
- */
-async function speak({ user = null, text }) {
- ensureDir();
- const clean = cleanText(text);
- if (clean.length < 2) throw new Error('Nothing to say');
- const voice = await voiceFor(user);
- const key = cacheKey(voice.sig, clean);
- const hit = cachedFile(key);
- if (hit) { try { fs.utimesSync(hit.path, new Date(), new Date()); } catch { /* */ } return { ...hit, cached: true, voice: voice.sig }; }
- if (await todayCount() >= await DAILY_MAX()) throw new Error('The announcer is hoarse — daily voice budget reached, try tomorrow');
- const started = Date.now();
- const out = await synth(voice, clean, user?.username);
- if (!out || !out.audio) throw new Error('Voice engine unavailable');
- const ext = /wav/i.test(out.mimeType || '') ? 'wav' : 'mp3';
- const file = path.join(CACHE_DIR, `${key}.${ext}`);
- const tmp = `${file}.tmp`;
- fs.writeFileSync(tmp, Buffer.from(out.audio, 'base64'));
- fs.renameSync(tmp, file);
- const engine = String(out.engine || '').toLowerCase();
- const cloud = /google/.test(engine) ? 'google' : /polly|aws|amazon/.test(engine) ? 'polly' : null;
- try { await db.recordAiUsage({ kind: 'tts', model: `${engine || 'espeak'}:${out.voiceId || voice.voiceId || 'auto'}`, input_tokens: clean.length, output_tokens: 0, cost_usd: cloud ? clean.length * CLOUD_COST_PER_CHAR[cloud] : 0, owner_user_id: user?.id || null, source: 'arena', role: 'tts', provider: engine || 'espeak', latency_ms: Date.now() - started }); } catch { /* */ }
- purge();
- return { path: file, mimeType: ext === 'wav' ? 'audio/wav' : 'audio/mpeg', cached: false, voice: voice.sig, engine };
-}
-
-let _lastPurge = 0;
-function purge() {
- if (Date.now() - _lastPurge < 10 * 60 * 1000) return;
- _lastPurge = Date.now();
- try {
- const files = fs.readdirSync(CACHE_DIR).filter(f => /\.(mp3|wav)$/.test(f)).map(f => { const p = path.join(CACHE_DIR, f); const st = fs.statSync(p); return { p, size: st.size, at: st.mtimeMs }; });
- const now = Date.now();
- let total = 0;
- for (const f of files) { if (now - f.at > MAX_AGE_MS) { try { fs.unlinkSync(f.p); } catch { /* */ } } else total += f.size; }
- if (total > MAX_CACHE_MB * 1024 * 1024) {
- for (const f of files.filter(x => now - x.at <= MAX_AGE_MS).sort((a, b) => a.at - b.at)) { if (total <= MAX_CACHE_MB * 1024 * 1024 * 0.8) break; try { fs.unlinkSync(f.p); total -= f.size; } catch { /* */ } }
- }
- } catch { /* */ }
-}
-
-// Per-IP limiter: anonymous 8/min, signed-in 30/min (cache hits are still counted so a scraper can't loop).
-const _hits = new Map();
-function allow(ip, signedIn) {
- const now = Date.now();
- const lim = signedIn ? 30 : 8;
- const arr = (_hits.get(ip) || []).filter(t => now - t < 60_000);
- if (arr.length >= lim) { _hits.set(ip, arr); return false; }
- arr.push(now); _hits.set(ip, arr);
- if (_hits.size > 5000) { for (const [k, v] of _hits) if (!v.length || now - v[v.length - 1] > 60_000) _hits.delete(k); }
- return true;
-}
-
-/** Park a one-off synthesized clip (admin test / mod preview) in the cache → safe same-origin filename. */
-function stash(audioBase64, mimeType) {
- ensureDir();
- const buf = Buffer.from(String(audioBase64 || ''), 'base64');
- if (!buf.length) return null;
- const ext = /wav/i.test(mimeType || '') ? 'wav' : 'mp3';
- const file = `${crypto.createHash('sha256').update(buf).digest('hex').slice(0, 32)}.${ext}`;
- const full = path.join(CACHE_DIR, file);
- if (!fs.existsSync(full)) { fs.writeFileSync(`${full}.tmp`, buf); fs.renameSync(`${full}.tmp`, full); purge(); }
- return { file, mimeType: ext === 'wav' ? 'audio/wav' : 'audio/mpeg' };
-}
-/** Resolve a cache filename (strict shape, no traversal) → { path, mimeType } or null. */
-function cachedByName(file) {
- if (!/^[a-f0-9]{32}\.(mp3|wav)$/.test(String(file || ''))) return null;
- const full = path.join(CACHE_DIR, file);
- return fs.existsSync(full) ? { path: full, mimeType: file.endsWith('.wav') ? 'audio/wav' : 'audio/mpeg' } : null;
-}
-
-module.exports = { speak, voiceFor, cacheKey, cleanText, allow, purge, stash, cachedByName, CACHE_DIR, MAX_TEXT, _setSynth: (fn) => { _synthOverride = fn; } };
@@ -5,7 +5,7 @@
@@ -5,7 +5,7 @@
* live.profile channel_url, followers, is_streamer, last_live_at (public), stream_minutes_30d
* live.profile channel_url, followers, is_streamer, last_live_at (public), stream_minutes_30d
* live.stats streams_30d, stream_minutes_30d, peak_viewers_30d (public), avg_viewers_30d,
* live.stats streams_30d, stream_minutes_30d, peak_viewers_30d (public), avg_viewers_30d,
* new_followers_30d, computed_at
* new_followers_30d, computed_at
- * live.loyalty channel_points_total, channels (top ten by points), arena_level, arena_xp,
+ * live.loyalty channel_points_total, channels (top ten by points) (the Arena's level and XP went with the Arena),
* computed_at; private (contracts 0.56.0, WS-K task 9). Loyalty, never money.
* computed_at; private (contracts 0.56.0, WS-K task 9). Loyalty, never money.
*
*
* Summaries only: streams and follows stay Live's truth. Written with Live's service token (grants
* Summaries only: streams and follows stay Live's truth. Written with Live's service token (grants
@@ -72,7 +72,7 @@ async function summarize(userId, { now = Date.now() } = {}) {
@@ -72,7 +72,7 @@ async function summarize(userId, { now = Date.now() } = {}) {
const LOYALTY_MIN_INTERVAL_MS = 30 * 60 * 1000; // points move while people watch: at most one write per half hour
const LOYALTY_MIN_INTERVAL_MS = 30 * 60 * 1000; // points move while people watch: at most one write per half hour
-/** live.loyalty for one account: channel points (top ten channels) and the Arena level, or null when
+/** live.loyalty for one account: channel points (top ten channels), or null when
* there is nothing to say. Once a record was written, an account whose points are gone gets zeros. */
* there is nothing to say. Once a record was written, an account whose points are gone gets zeros. */
async function loyaltyOf(userId) {
async function loyaltyOf(userId) {
const d = db.getDb();
const d = db.getDb();
@@ -80,15 +80,11 @@ async function loyaltyOf(userId) {
@@ -80,15 +80,11 @@ async function loyaltyOf(userId) {
WHERE cp.user_id = ? AND cp.balance > 0 ORDER BY cp.balance DESC, u.username LIMIT 10`).all(userId))
WHERE cp.user_id = ? AND cp.balance > 0 ORDER BY cp.balance DESC, u.username LIMIT 10`).all(userId))
.map((r) => ({ channel: String(r.channel).slice(0, 64), points: r.points }));
.map((r) => ({ channel: String(r.channel).slice(0, 64), points: r.points }));
const total = (await d.prepare('SELECT COALESCE(SUM(balance), 0)::bigint AS n FROM channel_points WHERE user_id = ? AND balance > 0').get(userId)).n;
const total = (await d.prepare('SELECT COALESCE(SUM(balance), 0)::bigint AS n FROM channel_points WHERE user_id = ? AND balance > 0').get(userId)).n;
- let arena = null;
- try { arena = await d.prepare('SELECT xp, level FROM arena_trash_levels WHERE user_id = ?').get(userId) || null; } catch { /* the arena tables do not exist yet */ }
- if (!total && !arena) {
+ if (!total) {
const had = await d.prepare("SELECT 1 FROM module_summary_pushes WHERE user_id = ? AND namespace = 'live.loyalty'").get(userId);
const had = await d.prepare("SELECT 1 FROM module_summary_pushes WHERE user_id = ? AND namespace = 'live.loyalty'").get(userId);
return had ? { channel_points_total: 0, channels: [] } : null;
return had ? { channel_points_total: 0, channels: [] } : null;
}
}
- const out = { channel_points_total: total, channels };
- if (arena) { out.arena_level = Math.max(1, Number(arena.level) || 1); out.arena_xp = Math.max(0, Number(arena.xp) || 0); }
- return out;
+ return { channel_points_total: total, channels };
}
}
function client() {
function client() {
@@ -138,9 +134,6 @@ async function push(userId, { modules = client(), now = Date.now(), force = fals
@@ -138,9 +134,6 @@ async function push(userId, { modules = client(), now = Date.now(), force = fals
return written;
return written;
}
}
-const tableExists = async (name) => Boolean(await db.getDb().prepare("SELECT 1 FROM information_schema.tables WHERE table_schema = current_schema() AND table_name = ?").get(name));
-const arenaLog = async () => await tableExists('arena_xp_log');
-const arenaLevels = async () => await tableExists('arena_trash_levels');
let lastScan = null;
let lastScan = null;
/** People whose stream ended, or who gained a follower, since the previous scan. */
/** People whose stream ended, or who gained a follower, since the previous scan. */
@@ -151,8 +144,7 @@ async function scan({ modules = client(), now = Date.now() } = {}) {
@@ -151,8 +144,7 @@ async function scan({ modules = client(), now = Date.now() } = {}) {
const ids = (await db.getDb().prepare(`SELECT user_id AS id FROM streams WHERE ended_at >= ? AND ended_at < ?
const ids = (await db.getDb().prepare(`SELECT user_id AS id FROM streams WHERE ended_at >= ? AND ended_at < ?
UNION SELECT streamer_id AS id FROM follows WHERE created_at >= ? AND created_at < ?
UNION SELECT streamer_id AS id FROM follows WHERE created_at >= ? AND created_at < ?
UNION SELECT user_id AS id FROM streams WHERE started_at >= ? AND started_at < ?
UNION SELECT user_id AS id FROM streams WHERE started_at >= ? AND started_at < ?
- UNION SELECT user_id AS id FROM channel_points WHERE updated_at >= ? AND updated_at < ?${await arenaLog() ? `
- UNION SELECT user_id AS id FROM arena_xp_log WHERE created_at >= ? AND created_at < ?` : ''}`).all(...Array(await arenaLog() ? 5 : 4).fill([from, to]).flat())).map((r) => r.id);
+ UNION SELECT user_id AS id FROM channel_points WHERE updated_at >= ? AND updated_at < ?`).all(...Array(4).fill([from, to]).flat())).map((r) => r.id);
for (const id of ids) await push(id, { modules, now });
for (const id of ids) await push(id, { modules, now });
lastScan = to;
lastScan = to;
stats.lastScanAt = new Date(now).toISOString();
stats.lastScanAt = new Date(now).toISOString();
@@ -166,8 +158,7 @@ async function refresh({ modules = client(), now = Date.now() } = {}) {
@@ -166,8 +158,7 @@ async function refresh({ modules = client(), now = Date.now() } = {}) {
const ids = (await db.getDb().prepare(`SELECT DISTINCT user_id AS id FROM streams WHERE started_at >= ?
const ids = (await db.getDb().prepare(`SELECT DISTINCT user_id AS id FROM streams WHERE started_at >= ?
UNION SELECT DISTINCT streamer_id AS id FROM follows
UNION SELECT DISTINCT streamer_id AS id FROM follows
UNION SELECT DISTINCT user_id AS id FROM channel_points WHERE balance > 0
UNION SELECT DISTINCT user_id AS id FROM channel_points WHERE balance > 0
- UNION SELECT user_id AS id FROM module_summary_pushes WHERE namespace = 'live.loyalty'${await arenaLevels() ? `
- UNION SELECT user_id AS id FROM arena_trash_levels` : ''}`).all(since)).map((r) => r.id);
+ UNION SELECT user_id AS id FROM module_summary_pushes WHERE namespace = 'live.loyalty'`).all(since)).map((r) => r.id);
for (const id of ids) await push(id, { modules, now, force: true });
for (const id of ids) await push(id, { modules, now, force: true });
return ids.length;
return ids.length;
}
}
@@ -240,7 +240,7 @@ contextRouter.get('/anon-first-seen', async (req, res) => {
@@ -240,7 +240,7 @@ contextRouter.get('/anon-first-seen', async (req, res) => {
contextRouter.get('/tts-audio/:file', (req, res) => {
contextRouter.get('/tts-audio/:file', (req, res) => {
try {
try {
- const hit = require('../arena/voice').cachedByName(req.params.file);
+ const hit = require('../rundown/voice').cachedByName(req.params.file);
if (!hit) return fail(res, 404, 'No such clip');
if (!hit) return fail(res, 404, 'No such clip');
res.set({ 'Content-Type': hit.mimeType });
res.set({ 'Content-Type': hit.mimeType });
require('stream').pipeline(require('fs').createReadStream(hit.path), res, () => {}); // closes the file when the listener leaves
require('stream').pipeline(require('fs').createReadStream(hit.path), res, () => {}); // closes the file when the listener leaves
@@ -450,7 +450,7 @@ effectsRouter.post('/arena-command', async (req, res) => {
@@ -450,7 +450,7 @@ effectsRouter.post('/arena-command', async (req, res) => {
track: (p) => { work = p; },
track: (p) => { work = p; },
};
};
let handled = false;
let handled = false;
- try { handled = await require('../arena/arena-chat').handle(shim, null, client, String(req.body.cmd || ''), Array.isArray(req.body.parts) ? req.body.parts : []); } catch (e) { console.warn('[Arena] chat command:', e.message); }
+ try { handled = await require('../rundown/chat-commands').handle(shim, null, client, String(req.body.cmd || ''), Array.isArray(req.body.parts) ? req.body.parts : []); } catch (e) { console.warn('[Arena] chat command:', e.message); }
if (work) { try { await work; } catch { /* the command answers its own errors */ } }
if (work) { try { await work; } catch { /* the command answers its own errors */ } }
res.json(replies.length ? { handled: !!handled, replies } : { handled: !!handled });
res.json(replies.length ? { handled: !!handled, replies } : { handled: !!handled });
});
});
@@ -670,12 +670,6 @@ app.use('/data/ai-moments', express.static(paths.dir('AI_MOMENTS_PATH', 'ai-mome
@@ -670,12 +670,6 @@ app.use('/data/ai-moments', express.static(paths.dir('AI_MOMENTS_PATH', 'ai-mome
maxAge: '30d', immutable: true, index: false, dotfiles: 'deny',
maxAge: '30d', immutable: true, index: false, dotfiles: 'deny',
}));
}));
-// Arena portraits (AI-generated fighting-game character art, see server/arena)
-app.use('/data/arena', express.static(paths.dir('ARENA_IMAGE_PATH', 'arena'), {
- maxAge: '7d', immutable: true, index: false, dotfiles: 'deny',
- setHeaders: (res) => res.setHeader('Content-Type', 'image/png'),
-}));
-
// OpenVibe.Media → Live outcomes (vod.ready / clip.ready …): the OpenVibe.Events delivery of
// OpenVibe.Media → Live outcomes (vod.ready / clip.ready …): the OpenVibe.Events delivery of
// media.vod.* / media.clip.* / media.storage.* (MEDIA_EVENTS_SECRET), applied once per outcome
// media.vod.* / media.clip.* / media.storage.* (MEDIA_EVENTS_SECRET), applied once per outcome
// (server/media-proxy/outcomes.js). Mounted BEFORE the /internal router because it authenticates
// (server/media-proxy/outcomes.js). Mounted BEFORE the /internal router because it authenticates
@@ -771,7 +765,6 @@ app.use('/api/vods', searchMedia.afterChange('vod'), vodRoutes);
@@ -771,7 +765,6 @@ app.use('/api/vods', searchMedia.afterChange('vod'), vodRoutes);
app.use('/api/clips', searchMedia.afterChange('clip'), clipRoutes);
app.use('/api/clips', searchMedia.afterChange('clip'), clipRoutes);
app.use('/api/chat-ai', require('./ai/chat-ai-routes'));
app.use('/api/chat-ai', require('./ai/chat-ai-routes'));
app.use('/api/easter-egg', require('./ai/easter-egg-routes'));
app.use('/api/easter-egg', require('./ai/easter-egg-routes'));
-app.use('/api/arena', require('./arena/routes')); // the old Arena's reads, until its code is removed (docs/rundown.md)
app.use('/api/rundown', require('./rundown/routes')); // what streamers talked about, written up (docs/rundown.md)
app.use('/api/rundown', require('./rundown/routes')); // what streamers talked about, written up (docs/rundown.md)
app.use('/api/recap', require('./recap/routes')); // after-show reports (docs/recap.md)
app.use('/api/recap', require('./recap/routes')); // after-show reports (docs/recap.md)
app.use('/api/social', require('./social/routes').router); // channel social links and their previews (server/social/)
app.use('/api/social', require('./social/routes').router); // channel social links and their previews (server/social/)
new file mode 100644
new file mode 100644
@@ -0,0 +1,59 @@
@@ -0,0 +1,59 @@
+/**
+ * OpenVibe.Live — The Rundown's chat commands (the Arena's three, which Chat still forwards to
+ * /internal/chat-effects/arena-command; the Arena became The Rundown on 2026-10-19).
+ *
+ * !arena [user] what the streamer (or user) has been talking about on The Rundown + a link
+ * !hype, !beef beefs are gone: a pointer to The Rundown
+ *
+ * Called from ChatServer.handleBangCommand; returns true when the command was handled. Replies go to
+ * the sender as a system line.
+ */
+'use strict';
+
+const db = require('../db/database');
+
+const COMMANDS = ['!hype', '!beef', '!arena'];
+const RATE_MS = 4000;
+const _last = new Map(); // voterKey → ms
+
+function voterKey(client) {
+ if (client.user?.id) return `user:${client.user.id}`;
+ if (client.anonId) return `anon:${client.anonId}`;
+ return `ip:${String(client.ip || '')}`;
+}
+function limited(map, key, ms) {
+ const now = Date.now();
+ if (now - (map.get(key) || 0) < ms) return true;
+ map.set(key, now);
+ return false;
+}
+function base() { try { const c = require('../config'); return String(c.baseUrl || '').replace(/\/$/, ''); } catch { return ''; } }
+
+async function handle(chat, ws, client, cmd, parts) {
+ if (!COMMANDS.includes(cmd)) return false;
+ const reply = (message) => chat.sendTo(ws, { type: 'system', message });
+ if (limited(_last, voterKey(client), RATE_MS)) { reply('Easy — one command every few seconds.'); return true; }
+ const work = (async () => {
+ try {
+ if (cmd !== '!arena') return reply(`Beefs are gone. The Rundown writes up what streamers talk about instead → ${base()}/rundown`);
+ const stream = client.streamId ? await db.getStreamById(client.streamId) : null;
+ const target = parts[1] ? await db.get('SELECT id, username, display_name FROM users WHERE lower(username) = lower(?)', [String(parts[1]).replace(/^@/, '')])
+ : (stream ? await db.get('SELECT id, username, display_name FROM users WHERE id = ?', [stream.user_id]) : null);
+ if (!target) return reply(parts[1] ? `Nobody called ${String(parts[1]).slice(0, 30)} here.` : `What streamers talked about, written up → ${base()}/rundown`);
+ const read = require('./read');
+ const page = await read.streamer(target.username);
+ const name = target.display_name || target.username;
+ if (!page || page.opted_out) return reply(`${name} keeps themselves out of The Rundown.`);
+ const ep = page.episodes[0];
+ const topics = page.topics.slice(0, 3).map((t) => t.title);
+ if (!ep && !topics.length) return reply(`Nothing on The Rundown about ${name} yet: it takes talk on a transcribed stream → ${base()}/rundown`);
+ return reply(`📰 ${name}${ep ? ` — latest episode: “${ep.title}”` : ''}${topics.length ? ` · talks about ${topics.join(', ')}` : ''} → ${base()}/rundown/${encodeURIComponent(target.username)}`);
+ } catch (err) {
+ reply(`The Rundown: ${err.message}`);
+ }
+ })();
+ if (typeof chat.track === 'function') chat.track(work);
+ return true;
+}
+
+module.exports = { handle, COMMANDS, _voterKey: voterKey };
@@ -17,7 +17,7 @@
@@ -17,7 +17,7 @@
* is in the transcript word for word; a headline, account or take that uses an accusing word the transcript never used
* is in the transcript word for word; a headline, account or take that uses an accusing word the transcript never used
* drops the segment (a quip, only the quip); every text passes db.noLooks(). Streamers in arena_opt_outs (the Arena's
* drops the segment (a quip, only the quip); every text passes db.noLooks(). Streamers in arena_opt_outs (the Arena's
* opt-out, kept for the Rundown) are never read. A streamer named in a segment is named by their whole username or
* opt-out, kept for the Rundown) are never read. A streamer named in a segment is named by their whole username or
- * display name in the segment's own lines (server/arena/names.js, wholeOnly), never a sound-alike.
+ * display name in the segment's own lines (server/rundown/names.js, wholeOnly), never a sound-alike.
*/
*/
const db = require('../db/database');
const db = require('../db/database');
const filters = require('./filters');
const filters = require('./filters');
@@ -78,20 +78,20 @@ const clean = (t, n) => {
@@ -78,20 +78,20 @@ const clean = (t, n) => {
// ── Who can be named ─────────────────────────────────────────
// ── Who can be named ─────────────────────────────────────────
let _people = { at: 0, entries: [] };
let _people = { at: 0, entries: [] };
/**
/**
- * Name entries for every streamer who streamed in the last 60 days. Only a whole name counts (server/arena/names.js),
+ * Name entries for every streamer who streamed in the last 60 days. Only a whole name counts (server/rundown/names.js),
* and never a name that is an everyday word or shorter than 5 letters: "you rubes" names nobody.
* and never a name that is an everyday word or shorter than 5 letters: "you rubes" names nobody.
*/
*/
async function nameEntries() {
async function nameEntries() {
if (Date.now() - _people.at < 10 * 60 * 1000) return _people.entries;
if (Date.now() - _people.at < 10 * 60 * 1000) return _people.entries;
const rows = await db.all('SELECT u.id, u.username, u.display_name FROM users u WHERE u.id IN (SELECT DISTINCT user_id FROM streams WHERE started_at >= ?)', [daysAgo(60)]);
const rows = await db.all('SELECT u.id, u.username, u.display_name FROM users u WHERE u.id IN (SELECT DISTINCT user_id FROM streams WHERE started_at >= ?)', [daysAgo(60)]);
- const names = require('../arena/names');
+ const names = require('./names');
const roster = { order: rows.map((r) => r.id), byId: Object.fromEntries(rows.map((r) => [r.id, { user: r }])) };
const roster = { order: rows.map((r) => r.id), byId: Object.fromEntries(rows.map((r) => [r.id, { user: r }])) };
const entries = names.rosterEntries(roster).filter((e) => e.whole && String(e.name).replace(/[^a-z0-9]/gi, '').length >= 5 && !names.COMMON.has(String(e.name).toLowerCase()));
const entries = names.rosterEntries(roster).filter((e) => e.whole && String(e.name).replace(/[^a-z0-9]/gi, '').length >= 5 && !names.COMMON.has(String(e.name).toLowerCase()));
_people = { at: Date.now(), entries };
_people = { at: Date.now(), entries };
return _people.entries;
return _people.entries;
}
}
async function mentionsIn(text, speakerId) {
async function mentionsIn(text, speakerId) {
- const found = require('../arena/names').findMentions(text, await nameEntries(), { excludeUserId: speakerId, wholeOnly: true });
+ const found = require('./names').findMentions(text, await nameEntries(), { excludeUserId: speakerId, wholeOnly: true });
return [...new Set(found.map((m) => Number(m.userId)).filter(Boolean))];
return [...new Set(found.map((m) => Number(m.userId)).filter(Boolean))];
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,203 @@
@@ -0,0 +1,203 @@
+/**
+ * names.js — how a streamer's name comes out of a speech-to-text engine, and how to find it.
+ *
+ * "JapaneseOldGuy" is never transcribed as one token: whisper hears "Japanese old guy",
+ * "japanese-old-guy", "Japanese Old Guy's stream". "lofi_dan99" → "lofi dan". "Maticus" →
+ * "Matticus" / "Mattie cuss". "x_Goosely_TV" → "Goosely". This module turns a name into every
+ * spoken form we can predict, then matches transcript lines against them with:
+ *
+ * 1. exact/loose-separator matches ("japanese old guy", "japanese-old-guy", "japaneseoldguy")
+ * 2. fuzzy matches on n-grams of the line (edit distance scaled by length)
+ * 3. phonetic matches (a compact metaphone-ish key: "goosely" ≈ "goose lee" ≈ "goosley")
+ *
+ * Guard rails: single-word aliases must be ≥ 4 letters and not a common English word, so a
+ * fighter called "Guy" or "Old" cannot be "mentioned" by every sentence; multi-word aliases only
+ * match as the whole phrase; the speaker never matches themselves; longest alias wins.
+ */
+'use strict';
+
+const COMMON = new Set(('about after again also always another anyone anything around because before being between both bring build built came cant cannot chat check come could does doing done down each else even ever every first from game games getting give going gone good great group guy guys have having here home into just keep kind know last later least life like little live look made make many maybe mean might more most much must name need never next nice night nobody nothing okay only other over people place play player point pretty probably really right same says school seen shit should show since some something sorry stay still stop stream streamer stuff sure take talk tell than thank that their them then there these they thing think this those three time today told took trying under until very want watch week well went were what when where which while whole will with without work would year years young your live love hate lord king queen dude bro bruh fam yeah yes okay old new big small guy girl boy man men women woman baby dan dave mike john tom jack ben sam max alex chris '
+ + 'matter meter motor water later letter little level lever mother master mister monster minute minutes money moment mountain market maybe major magic medic metal middle mirror mission model modern moving music mute native nature never number often order other paper party power pretty price quick quiet radio ready river robot round sauce scary second server seven shadow silver simple sister sixty slower smaller smoke solid sound south space speak speed spend spirit sport square start state steam story street strong stuff sugar summer super sweet table taken teach teeth thank thick thing third thought throw tiger timer title toast total touch tower track trade train travel treat truck truly trust truth turned twenty under upper usual value video village voice waste water wheel where white whole winter wonder world worry worse worth would write wrong yellow young about above across actor adult again agent ahead alarm album alien alive allow alone along alter among angle animal answer anyway apple armor arrow attack aware awful bacon badge basic battle beach beast begin being below bench birth black blade blame blank blast blind block blood board boost boots bottle bottom bounce bounty brain brand brave bread break brick bridge bright broken brush bucket buddy budget bullet button cable camera candy cannon cargo carry castle catch cause chain chair chance change charge chase cheap cheese chest chief child choice church claim class clean clear click climb clock close cloud coach coast coffee color combo comic coming crazy crash cream credit crime cross crowd crown cycle daily damage dance dark death decent delay demon depth desert device diamond dinner direct dirty double dozen dragon drama dream dress drink drive dungeon early earth eight empty enemy energy engine enter entire equal error event exact extra faith false fancy farm fast fatal fault favor feast fence field fight figure final finish fire flame flash flesh floor flower focus force forest forge frame fresh front frozen fruit funny giant glass glory gold grand grass green guard guess guide happy heart heavy hello hidden honor horse hotel house human hunt image inside iron island jacket joint judge juice jungle knife knight label ladder large laser latest laugh layer leader legend lemon light limit liquid local lucky lunch magic manual march match medal melee metal minor mobile moon mouse mouth movie nasty needle noise north notice number nurse ocean offer olive onion orange order outer oven owner paint panel panic paper patch peace pearl pedal pencil phone photo piano piece pilot pizza plain plane plant plate plush pocket poison police potato pound press prime print prize proof pulse punch puzzle queen quest rabbit radar raid rain range ratio razor reach recent relax reload rescue retro rifle rival rocket rough route royal rubber ruler rusty sadly safety salad sample sandwich scale scene score scout screen script scroll search secret select shape share sharp shelf shield shirt shock shoot shore short shoulder shovel signal silent skill skull slate sleep slice slide slime smart smell smile snake sneak snipe snow sock solar solo sorry sound spare spark spawn spear spell spice spider spike spoon spray staff stage stair stamp stand star steel step stick stone store storm story stove strange strike stupid sudden sunny sword tackle talent tank taste tavern temple tennis thief thirty ticket tired toilet token tomato tooth torch tough toxic trail trap trash treasure trick troll trophy trouble tunnel turtle twist uncle unique unit upset urban valley vault vendor virus vision vocal volume wagon waiting wallet weapon weird whale wheat window wire wizard wolf worker yard zombie zone'
+).split(/\s+/));
+
+const SUFFIXES = /(?:_|-)?(?:tv|ttv|live|official|yt|twitch|gaming|stream|streams|vods|irl|hd|4k)$/i;
+
+const LEET = { '0': 'o', '1': 'i', '3': 'e', '4': 'a', '5': 's', '7': 't', '8': 'b', '@': 'a', '$': 's', '!': 'i', '|': 'l' };
+
+function clean(s) { return String(s || '').trim(); }
+function words(s) { return String(s || '').toLowerCase().split(/[^a-z0-9]+/).filter(Boolean); }
+
+/** Split a handle the way it is SAID: camelCase, snake_case, kebab, letters|digits. */
+function splitHandle(name) {
+ let s = clean(name);
+ if (!s) return [];
+ s = s.replace(/[_.\-+]+/g, ' ')
+ .replace(/([a-z])([A-Z])/g, '$1 $2') // JapaneseOldGuy → Japanese Old Guy
+ .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2') // XXGooselyXX → XX Goosely XX
+ .replace(/([a-zA-Z])(\d)/g, '$1 $2') // dan99 → dan 99
+ .replace(/(\d)([a-zA-Z])/g, '$1 $2');
+ return s.toLowerCase().split(/\s+/).filter(Boolean);
+}
+
+function deleet(w) { w = String(w); if (/^\d+$/.test(w)) return w; /* 151 stays a number, never 'isi' */ return /[a-z]/i.test(w) ? w.replace(/[0134578@$!|]/g, c => LEET[c] || c) : w; }
+
+/**
+ * All predicted spoken forms of one name (lowercase word arrays, deduped, most specific first).
+ * JapaneseOldGuy → ["japanese old guy"], ["japaneseoldguy"]
+ * lofi_dan99 → ["lofi dan 99"], ["lofi dan"], ["lofidan"]
+ * x_Goosely_TV → ["x goosely tv"], ["goosely"], ["x goosely"]
+ * M4ticus → ["m4ticus"], ["maticus"]
+ */
+function variants(name) {
+ const out = new Set();
+ const add = (ws) => { const v = ws.filter(Boolean).join(' ').trim(); if (v.length >= 3) out.add(v); };
+ const raw = clean(name);
+ if (!raw) return [];
+ const base = splitHandle(raw);
+ add(base);
+ add(base.map(deleet));
+ // Leet inside a token (M4ticus, l33t_dan) → un-leet the whole handle first, then split.
+ if (/[a-z][0134578@$!|]+[a-z]/i.test(raw)) { const b0 = splitHandle(raw.replace(/[0134578@$!|]/g, c => LEET[c] || c)); add(b0); add(b0.filter(w => !/^\d+$/.test(w))); }
+ add(base.filter(w => !/^\d+$/.test(w))); // drop pure numbers: "lofi dan 99" → "lofi dan"
+ add(base.map(deleet).filter(w => !/^\d+$/.test(w)));
+ const noSuffix = raw.replace(SUFFIXES, '');
+ if (noSuffix !== raw) { const b2 = splitHandle(noSuffix); add(b2); add(b2.filter(w => !/^\d+$/.test(w))); }
+ // Decorations people put around handles: xX_name_Xx, __name__, TheName → name
+ const stripped = raw.replace(/^(?:xx+|x_|_+|the)/i, '').replace(/(?:xx+|_x|_+)$/i, '');
+ if (stripped !== raw && stripped.length >= 3) { const b3 = splitHandle(stripped); add(b3); add(b3.filter(w => !/^\d+$/.test(w))); }
+ // Glued form for names that ARE a phrase (a transcriber may glue or split them).
+ for (const v of [...out]) { const ws = v.split(' '); if (ws.length > 1 && ws.every(w => /^[a-z]+$/.test(w))) out.add(ws.join('')); }
+ // Drop unsafe single common words (would match every sentence).
+ return [...out].filter(v => v.split(' ').length > 1 || (v.length >= 4 && !COMMON.has(v) && !/^\d+$/.test(v))).sort((a, b) => b.length - a.length);
+}
+
+// ── Fuzzy + phonetic ────────────────────────────────────────
+
+function levenshtein(a, b) {
+ if (a === b) return 0;
+ const m = a.length, n = b.length;
+ if (!m) return n; if (!n) return m;
+ let prev = Array.from({ length: n + 1 }, (_, i) => i);
+ for (let i = 1; i <= m; i++) {
+ const cur = [i];
+ for (let j = 1; j <= n; j++) cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
+ prev = cur;
+ }
+ return prev[n];
+}
+
+/** Compact phonetic key: "goosely" → "gsl", "goose lee" → "gsl", "matticus" → "mtks", "japanese old guy" → "jpns ld g". */
+function phonetic(s) {
+ let t = String(s || '').toLowerCase().replace(/[^a-z ]/g, '');
+ t = t.replace(/ph/g, 'f').replace(/ck/g, 'k').replace(/qu/g, 'kw').replace(/x/g, 'ks').replace(/z/g, 's').replace(/wh/g, 'w')
+ .replace(/c(?=[eiy])/g, 's').replace(/c/g, 'k').replace(/g(?=[eiy])/g, 'j').replace(/dg/g, 'j').replace(/tio/g, 'sho').replace(/y/g, 'i');
+ return t.split(' ').map(w => {
+ if (!w) return '';
+ const first = w[0];
+ let rest = w.slice(1).replace(/[aeiouhw]/g, '');
+ rest = rest.replace(/(.)\1+/g, '$1');
+ return (first + rest).replace(/(.)\1+/g, '$1');
+ }).filter(Boolean).join(' ');
+}
+function ratioThreshold(len) { return len <= 4 ? 0 : len <= 7 ? 1 : len <= 11 ? 2 : 3; }
+
+// ── Matching ────────────────────────────────────────────────
+
+/** Regex for one variant: words joined by any separator, optional 's / plural, no letters glued around. */
+function variantRegex(v) {
+ const parts = v.split(' ').map(w => w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
+ return new RegExp(`(?:^|[^a-z0-9])@?${parts.join('[\\s\\-_.]*')}(?:'?s)?(?![a-z0-9])`, 'i');
+}
+
+/** Every letter of a name, in the spellings it can be said in whole ("finditfixit", "mnss22741", "maticus" for M4ticus). */
+function wholeLetters(name) {
+ const raw = clean(name);
+ const base = splitHandle(raw);
+ return new Set([base, base.map(deleet), splitHandle(raw.replace(/[0134578@$!|]/g, c => LEET[c] || c))].map(ws => ws.join('')).filter(Boolean));
+}
+
+/**
+ * Build an alias entry: { userId, name, words, re, key, whole } for each variant of each name. `whole` is a variant
+ * that says the entire name, not a piece of it: "find it fix it" is finditfixit, "fix it" is not; "mnss 22741" is
+ * mnss22741, "mnss" is not. `{ whole: false }` marks every entry as a piece (nicknames, persona names).
+ */
+function aliasEntries(userId, names, { whole = true } = {}) {
+ const seen = new Map();
+ const out = [];
+ for (const n of names.filter(Boolean)) {
+ const full = whole ? wholeLetters(n) : new Set();
+ for (const v of variants(n)) {
+ const letters = v.replace(/\s+/g, '');
+ if (seen.has(v)) { if (full.has(letters)) seen.get(v).whole = true; continue; }
+ const e = { userId, name: v, words: v.split(' '), re: variantRegex(v), key: phonetic(v), letters, whole: full.has(letters) };
+ seen.set(v, e);
+ out.push(e);
+ }
+ }
+ return out;
+}
+
+/**
+ * Which alias entries occur in `text`. Returns [{ userId, name, how: 'exact'|'fuzzy'|'phonetic', hit }].
+ * `excludeUserId` = the speaker (never mentions themselves). `wholeOnly`: only a whole name said as it is spelled
+ * (no pieces, no sound-alikes): what a public claim that someone named someone needs.
+ */
+function findMentions(text, entries, { excludeUserId = null, fuzzy = true, wholeOnly = false } = {}) {
+ const found = new Map(); // userId → best hit
+ const raw = String(text || '');
+ if (raw.length < 3) return [];
+ if (wholeOnly) { entries = entries.filter(e => e.whole); fuzzy = false; }
+ const consider = (e, how, hit) => { if (e.userId === excludeUserId) return; const cur = found.get(e.userId); if (!cur || cur.rank < (how === 'exact' ? 3 : how === 'fuzzy' ? 2 : 1)) found.set(e.userId, { userId: e.userId, name: e.name, how, hit, rank: how === 'exact' ? 3 : how === 'fuzzy' ? 2 : 1 }); };
+ // 1) exact / loose separators
+ for (const e of entries) { const m = raw.match(e.re); if (m) consider(e, 'exact', m[0].trim()); }
+ if (!fuzzy) return [...found.values()].map(({ rank, ...r }) => r);
+ // 2) fuzzy + phonetic on n-grams (1..4 words) of the line
+ const toks = words(raw).map(deleet);
+ if (!toks.length) return [...found.values()].map(({ rank, ...r }) => r);
+ for (const e of entries) {
+ if (found.has(e.userId) && found.get(e.userId).rank === 3) continue;
+ const n = e.words.length;
+ const target = e.letters;
+ if (target.length < 6) continue; // "mater" ≈ matter/meter/water — too short to fuzzy-match safely
+ if (n === 1 && COMMON.has(e.name)) continue;
+ const maxD = ratioThreshold(target.length);
+ // n-grams of n words, and of n±1 words (a name can be split or glued by the transcriber)
+ for (const span of [n, n + 1, n - 1].filter(x => x >= 1 && x <= 4)) {
+ for (let i = 0; i + span <= toks.length; i++) {
+ const gram = toks.slice(i, i + span);
+ const glued = gram.join('');
+ if (Math.abs(glued.length - target.length) > maxD + 4) continue;
+ // Fuzzy (typo-level) only on the same number of words: "matticus" ~ "maticus", "japanese old gai" ~
+ // "japanese old guy" — but never "japanese guy" ~ "japanese old guy" (a different phrase).
+ if (gram.filter(w => COMMON.has(w)).length * 2 > gram.length) continue; // plain English words are never a mangled name ("water is" ≠ mater)
+ if (span === n && maxD > 0 && Math.abs(glued.length - target.length) <= maxD + 1 && levenshtein(glued, target) <= maxD) { consider(e, 'fuzzy', gram.join(' ')); break; }
+ // "mattie cuss" → mtks == maticus → mtks; "goose lee" → gsl == goosely → gsl
+ if (glued.length >= 6 && phonetic(gram.join(' ')).replace(/ /g, '') === e.key.replace(/ /g, '')) { consider(e, 'phonetic', gram.join(' ')); break; }
+ }
+ }
+ }
+ return [...found.values()].sort((a, b) => b.rank - a.rank).map(({ rank, ...r }) => r);
+}
+
+/**
+ * Alias entries for a whole roster, roster-aware: besides every spoken form of each name, a handle's
+ * FIRST token ("grizzly" for grizzly_bear, "japanese" is NOT for JapaneseOldGuy — common word) becomes
+ * an alias when it is ≥ 5 letters, not a common word, and no other fighter starts with it.
+ * `extra(id)` may add persona names / spoken_as forms per fighter.
+ */
+function rosterEntries(roster, extra = null) {
+ const firsts = new Map();
+ for (const id of roster.order) { const f = splitHandle(roster.byId[id].user.username)[0]; if (f && f.length >= 5 && !COMMON.has(f) && !/^\d+$/.test(f)) firsts.set(f, (firsts.get(f) || 0) + 1); }
+ const list = [];
+ for (const id of roster.order) {
+ const u = roster.byId[id].user;
+ const own = aliasEntries(id, [u.username, u.display_name]);
+ const nick = [...(extra ? (extra(id) || []) : [])];
+ const first = splitHandle(u.username)[0];
+ if (first && firsts.get(first) === 1 && !variants(u.username).includes(first)) nick.push(first);
+ list.push(...own, ...aliasEntries(id, nick, { whole: false }).filter(e => !own.some(o => o.name === e.name)));
+ }
+ return list.sort((a, b) => b.name.length - a.name.length);
+}
+
+module.exports = { variants, splitHandle, deleet, phonetic, levenshtein, aliasEntries, rosterEntries, findMentions, variantRegex, COMMON };
@@ -11,7 +11,7 @@
@@ -11,7 +11,7 @@
* POST /react {type, id, reaction} one per reader per item (fire, lol, dead, cap, facts); the same again takes it back
* POST /react {type, id, reaction} one per reader per item (fire, lol, dead, cap, facts); the same again takes it back
* POST /hide {type, id} staff: take a segment, episode or topic down
* POST /hide {type, id} staff: take a segment, episode or topic down
* GET|PUT /opt-out {out} signed in: keep yourself out of the Rundown (or come back)
* GET|PUT /opt-out {out} signed in: keep yourself out of the Rundown (or come back)
- * GET /voice/:user?t= a line in that streamer's chat voice (server/arena/voice.js)
+ * GET /voice/:user?t= a line in that streamer's chat voice (server/rundown/voice.js)
*/
*/
const crypto = require('crypto');
const crypto = require('crypto');
const express = require('express');
const express = require('express');
@@ -78,7 +78,7 @@ router.put('/opt-out', requireAuth, async (req, res) => {
@@ -78,7 +78,7 @@ router.put('/opt-out', requireAuth, async (req, res) => {
});
});
router.get('/voice/:user', optionalAuth, async (req, res) => {
router.get('/voice/:user', optionalAuth, async (req, res) => {
try {
try {
- const voice = require('../arena/voice');
+ const voice = require('./voice');
if (!voice.allow(req.ip, !!req.user)) return res.status(429).json({ error: 'Slow down — too many voice requests' });
if (!voice.allow(req.ip, !!req.user)) return res.status(429).json({ error: 'Slow down — too many voice requests' });
const text = voice.cleanText(req.query.t || '');
const text = voice.cleanText(req.query.t || '');
if (text.length < 2) return res.status(400).json({ error: 'Nothing to say' });
if (text.length < 2) return res.status(400).json({ error: 'Nothing to say' });
new file mode 100644
new file mode 100644
@@ -0,0 +1,136 @@
@@ -0,0 +1,136 @@
+/**
+ * voice.js — "hear it in their voice": Arena lines (taunts, quotes, headlines, ringside calls) read
+ * out in the streamer's own OpenVibe chat TTS voice — the cosmetic voice they equipped, else the
+ * per-identity auto voice chat already gives them (same `user:<username>` key OpenVibe.Chat uses).
+ *
+ * Every (voice, text) pair is synthesized ONCE and kept on disk (`data/tts-cache/<hash>.<ext>`),
+ * served with a week-long HTTP cache — repeat clicks cost nothing, and a changed voice simply
+ * hashes to a new file. Cloud engines are metered into ai_usage (kind 'tts'); a daily cap and a
+ * per-IP limit keep the endpoint from becoming a free TTS API.
+ */
+'use strict';
+
+const fs = require('fs');
+const path = require('path');
+const crypto = require('crypto');
+const db = require('../db/database');
+
+const CACHE_DIR = require('../paths').dir('TTS_CACHE_PATH', 'tts-cache');
+const MAX_TEXT = 240;
+const MAX_CACHE_MB = 300;
+const MAX_AGE_MS = 60 * 24 * 3600_000;
+const DAILY_MAX = async () => Number(await db.getSetting('arena_voice_daily_max') || 2000);
+const ANNOUNCER_VOICE = async () => String(await db.getSetting('arena_announcer_voice') || await db.getSetting('tts_default_voice') || 'gary');
+const CLOUD_COST_PER_CHAR = { google: 16 / 1_000_000, polly: 16 / 1_000_000 };
+
+let _tts = null;
+function tts() { if (!_tts) _tts = require('../chat/tts-engine'); return _tts; }
+let _synthOverride = null; // tests
+
+function ensureDir() { try { fs.mkdirSync(CACHE_DIR, { recursive: true }); } catch { /* */ } }
+function cleanText(text) { return String(text || '').replace(/\s+/g, ' ').replace(/[<>]/g, '').trim().slice(0, MAX_TEXT); }
+
+/** How this person sounds in chat: equipped cosmetic voice, else the derived auto voice. */
+async function voiceFor(user) {
+ if (!user) return { kind: 'announcer', voiceId: await ANNOUNCER_VOICE(), sig: `announcer:${await ANNOUNCER_VOICE()}` };
+ try {
+ const cosmetics = require('../monetization/cosmetics');
+ const prof = await cosmetics.getCosmeticProfile(user.id);
+ const itemId = prof && prof.voiceFX && prof.voiceFX.itemId;
+ if (itemId && tts().VOICE_CATALOG && tts().VOICE_CATALOG[itemId]) return { kind: 'equipped', voiceId: itemId, sig: `equipped:${itemId}` };
+ } catch { /* cosmetics optional */ }
+ const key = `user:${String(user.username).toLowerCase()}`;
+ let params = null;
+ try { params = tts().deriveUserVoiceParams ? tts().deriveUserVoiceParams(key) : null; } catch { /* */ }
+ return { kind: 'auto', identityKey: key, params, sig: `auto:${key}:${params ? [params.voice, params.pitch, params.speed, params.gap].join('/') : 'default'}` };
+}
+
+function cacheKey(sig, text) { return crypto.createHash('sha256').update(`${sig}\n${text}`).digest('hex').slice(0, 32); }
+function cachedFile(key) {
+ for (const ext of ['mp3', 'wav']) { const p = path.join(CACHE_DIR, `${key}.${ext}`); if (fs.existsSync(p)) return { path: p, mimeType: ext === 'mp3' ? 'audio/mpeg' : 'audio/wav' }; }
+ return null;
+}
+
+async function todayCount() { try { return (await db.get(`SELECT COUNT(*) AS n FROM ai_usage WHERE kind = 'tts' AND created_at >= to_char(ov_ts(ov_now()), 'YYYY-MM-DD')`))?.n || 0; } catch { return 0; } }
+
+async function synth(voice, text, username) {
+ if (_synthOverride) return _synthOverride(voice, text, username);
+ const T = tts();
+ if (voice.kind === 'auto' && T.synthesizeUserVoice) return T.synthesizeUserVoice(text, voice.identityKey, username, MAX_TEXT);
+ return T.synthesize(text, voice.voiceId, username || 'Arena', MAX_TEXT);
+}
+
+/**
+ * Speak `text` as `user` (a users row) or the announcer (null). Returns { path, mimeType, cached, voice }.
+ * Throws with a friendly message on limits.
+ */
+async function speak({ user = null, text }) {
+ ensureDir();
+ const clean = cleanText(text);
+ if (clean.length < 2) throw new Error('Nothing to say');
+ const voice = await voiceFor(user);
+ const key = cacheKey(voice.sig, clean);
+ const hit = cachedFile(key);
+ if (hit) { try { fs.utimesSync(hit.path, new Date(), new Date()); } catch { /* */ } return { ...hit, cached: true, voice: voice.sig }; }
+ if (await todayCount() >= await DAILY_MAX()) throw new Error('The announcer is hoarse — daily voice budget reached, try tomorrow');
+ const started = Date.now();
+ const out = await synth(voice, clean, user?.username);
+ if (!out || !out.audio) throw new Error('Voice engine unavailable');
+ const ext = /wav/i.test(out.mimeType || '') ? 'wav' : 'mp3';
+ const file = path.join(CACHE_DIR, `${key}.${ext}`);
+ const tmp = `${file}.tmp`;
+ fs.writeFileSync(tmp, Buffer.from(out.audio, 'base64'));
+ fs.renameSync(tmp, file);
+ const engine = String(out.engine || '').toLowerCase();
+ const cloud = /google/.test(engine) ? 'google' : /polly|aws|amazon/.test(engine) ? 'polly' : null;
+ try { await db.recordAiUsage({ kind: 'tts', model: `${engine || 'espeak'}:${out.voiceId || voice.voiceId || 'auto'}`, input_tokens: clean.length, output_tokens: 0, cost_usd: cloud ? clean.length * CLOUD_COST_PER_CHAR[cloud] : 0, owner_user_id: user?.id || null, source: 'arena', role: 'tts', provider: engine || 'espeak', latency_ms: Date.now() - started }); } catch { /* */ }
+ purge();
+ return { path: file, mimeType: ext === 'wav' ? 'audio/wav' : 'audio/mpeg', cached: false, voice: voice.sig, engine };
+}
+
+let _lastPurge = 0;
+function purge() {
+ if (Date.now() - _lastPurge < 10 * 60 * 1000) return;
+ _lastPurge = Date.now();
+ try {
+ const files = fs.readdirSync(CACHE_DIR).filter(f => /\.(mp3|wav)$/.test(f)).map(f => { const p = path.join(CACHE_DIR, f); const st = fs.statSync(p); return { p, size: st.size, at: st.mtimeMs }; });
+ const now = Date.now();
+ let total = 0;
+ for (const f of files) { if (now - f.at > MAX_AGE_MS) { try { fs.unlinkSync(f.p); } catch { /* */ } } else total += f.size; }
+ if (total > MAX_CACHE_MB * 1024 * 1024) {
+ for (const f of files.filter(x => now - x.at <= MAX_AGE_MS).sort((a, b) => a.at - b.at)) { if (total <= MAX_CACHE_MB * 1024 * 1024 * 0.8) break; try { fs.unlinkSync(f.p); total -= f.size; } catch { /* */ } }
+ }
+ } catch { /* */ }
+}
+
+// Per-IP limiter: anonymous 8/min, signed-in 30/min (cache hits are still counted so a scraper can't loop).
+const _hits = new Map();
+function allow(ip, signedIn) {
+ const now = Date.now();
+ const lim = signedIn ? 30 : 8;
+ const arr = (_hits.get(ip) || []).filter(t => now - t < 60_000);
+ if (arr.length >= lim) { _hits.set(ip, arr); return false; }
+ arr.push(now); _hits.set(ip, arr);
+ if (_hits.size > 5000) { for (const [k, v] of _hits) if (!v.length || now - v[v.length - 1] > 60_000) _hits.delete(k); }
+ return true;
+}
+
+/** Park a one-off synthesized clip (admin test / mod preview) in the cache → safe same-origin filename. */
+function stash(audioBase64, mimeType) {
+ ensureDir();
+ const buf = Buffer.from(String(audioBase64 || ''), 'base64');
+ if (!buf.length) return null;
+ const ext = /wav/i.test(mimeType || '') ? 'wav' : 'mp3';
+ const file = `${crypto.createHash('sha256').update(buf).digest('hex').slice(0, 32)}.${ext}`;
+ const full = path.join(CACHE_DIR, file);
+ if (!fs.existsSync(full)) { fs.writeFileSync(`${full}.tmp`, buf); fs.renameSync(`${full}.tmp`, full); purge(); }
+ return { file, mimeType: ext === 'wav' ? 'audio/wav' : 'audio/mpeg' };
+}
+/** Resolve a cache filename (strict shape, no traversal) → { path, mimeType } or null. */
+function cachedByName(file) {
+ if (!/^[a-f0-9]{32}\.(mp3|wav)$/.test(String(file || ''))) return null;
+ const full = path.join(CACHE_DIR, file);
+ return fs.existsSync(full) ? { path: full, mimeType: file.endsWith('.wav') ? 'audio/wav' : 'audio/mpeg' } : null;
+}
+
+module.exports = { speak, voiceFor, cacheKey, cleanText, allow, purge, stash, cachedByName, CACHE_DIR, MAX_TEXT, _setSynth: (fn) => { _synthOverride = fn; } };
@@ -96,7 +96,6 @@ async function check(name, fn) {
@@ -96,7 +96,6 @@ async function check(name, fn) {
const translate = require('../server/i18n/translate');
const translate = require('../server/i18n/translate');
const recap = require('../server/recap/recap');
const recap = require('../server/recap/recap');
const budget = require('../server/ai/viewers/budget');
const budget = require('../server/ai/viewers/budget');
- const arena = require('../server/arena/arena-service');
const media = require('../server/media-client');
const media = require('../server/media-client');
media.listVods = async () => ({ vods: [] });
media.listVods = async () => ({ vods: [] });
media.listClips = async () => ({ clips: [] });
media.listClips = async () => ({ clips: [] });
@@ -156,8 +155,6 @@ async function check(name, fn) {
@@ -156,8 +155,6 @@ async function check(name, fn) {
const r = await recap.buildRecap(sid);
const r = await recap.buildRecap(sid);
assert.ok(r && r.write && r.write.headline, 'the template report still ships');
assert.ok(r && r.write && r.write.headline, 'the template report still ships');
assert.strictEqual(r.ai, false);
assert.strictEqual(r.ai, false);
- assert.strictEqual(arena.imageGenAvailable(), false);
- assert.strictEqual(await arena.generateImage(40), null, 'no portrait is drawn');
if (label === 'AI_SERVICE=off') {
if (label === 'AI_SERVICE=off') {
// A streamer's key in OpenVibe.AI is not used either while the service is off.
// A streamer's key in OpenVibe.AI is not used either while the service is off.
assert.strictEqual(await llm.complete({ role: 'chat', user: 'hi', provider: { credentialSubject: SUBJECT } }), null);
assert.strictEqual(await llm.complete({ role: 'chat', user: 'hi', provider: { credentialSubject: SUBJECT } }), null);
deleted file mode 100644
deleted file mode 100644
@@ -1,170 +0,0 @@
@@ -1,170 +0,0 @@
-'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'), fan = await mk('fan');
-
- // 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 },
- ]);
- // fan: a match on stream; the crowd's screams and groans and "crying", and nothing heated said by the streamer.
- const fs2 = await stream(fan, { startMin: 20, peak: 1 });
- await db.addTimelineEvents([
- ...[100, 120, 140].map((t) => ({ stream_id: fs2, user_id: fan, vod_id: null, kind: 'sound', start_sec: t, end_sec: t + 1, text: null, label: 'Screaming', confidence: 0.8 })),
- ...[200, 210, 220].map((t) => ({ stream_id: fs2, user_id: fan, vod_id: null, kind: 'sound', start_sec: t, end_sec: t + 1, text: null, label: 'Crying, sobbing', confidence: 0.8 })),
- { stream_id: fs2, user_id: fan, vod_id: null, kind: 'speech', start_sec: 125, end_sec: 130, text: 'We are going to get the control of this game now!', label: null, confidence: 0.9 },
- { stream_id: fs2, user_id: fan, vod_id: null, kind: 'speech', start_sec: 205, end_sec: 210, text: 'Thank you, thank you, thank you all.', 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' });
-
- // Chat announcements: captured instead of sent.
- const delivery = require('../server/chat/chat-delivery');
- const sent = [];
- delivery.message = async (line) => { sent.push(line); return sent.length; };
- const first = await drama.detect({ now: NOW });
- await new Promise((r) => setTimeout(r, 100));
- 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 } });
- assert.ok(!kinds.includes(`meltdown:${fan}`), 'crowd noise with nothing heated said is no meltdown');
- assert.ok(!kinds.some((k) => k.startsWith('tears:')), 'no tears stories');
- 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]);
-
- // Stories at alert 3+ reach chat once each: the live subject's stream room (mirrored), else global chat.
- assert.ok(sent.length >= 2 && sent.every((l) => l.message_type === 'system' && l.username === 'ARENA ALERT' && /^arena:\d+$/.test(l.key)));
- const rqLine = sent.find((l) => l.key === `arena:${rq.id}`);
- assert.ok(rqLine && rqLine.is_global && !rqLine.stream_id && /\/arena\/drama\/\d+$/.test(rqLine.message), 'an ended stream: global chat');
- const mdLine = sent.find((l) => l.key === `arena:${md.id}`);
- assert.deepStrictEqual([mdLine.stream_id, mdLine.mirror], [ss, true], 'a live subject: their stream room, mirrored');
- assert.ok(!sent.some((l) => l.message.includes('GHOST')), 'alert 1-2 stories stay off chat');
-
- // 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']);
-
- // "Ducked": only someone who was live while on the clock (crasher is live; ghost has not streamed since).
- assert.strictEqual(await drama.beefStory('forfeit', { id: 993, opened_at: ago(10) }, { loserId: ghost, winnerId: crasher }), null, 'never live on the clock: no story');
- assert.ok(await drama.beefStory('forfeit', { id: 994, opened_at: ago(10) }, { loserId: crasher, winnerId: 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 }));
-
- // The AI rewrite: taken when it names everyone exactly (case aside), dropped when it misspells a name.
- const arenaSvc = require('../server/arena/arena-service');
- const aiSvc = require('../server/ai/ai-service');
- arenaSvc.aiOn = async () => true;
- let answer = { headline: '🚨 GHOST STREAMS 90 MINUTES TO NOBODY', blurb: 'Peak 0. Chat 0. Brutal.', alert: 2 };
- aiSvc.structured = async (workflow, input) => { assert.strictEqual(workflow, 'live.arena.drama'); assert.strictEqual(input.streamer, 'ghost'); assert.match(input.what, /^Peak: 0 viewers\. Chat messages: 0\.$/); return answer; };
- const g1 = await drama.createStory({ kind: 'ghost_town', userId: ghost, dedupeKey: 'ai:1', facts: { minutes: 90, peak: 0, messages: 0 }, happenedAt: NOW });
- await new Promise((r) => setTimeout(r, 50));
- assert.deepStrictEqual(await db.get('SELECT headline, ai FROM arena_drama WHERE id = ?', [g1]), { headline: '🚨 GHOST STREAMS 90 MINUTES TO NOBODY', ai: 1 });
- answer = { headline: '🚨 GOSTY STREAMS TO NOBODY', blurb: 'x', alert: 2 };
- const g2 = await drama.createStory({ kind: 'ghost_town', userId: ghost, dedupeKey: 'ai:2', facts: { minutes: 90, peak: 0, messages: 0 }, happenedAt: NOW });
- await new Promise((r) => setTimeout(r, 50));
- assert.deepStrictEqual(await db.get('SELECT headline, ai FROM arena_drama WHERE id = ?', [g2]), { headline: '👻 ghost STREAMED 90 MINUTES TO AN EMPTY ROOM', ai: 0 }, 'a misspelled name keeps the template');
-
- // An accusation the template does not make keeps the template (a beef of game voice lines became "DECLARES DEATH
- // THREATS", 2026-10-18); one the template already makes may stay.
- answer = { headline: '🚨 ghost THREATENS AN EMPTY ROOM', blurb: 'Peak 0.', alert: 3 };
- const g3 = await drama.createStory({ kind: 'ghost_town', userId: ghost, dedupeKey: 'ai:3', facts: { minutes: 90, peak: 0, messages: 0 }, happenedAt: NOW });
- await new Promise((r) => setTimeout(r, 50));
- assert.strictEqual((await db.get('SELECT ai FROM arena_drama WHERE id = ?', [g3])).ai, 0, 'an added accusation keeps the template');
- assert.strictEqual(drama.addsAccusation('🚨 A DECLARES DEATH THREATS', '🥩 BEEF: A vs B. A came for B on mic: I kill for this crown'), true);
- assert.strictEqual(drama.addsAccusation('🚨 A KILLS IT AGAIN', 'A came for B on mic: I kill for this crown'), true, 'another form of the word is another word');
- assert.strictEqual(drama.addsAccusation('🚨 RAGE QUIT after 6 minutes', 'rager RAGE QUIT? Stream over after 6 minutes'), false);
-
- // The meltdown quote: the heated line near the shouting beats a longer mundane one.
- const { pickQuote } = drama._test;
- assert.strictEqual(pickQuote([
- { text: 'so the charge on each capacitor of the panel decides what you see on the screen today', start_sec: 95 },
- { text: 'bro why does this keep happening to me', start_sec: 128 },
- { text: 'ok', start_sec: 130 },
- ], 100, 160), 'bro why does this keep happening to me');
- assert.strictEqual(pickQuote([{ text: 'ok', start_sec: 1 }], 0, 10), null, 'too short to quote');
-
- console.log('arena-drama: all checks passed');
- process.exit(0);
-})().catch((e) => { console.error(e); process.exit(1); });
deleted file mode 100644
deleted file mode 100644
@@ -1,77 +0,0 @@
@@ -1,77 +0,0 @@
-'use strict';
-// The Arena judges are OpenVibe.AI templates (roadmap WS-O task 2): against a stub Network (token endpoint) and a
-// stub AI, judgeMic and judgeBeef send live.arena.judge_mic / judge_beef with what Live heard (never a prompt), attributed
-// to the speaker; the answer goes through Live's house rules (quotable line, clamp); no answer falls back to the
-// heuristic.
-// node test/arena-judge-ai.test.js
-const assert = require('assert');
-const http = require('http');
-const crypto = require('crypto');
-const { serviceAuth } = require('openvibe-contracts');
-
-process.env.NODE_ENV = 'test';
-process.env.OV_OAUTH_CLIENT_ID = 'live';
-process.env.OV_OAUTH_CLIENT_SECRET = 'live-secret';
-const quiet = console.log;
-console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };
-console.warn = () => {};
-
-const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
-const runs = [];
-let answer = null;
-const network = http.createServer((req, res) => {
- let raw = ''; req.on('data', (c) => { raw += c; });
- req.on('end', () => {
- const f = new URLSearchParams(raw); const now = Math.floor(Date.now() / 1000);
- res.setHeader('Content-Type', 'application/json');
- res.end(JSON.stringify({ access_token: serviceAuth.signServiceToken({ iss: 'https://openvibe.network', sub: 'svc:live', actor_type: 'service', aud: [f.get('audience')], cap: ['ai.run.create'], iat: now, exp: now + 300, jti: `tok_${crypto.randomBytes(6).toString('hex')}` }, keys.privateKey), token_type: 'Bearer', expires_in: 300 }));
- });
-});
-const ai = http.createServer((req, res) => {
- let raw = ''; req.on('data', (c) => { raw += c; });
- req.on('end', () => {
- const body = raw ? JSON.parse(raw) : null;
- runs.push(body);
- res.setHeader('Content-Type', 'application/json');
- if (!answer) { res.statusCode = 502; return res.end(JSON.stringify({ code: 'provider.unavailable' })); }
- res.statusCode = 201;
- res.end(JSON.stringify({ run: { id: 'run_01JAB2C3D4E5F6G7H8J9K0MNPA', status: 'succeeded', synthetic: false, workflow: { key: body.workflow }, output: answer, usage: { tokens_in: 10, tokens_out: 5, cost_usd: 0.0001 }, provenance: { model: 'm' } } }));
- });
-});
-
-(async () => {
- await new Promise((r) => network.listen(0, '127.0.0.1', r));
- await new Promise((r) => ai.listen(0, '127.0.0.1', r));
- process.env.OV_NETWORK_INTERNAL_URL = `http://127.0.0.1:${network.address().port}`;
- process.env.OV_AI_INTERNAL_URL = `http://127.0.0.1:${ai.address().port}`;
- const db = require('../server/db/database');
- await db.initDb();
- await db.setSetting('ai_enabled', 'true');
- const listener = require('../server/arena/listener');
- try {
- answer = { is_trash_talk: true, garbled: false, quality: 12, best_line: 'chat you are the worst mods i have ever seen', about: 'the mods', aimed_at: 'The Mods', announcer: 'OH!', flagged: false };
- const mic = await listener._judgeMic(7, 'chat you are the worst mods i have ever seen');
- assert.deepStrictEqual([runs[0].workflow, runs[0].input, runs[0].attribution], ['live.arena.judge_mic', { speech: 'chat you are the worst mods i have ever seen' }, { service: 'live', type: 'user', id: '7' }]);
- assert.ok(!JSON.stringify(runs[0]).includes('You judge'), 'no prompt is sent');
- assert.deepStrictEqual([mic.is_trash_talk, mic.quality, mic.best_line, mic.aimed_at, mic.fallback], [true, 10, 'Chat you are the worst mods i have ever seen.', 'the mods', false]);
- answer = { ...answer, best_line: 'too short' };
- assert.strictEqual((await listener._judgeMic(7, 'too short a line')).is_trash_talk, false, 'the quotable-line rule still applies');
- answer = null;
- const fallback = await listener._judgeMic(7, 'you are all clowns and nobody in chat can beat me');
- assert.strictEqual(fallback.fallback, true, 'no answer: the heuristic');
-
- await db.getDb().prepare("INSERT INTO users (id, username, display_name, password_hash) OVERRIDING SYSTEM VALUE VALUES (8, 'ann', 'Ann', '$sso$')").run();
- const roster = { byId: { 8: { user: { username: 'ann', display_name: 'Ann' } } } };
- answer = { about_target: true, aimed_at_target: true, quality: 7, best_line: 'ann your stream is so boring even your bots left', about: 'her stream', announcer: 'Ouch', flagged: false };
- const beef = await listener._judgeBeef(7, 8, 'ann your stream is so boring even your bots left', roster, { context: 'called her washed', named: true, how: 'exact' });
- const b = runs[runs.length - 1];
- assert.strictEqual(b.workflow, 'live.arena.judge_beef');
- assert.deepStrictEqual(Object.keys(b.input), ['target_names', 'target_as_transcribed', 'target_named_in_new_speech', 'how_the_name_was_matched', 'what_speaker_already_said_about_target', 'new_speech']);
- assert.deepStrictEqual([b.input.target_names, b.input.what_speaker_already_said_about_target], [['ann', 'Ann'], 'called her washed']);
- assert.deepStrictEqual([beef.aimed_at_target, beef.quality, beef.fallback], [true, 7, false]);
- } finally {
- network.close(); ai.close();
- }
- console.log('arena judge via AI: all checks passed');
- process.exit(0);
-})().catch((e) => { console.error(e); process.exit(1); });
deleted file mode 100644
deleted file mode 100644
@@ -1,258 +0,0 @@
@@ -1,258 +0,0 @@
-'use strict';
-
-// Arena v3 (Battle Cam) — pure mic. The roster is whoever has been heard, ratings come from the
-// transcripts + the mic ledger, beefs open only when a fighter's whole name is said (never from the
-// mic judge's guess at "aimed_at", a nickname or a sound-alike),
-// free-standing shit talk lands in the feed and pays Trash Level XP, and chat can only hype.
-// Runs end-to-end on a temp DB with no AI configured (heuristic judges, 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-arena3-'));
-process.env.ARENA_IMAGE_PATH = path.join(tmp, 'arena');
-
-const db = require('../server/db/database');
-(async () => {
-await db.initDb();
-const arena = require('../server/arena/arena-service');
-const mic = require('../server/arena/mic');
-const beef = require('../server/arena/beef');
-const listener = require('../server/arena/listener');
-const chat = require('../server/arena/arena-chat');
-
-const mk = async (username) => { await db.createUser({ username, email: `${username}@x`, password_hash: 'x', display_name: username[0].toUpperCase() + username.slice(1), stream_key: username.padEnd(32, '0') }); return (await db.get('SELECT id FROM users WHERE username = ?', [username])).id; };
-const u1 = await mk('nova'), u2 = await mk('grizzly_bear'), u3 = await mk('pixelqueen'), quiet = await mk('quietguy'), viewer = await mk('viewer');
-for (const uid of [u1, u2, u3, quiet, viewer]) await db.ensureChannel(uid);
-// Streams: everyone streamed, but the roster only cares who was HEARD.
-const stream = async (uid, hours, daysAgo, peak = 500) => {
- const id = Number((await db.createStream({ user_id: uid, title: `${uid} stream`, category: 'irl', protocol: 'rtmp' })).lastInsertRowid);
- await db.run(`UPDATE streams SET is_live = 0, started_at = datetime('now', ?), ended_at = datetime('now', ?), duration_seconds = ?, peak_viewers = ? WHERE id = ?`, [`-${daysAgo} days`, `-${daysAgo} days`, Math.round(hours * 3600), peak, id]);
- return id;
-};
-const s1 = await stream(u1, 2, 2), s2 = await stream(u2, 2, 1), s3 = await stream(u3, 1, 3);
-await stream(quiet, 40, 1, 9000); // huge audience, never says a word → NOT on the roster
-const speak = async (streamId, uid, n, mk) => await db.addTimelineEvents(Array.from({ length: n }, (_, i) => ({ stream_id: streamId, user_id: uid, vod_id: null, kind: 'speech', start_sec: i * 30, end_sec: i * 30 + 8, text: mk(i), label: null, confidence: 0.9 })));
-await speak(s1, u1, 60, i => `Chat listen, this is line ${i}, we are cooking today, nobody can touch this, bet!`);
-await speak(s2, u2, 30, i => `Okay so line ${i}, let me check the map and farm this boss.`);
-await speak(s3, u3, 10, i => `Line ${i} hello everyone welcome in.`);
-
-const roster = await arena.loadRoster(true);
-assert.deepStrictEqual([...roster.order].sort(), [u1, u2, u3].sort(), 'roster = everyone with speech; the silent 9000-viewer streamer is not a fighter');
-assert.ok(!roster.byId[quiet], 'audience size buys nothing');
-assert.deepStrictEqual(arena.STAT_KEYS, ['heat', 'aim', 'kills', 'mouth', 'clapback', 'stamina', 'pace']);
-assert.ok(Math.abs(Object.values(arena.STAT_WEIGHTS).reduce((a, b) => a + b, 0) - 1) < 1e-9, 'stat weights sum to 1');
-const f1 = roster.byId[u1];
-assert.ok(f1.raw.mic && f1.raw.mic.window_days === 30 && f1.raw.mic.moments === 0, 'mic stats present, nothing judged yet');
-assert.ok(!('peak_viewers' in f1.raw) && !('followers' in f1.raw) && !('messages_per_hour' in f1.raw), 'no audience numbers anywhere in the raw stats');
-assert.strictEqual(f1.ratings.stamina, 99, 'the most speech = top STAMINA');
-assert.strictEqual(roster.byId[u3].ratings.stamina, 40);
-assert.strictEqual(f1.ratings.heat, roster.byId[u3].ratings.heat, 'nobody judged yet → HEAT ties');
-console.log('✅ roster + ratings are pure mic');
-
-{
- // ── Mic ledger: moments pay XP, best line sticks, feed lists newest first ──
- const m1 = await mic.addMoment({ userId: u1, streamId: s1, vodId: 501, sec: 40, kind: 'trash', aimedAt: 'chat', text: 'Chat you are all clowns, every one of you, bet', about: 'chat is clowns', quality: 7 });
- assert.ok(m1 && m1.id, 'moment stored');
- assert.strictEqual(await mic.addMoment({ userId: u1, kind: 'trash', text: 'kys buddy', quality: 9 }), null, 'threats never land in the feed');
- const lv = await mic.levelView(u1);
- assert.strictEqual(lv.xp, Math.round(7 * mic.XP_MOMENT), `trash moment pays quality × ${mic.XP_MOMENT} XP: ${lv.xp}`);
- assert.strictEqual(lv.mic_moments, 1); assert.strictEqual(lv.best_line.text, 'Chat you are all clowns, every one of you, bet');
- await mic.addMoment({ userId: u1, streamId: s1, kind: 'trash', aimedAt: 'the mods', text: 'the mods are asleep again, useless', about: 'mods', quality: 5 });
- const feed = await mic.feed({ limit: 10 });
- assert.strictEqual(feed.length, 2); assert.strictEqual(feed[0].aimed_at, 'the mods'); assert.strictEqual(feed[1].vod_id, 501);
- assert.strictEqual(feed[0].fighter_name, 'Nova');
- const ms = await mic.micStats(u1);
- assert.strictEqual(ms.moments, 2); assert.strictEqual(ms.avg_quality, 6); assert.strictEqual(ms.bangers, 1);
- assert.strictEqual((await arena.loadRoster(true)).byId[u1].ratings.heat, 99, 'judged moments lift HEAT');
- console.log('✅ mic ledger: moments, XP, best line, feed, stats');
-
- // ── Beefs: a hit logs into the feed too; clocks flip; forfeit; KILLS ──
- const h1 = await beef.recordHit(u1, u2, { quality: 7, best_line: 'Grizzly streams to 12 people and 9 are his alts', about: 'alts', announcer: 'Nova swings first!', stream_id: s1, vod_id: 501, sec: 90 });
- assert.strictEqual(h1.opened, true); assert.strictEqual(h1.pending, true); assert.strictEqual(h1.beef.on_clock, 'b');
- assert.ok(!('bounty' in h1), 'bounties are gone');
- // One-sided, it is nobody's business yet: no public moment, no XP, no beef page, no open beef (2026-10-18).
- assert.ok(!(await mic.feed({ limit: 5 })).some((m) => m.beef_id === h1.beef.id), 'a pending callout is not in the feed');
- assert.strictEqual(await beef.get(h1.beef.id), null, 'nor on its own page');
- assert.strictEqual((await beef.openBeefsFor(u1)).length, 0);
- const h2 = await beef.recordHit(u2, u1, { quality: 9, best_line: 'Nova needed a bit to get views, I just exist', about: 'clout', stream_id: s2, vod_id: 502, sec: 40 });
- assert.strictEqual(h2.first_response, true); assert.strictEqual(h2.opened, true, 'the answer opens it'); assert.strictEqual(h2.beef.status, 'open'); assert.strictEqual(h2.beef.on_clock, 'a');
- const fm = (await mic.feed({ limit: 1 }))[0];
- assert.strictEqual(fm.kind, 'beef_hit'); assert.strictEqual(fm.target.user.id, u1); assert.strictEqual(fm.beef_id, h1.beef.id); assert.strictEqual(fm.sec, 40);
- assert.strictEqual((await mic.levelView(u2)).beef_hits, 1);
- assert.deepStrictEqual(await beef.hype(h1.beef.id, 'a', 'anon:x'), { added: true, hypers: 1, crowd: 1 });
- await assert.rejects(beef.hype(h1.beef.id, 'a', `user:${u1}`), /yourself/);
- await db.run(`UPDATE arena_beefs SET clock_until = datetime('now', '-1 minute') WHERE id = ?`, [h1.beef.id]);
- await beef.tick();
- const v = await beef.get(h1.beef.id);
- assert.strictEqual(v.resolution, 'forfeit'); assert.strictEqual(v.winner_user_id, u2, 'nova went silent on the clock → grizzly wins');
- assert.ok(!('bounty' in v), 'no bounty field on the view');
- assert.deepStrictEqual(await beef.recordFor(u2), { wins: 1, losses: 0, draws: 0 });
- const r2 = await arena.loadRoster(true);
- assert.strictEqual(r2.byId[u2].ratings.kills, 99, 'a win is a KILL');
- assert.strictEqual(r2.byId[u2].raw.mic.answered, 1, 'answering on the clock counts for CLAPBACK');
- assert.ok(r2.byId[u2].ratings.talk_bonus > 0, 'recent win → mouth bonus');
- console.log('✅ beefs feed the ledger; forfeit; KILLS + CLAPBACK from the mic ledger');
-
- // ── Heuristic judges (AI off) ──
- const hm = listener._heuristicMic('Chat, listen, all of you are clowns, absolute bums, nobody in this chat could beat me at anything, bet!');
- assert.strictEqual(hm.is_trash_talk, true); assert.ok(hm.quality >= 5, `quality ${hm.quality}`); assert.strictEqual(hm.aimed_at, 'chat');
- assert.strictEqual(listener._heuristicMic('okay so we need to farm this boss and then go to the shop, let me check the map').is_trash_talk, false);
- assert.strictEqual(listener._heuristicBeef('thanks grizzly for the raid, love you', ['grizzly']).aimed_at_target, false);
- console.log('✅ heuristic judges');
-
- // ── Listener over a live stream: free talk → feed; a name-drop → beef; a callout by nickname → beef ──
- const liveId = Number((await db.createStream({ user_id: u3, title: 'live', category: 'irl', protocol: 'rtmp' })).lastInsertRowid);
- await db.run(`UPDATE streams SET is_live = 1, started_at = datetime('now', '-600 seconds') WHERE id = ?`, [liveId]);
- const xpBefore = (await mic.levelView(u3)).xp;
- await db.addTimelineEvents([
- { stream_id: liveId, user_id: u3, vod_id: null, kind: 'speech', start_sec: 586, end_sec: 590, text: 'Chat, every single one of you is a clown, you bums could never do what I do, nobody in here has a job, bet!', label: null, confidence: 0.9 },
- { stream_id: liveId, user_id: u3, vod_id: null, kind: 'speech', start_sec: 591, end_sec: 596, text: 'Sit down, all of you, absolute losers, I am better than this whole chat combined and everyone knows it!', label: null, confidence: 0.9 },
- ]);
- let ev = await listener.tick();
- assert.ok(ev.some(e => e.kind === 'mic_hit' && e.speakerId === u3), `free shit talk lands in the feed: ${JSON.stringify(ev)}`);
- assert.ok((await mic.levelView(u3)).xp > xpBefore, 'and pays XP');
- assert.strictEqual((await mic.feed({ limit: 1 }))[0].user.id, u3);
- const cs = await listener.consoleState(u3);
- assert.ok(cs.listening && cs.last_mic_judgement && cs.last_mic_judgement.is_trash_talk && cs.focus === null, 'console shows the mic judgement, no lock');
- assert.strictEqual((await arena.liveFighters())[0].user.id, u3);
- const camThumb = (await arena.liveFighters())[0].thumbnail_url;
- assert.ok(camThumb === null || typeof camThumb === 'string', `a live cam's frame is a URL or null, never ${JSON.stringify(camThumb)} (an un-awaited lookup made it {}, and the page asked for /[object Object])`);
- assert.ok((await arena.liveFighters())[0].last_moment && (await arena.liveFighters())[0].last_moment.aimed_at === 'chat', 'live cam carries the last judged line');
- // Name-drop → focus lock → beef.
- await db.run(`UPDATE streams SET started_at = datetime('now', '-700 seconds') WHERE id = ?`, [liveId]);
- await db.addTimelineEvents([
- { stream_id: liveId, user_id: u3, vod_id: null, kind: 'speech', start_sec: 686, end_sec: 690, text: 'And grizzly bear? Grizzly bear is trash, washed, a fraud, he could never beat me, he is scared of the smoke, bet!', label: null, confidence: 0.9 },
- { stream_id: liveId, user_id: u3, vod_id: null, kind: 'speech', start_sec: 691, end_sec: 696, text: 'Nobody watches that stream, it is mid, it is garbage, I would cook him in a second!', label: null, confidence: 0.9 },
- ]);
- listener._state.get(liveId).lastJudgeAt = 0;
- ev = await listener.tick();
- assert.ok(ev.some(e => e.kind === 'beef_hit' && e.targetId === u2 && e.opened), `name-drop opened the beef: ${JSON.stringify(ev)}`);
- assert.ok((await listener.consoleState(u3)).focus && (await listener.consoleState(u3)).focus.target_id === u2, 'locked on grizzly');
- assert.strictEqual((await beef.openBeefsFor(u3)).length, 0, 'one-sided: pending, not open');
- assert.strictEqual(await db.get("SELECT status FROM arena_beefs WHERE a_user_id = ? AND b_user_id = ?", [u3, u2]).then((r) => r && r.status), 'pending');
- assert.strictEqual((await arena.liveFighters())[0].ears.focus.target_id, u2, 'live cam shows the lock');
- // Unnamed trash while locked is context, never a hit (a League stream's voice lines after one transcribed "Adam"
- // scored a 107–0 beef, 2026-10-18).
- await db.run(`UPDATE streams SET started_at = datetime('now', '-760 seconds') WHERE id = ?`, [liveId]);
- await db.addTimelineEvents([{ stream_id: liveId, user_id: u3, vod_id: null, kind: 'speech', start_sec: 746, end_sec: 752, text: 'He is trash, washed, a fraud, scared of the smoke, nobody watches that garbage stream, I would cook him, bet!', label: null, confidence: 0.9 }]);
- listener._state.get(liveId).lastJudgeAt = 0;
- ev = await listener.tick();
- assert.ok(!ev.some(e => e.kind === 'beef_hit'), `unnamed lines never score: ${JSON.stringify(ev)}`);
- // Drop the lock (two off-target chunks).
- for (let k = 0; k < 2; k++) {
- await db.run(`UPDATE streams SET started_at = datetime('now', ?) WHERE id = ?`, [`-${800 + k * 100} seconds`, liveId]);
- await db.addTimelineEvents([{ stream_id: liveId, user_id: u3, vod_id: null, kind: 'speech', start_sec: 786 + k * 100, end_sec: 792 + k * 100, text: 'Okay chat back to the game, we need to farm this boss and then do the quest line for the sword upgrade, let me check the map real quick.', label: null, confidence: 0.9 }]);
- listener._state.get(liveId).lastJudgeAt = 0;
- await listener.tick();
- }
- assert.strictEqual((await listener.consoleState(u3)).focus, null, 'moved on → lock dropped');
- // A piece of a name or a sound-alike names nobody: no lock, no beef (a League stream's "Minions" locked onto
- // mnss22741 and opened three beefs of game voice lines, 2026-10-18).
- await db.run(`UPDATE streams SET started_at = datetime('now', '-1100 seconds') WHERE id = ?`, [liveId]);
- await db.addTimelineEvents([{ stream_id: liveId, user_id: u3, vod_id: null, kind: 'speech', start_sec: 1086, end_sec: 1092, text: 'Grizzly? Grizzly is trash, washed, a fraud, scared of the smoke, I would cook that grizly bare in a second, bet!', label: null, confidence: 0.9 }]);
- listener._state.get(liveId).lastJudgeAt = 0;
- ev = await listener.tick();
- assert.ok(!ev.some(e => e.kind === 'focus' || e.kind === 'beef_hit'), `a nickname or a misspelling locks nothing: ${JSON.stringify(ev)}`);
- assert.strictEqual((await listener.consoleState(u3)).focus, null);
- assert.strictEqual(await db.get("SELECT COUNT(*) AS n FROM arena_beefs WHERE a_user_id = ? AND status = 'pending'", [u3]).then((r) => Number(r.n)), 1, 'still the one callout a whole name started');
- // Grizzly answers, naming her on his own stream: now it is a beef, and public.
- const answer = await beef.recordHit(u2, u3, { quality: 6, best_line: 'pixelqueen talks big for someone who streams to her cat', about: 'answer' });
- assert.strictEqual(answer.opened, true);
- assert.strictEqual((await beef.openBeefsFor(u3)).length, 1);
- // An unanswered callout ends quietly: no forfeit, no record, no story.
- const lone = await beef.recordHit(u1, u3, { quality: 8, best_line: 'pixelqueen could not win a race against a parked car', about: 'race' });
- await db.run("UPDATE arena_beefs SET clock_until = datetime('now', '-1 minute') WHERE id = ?", [lone.beef.id]);
- await beef.tick();
- assert.strictEqual((await db.get('SELECT status, winner_user_id FROM arena_beefs WHERE id = ?', [lone.beef.id])).status, 'expired');
- assert.deepStrictEqual(await beef.recordFor(u1), { wins: 0, losses: 1, draws: 0 }, 'the expired callout counts for nobody (the loss is the forfeit above)');
- assert.strictEqual(await listener._aimedAtUnnamed('grizzly bear', u3, await arena.loadRoster()), null, "the judge's guess at a fighter is dropped");
- assert.strictEqual(await listener._aimedAtUnnamed('chat', u3, await arena.loadRoster()), 'chat');
- const names = require('../server/arena/names');
- const fake = { order: [41, 55], byId: { 41: { user: { username: 'mnss22741', display_name: 'mnss22741' } }, 55: { user: { username: 'finditfixit', display_name: 'finditfixit' } } } };
- const entries = names.rosterEntries(fake, (id) => (id === 55 ? ['fix it'] : []));
- const whole = (t) => names.findMentions(t, entries, { wholeOnly: true }).map(m => m.userId).sort((a, b) => a - b);
- assert.deepStrictEqual(whole('Minions has been.'), [], 'a sound-alike is nobody');
- assert.deepStrictEqual(whole("This is lame. We have to fix it."), [], 'a nickname that is a phrase is nobody');
- assert.deepStrictEqual(whole('mnss 22741 is mid and finditfixit too'), [41, 55], 'whole names, however spaced');
- assert.deepStrictEqual(names.findMentions('Minions has been.', entries).map(m => m.how), ['phonetic'], 'the loose matcher still exists for other uses');
- console.log('✅ listener: free talk → feed + XP; name-drop → lock → beef; lets go when they move on');
-
- // ── Persona fallback + fighter card are mic-shaped ──
- const card = await arena.getFighter('nova');
- assert.ok(card.mic && card.mic.moments >= 2 && card.level.level >= 1 && Array.isArray(card.beefs));
- assert.ok(!('active_topic' in card), 'no topics');
- assert.ok(['Rushdown', 'Sniper', 'Assassin', 'Caster', 'Counter', 'Tank', 'Zoner'].includes(card.persona.class), `fallback class from the mic stats: ${card.persona.class}`);
- assert.deepStrictEqual(Object.keys(card.persona.stat_quips).sort(), [...arena.STAT_KEYS].sort());
- const detail = await arena.getStatDetail(u1, 'heat');
- assert.strictEqual(detail.unit, 'avg judge score'); assert.ok(detail.series.length >= 1);
- assert.strictEqual(await arena.getStatDetail(u1, 'hype'), null, 'audience stats do not exist');
- const lb = await mic.levelsLeaderboard(5);
- assert.ok(lb.length >= 2 && lb[0].xp >= lb[1].xp);
- console.log('✅ fighter card, stat detail, ladder');
-
- // ── Chat: hype only ──
- const sent = [], room = [];
- // track() is how a caller that answers once waits for the command's replies (Chat's arena-command call).
- let pending = [];
- const fakeChat = { sendTo: (ws, m) => sent.push(m.message), broadcastToStream: (sid, m) => room.push(m.message), track: (p) => pending.push(p) };
- const run = async (client, line) => { const parts = line.split(' '); const handled = await chat.handle(fakeChat, {}, client, parts[0], parts); await Promise.all(pending); pending = []; return handled; };
- assert.strictEqual(await run({ user: { id: viewer, username: 'viewer' }, streamId: liveId, ip: '1.1.1.1' }, '!topic anything'), false, '!topic no longer exists');
- assert.strictEqual(await run({ anonId: 'b1', streamId: liveId }, '!bounty nova'), false, '!bounty no longer exists');
- assert.strictEqual(await run({ anonId: 'b2', streamId: liveId }, '!board'), false, '!board no longer exists');
- // The Arena became The Rundown (2026-10-19): !hype and !beef point there, !arena answers with the streamer's Rundown.
- await run({ user: { id: viewer, username: 'viewer', display_name: 'Viewer' }, streamId: liveId, ip: '1.1.1.1' }, '!hype');
- assert.match(sent.pop(), /Beefs are gone.*\/rundown$/);
- await run({ anonId: 'zz', streamId: liveId }, '!beef');
- assert.match(sent.pop(), /Beefs are gone/);
- await run({ anonId: 'zy', streamId: liveId }, '!arena');
- assert.match(sent.pop(), /rundown/i);
- console.log('✅ chat: !hype / !beef point to The Rundown');
-
- // ── Backfill: past speech on ended streams gets judged into the feed (heuristic judges, no AI) ──
- const backfill = require('../server/arena/backfill');
- const old = Number((await db.createStream({ user_id: u2, title: 'yesterday', category: 'irl', protocol: 'rtmp' })).lastInsertRowid);
- await db.run(`UPDATE streams SET is_live = 0, started_at = datetime('now', '-1 day'), ended_at = datetime('now', '-23 hours'), duration_seconds = 3600 WHERE id = ?`, [old]);
- await db.addTimelineEvents([
- { stream_id: old, user_id: u2, vod_id: 777, kind: 'speech', start_sec: 100, end_sec: 106, text: 'Chat, all of you are clowns, absolute bums, nobody in this chat could beat me at anything, sit down losers, bet!', label: null, confidence: 0.9 },
- { stream_id: old, user_id: u2, vod_id: 777, kind: 'speech', start_sec: 107, end_sec: 112, text: 'Pathetic, every single one of you is washed and delusional, I am better than this entire chat!', label: null, confidence: 0.9 },
- { stream_id: old, user_id: u2, vod_id: 777, kind: 'speech', start_sec: 400, end_sec: 406, text: 'Okay so we go to the shop, buy the potions, then head to the cave and farm the boss for the sword, let me check the map for the route.', label: null, confidence: 0.9 },
- { stream_id: old, user_id: u2, vod_id: 777, kind: 'speech', start_sec: 407, end_sec: 412, text: 'Yeah the cave is north of the village past the bridge, should take about ten minutes if we do not get lost again.', label: null, confidence: 0.9 },
- ]);
- const feedBefore = (await mic.feed({ limit: 100 })).length;
- const bfr = await backfill.run({ force: true });
- assert.ok(bfr.moments >= 1, `backfill judged old speech into the feed: ${JSON.stringify(bfr)}`);
- assert.ok(bfr.skipped >= 1, 'gameplay chunk never cost a judge call');
- const fromOld = (await mic.feed({ limit: 100 })).filter(m => m.stream_id === old);
- assert.ok(fromOld.length >= 1 && fromOld[0].vod_id === 777 && fromOld[0].aimed_at === 'chat', `moment carries VOD + target: ${JSON.stringify(fromOld[0])}`);
- assert.ok(fromOld[0].at && Date.now() - Date.parse(fromOld[0].at.replace(' ', 'T') + 'Z') > 20 * 3600 * 1000, 'moment is dated when it was SAID, not when it was judged');
- assert.ok((await mic.feed({ limit: 100 })).length >= feedBefore + fromOld.length, 'other fighters\' spicy past lines were judged too');
- const again = await backfill.run({ force: true });
- assert.strictEqual(again.judged, 0, 'cursor: nothing re-judged on the second run');
- assert.deepStrictEqual(await backfill.run({ force: true }), await backfill.run({ force: true }));
- console.log('✅ backfill: past speech judged once, VOD-linked, dated when said');
-
- // ── Public API smoke ──
- const express = require('express');
- const app = express(); app.use(express.json()); app.use('/api/arena', require('../server/arena/routes'));
- const srv = await new Promise(r => { const s = app.listen(0, () => r(s)); });
- const get = async (p) => { const res = await fetch(`http://127.0.0.1:${srv.address().port}/api/arena${p}`); const txt = await res.text(); let body = null; try { body = JSON.parse(txt); } catch { body = null; } return { status: res.status, body }; };
- const fd = await get('/feed?limit=5'); assert.strictEqual(fd.status, 200); assert.ok(fd.body.feed.length >= 3 && fd.body.feed[0].text);
- assert.strictEqual((await get('/backfill')).status, 404, 'backfill is POST + admin only');
- const fi = await get('/fighters'); assert.deepStrictEqual(fi.body.stats, arena.STAT_KEYS); assert.ok(fi.body.fighters.every(f => f.mic && typeof f.mic.avg_quality === 'number'));
- const bf = await get('/beefs'); assert.strictEqual(bf.body.open.length, 1); assert.strictEqual(bf.body.resolved.length, 1);
- const con = await get('/console/pixelqueen'); assert.strictEqual(con.body.listener.listening, true); assert.ok(con.body.recent_moments.length >= 1 && con.body.mic);
- const lv2 = await get('/live'); assert.strictEqual(lv2.body.live[0].open_beefs, 1);
- const st = await get('/status'); assert.strictEqual(st.body.mode, 'battle-cam'); assert.ok(!('topics_open' in st.body));
- assert.strictEqual((await get('/board')).status, 404, 'the board is gone');
- assert.strictEqual((await get('/yappers')).status, 404, 'yappers are gone');
- assert.strictEqual((await get('/fighters/quietguy')).body.not_on_roster, true);
- srv.close();
- console.log('✅ public API');
-
- console.log('\n✅ All Arena (Battle Cam) tests passed');
- process.exit(0);
-}
-})().catch(err => { console.error(err); process.exit(1); });
deleted file mode 100644
deleted file mode 100644
@@ -1,69 +0,0 @@
@@ -1,69 +0,0 @@
-'use strict';
-// Arena voice: one synthesis per (voice, text), cached on disk; equipped cosmetic voice wins; limits.
-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-voice-'));
-process.env.TTS_CACHE_PATH = path.join(tmp, 'tts-cache');
-const db = require('../server/db/database');
-const voice = require('../server/arena/voice');
-let calls = [];
-voice._setSynth(async (v, text, username) => { calls.push({ sig: v.sig, text, username }); return { audio: Buffer.from(`AUDIO:${text}`).toString('base64'), mimeType: 'audio/mpeg', engine: 'espeak-ng', voiceId: 'auto:gary' }; });
-
-(async () => {
- await db.initDb();
- await db.createUser({ username: 'Goosely', email: 'g@x', password_hash: 'x', display_name: 'Goosely', stream_key: 'g'.repeat(32) });
- const user = await db.get('SELECT * FROM users WHERE username = ?', ['Goosely']);
- const v = await voice.voiceFor(user);
- assert.strictEqual(v.kind, 'auto'); assert.strictEqual(v.identityKey, 'user:goosely', 'same identity key chat uses');
- assert.strictEqual((await voice.voiceFor(null)).kind, 'announcer');
- const a = await voice.speak({ user, text: ' you think your chat is loud? ' });
- assert.strictEqual(a.cached, false); assert.strictEqual(a.mimeType, 'audio/mpeg'); assert.ok(fs.existsSync(a.path));
- assert.strictEqual(fs.readFileSync(a.path, 'utf8'), 'AUDIO:you think your chat is loud?', 'text is normalised before synthesis');
- const b = await voice.speak({ user, text: 'you think your chat is loud?' });
- assert.strictEqual(b.cached, true); assert.strictEqual(b.path, a.path); assert.strictEqual(calls.length, 1, 'second click never re-synthesizes');
- const c = await voice.speak({ user: null, text: 'you think your chat is loud?' });
- assert.notStrictEqual(c.path, a.path, 'same text in another voice is another file'); assert.strictEqual(calls.length, 2);
- assert.strictEqual((await db.get(`SELECT COUNT(*) AS n FROM ai_usage WHERE kind = 'tts'`)).n, 2, 'every synthesis is metered');
- await assert.rejects(voice.speak({ user, text: 'x' }), /Nothing to say/);
- await db.setSetting('arena_voice_daily_max', '2');
- await assert.rejects(voice.speak({ user, text: 'a brand new line' }), /hoarse|budget/, 'daily cap stops fresh synthesis');
- const d = await voice.speak({ user, text: 'you think your chat is loud?' });
- assert.strictEqual(d.cached, true, 'cache hits still work past the cap');
- console.log('✅ voice: cached per (voice, text), metered, capped');
-
- let ok = 0; for (let i = 0; i < 12; i++) if (voice.allow('1.2.3.4', false)) ok++;
- assert.strictEqual(ok, 8, 'anonymous: 8 per minute'); assert.ok(voice.allow('1.2.3.4', true) === false || true);
- let ok2 = 0; for (let i = 0; i < 40; i++) if (voice.allow('5.6.7.8', true)) ok2++;
- assert.strictEqual(ok2, 30, 'signed in: 30 per minute');
- console.log('✅ voice: per-IP limits');
-
- // HTTP: streams the file with a week of caching; announcer works without a user.
- const express = require('express'); const app = express(); app.use('/api/arena', require('../server/arena/routes'));
- const srv = await new Promise(r => { const s = app.listen(0, () => r(s)); });
- await db.setSetting('arena_voice_daily_max', '100');
- const res = await fetch(`http://127.0.0.1:${srv.address().port}/api/arena/voice/goosely?t=${encodeURIComponent('you think your chat is loud?')}`);
- assert.strictEqual(res.status, 200); assert.strictEqual(res.headers.get('content-type'), 'audio/mpeg'); assert.ok(/max-age=604800/.test(res.headers.get('cache-control'))); assert.strictEqual(res.headers.get('x-cache'), 'HIT');
- assert.strictEqual((await res.text()), 'AUDIO:you think your chat is loud?');
- const res2 = await fetch(`http://127.0.0.1:${srv.address().port}/api/arena/voice/announcer?t=${encodeURIComponent('GRIZZLY ANSWERS')}`);
- assert.strictEqual(res2.status, 200); assert.strictEqual(res2.headers.get('x-cache'), 'MISS');
- assert.strictEqual((await fetch(`http://127.0.0.1:${srv.address().port}/api/arena/voice/nobody?t=hi`)).status, 404);
- assert.strictEqual((await fetch(`http://127.0.0.1:${srv.address().port}/api/arena/voice/goosely?t=`)).status, 400);
- srv.close();
- console.log('✅ voice: HTTP streaming + caching headers');
-
- // One-off clips (admin Test Voice / mod preview) get a same-origin URL from the same cache —
- // no blob:/data: needed in the browser, and the filename is traversal-proof.
- const st = voice.stash(Buffer.from('RIFFfakewav').toString('base64'), 'audio/wav');
- assert.ok(/^[a-f0-9]{32}\.wav$/.test(st.file) && fs.existsSync(path.join(voice.CACHE_DIR, st.file)));
- assert.strictEqual(voice.stash(Buffer.from('RIFFfakewav').toString('base64'), 'audio/wav').file, st.file, 'same clip → same file');
- assert.strictEqual(voice.stash('', 'audio/wav'), null);
- assert.strictEqual(voice.cachedByName('../../etc/passwd'), null, 'no traversal');
- assert.strictEqual(voice.cachedByName('zz.wav'), null);
- assert.ok(voice.cachedByName(st.file).mimeType === 'audio/wav');
- // An arena voice line lands in the clip cache by filename (the /api/tts clip route is Chat's now).
- const line = await voice.speak({ user, text: 'you think your chat is loud?' });
- assert.ok(voice.cachedByName(path.basename(line.path)), 'a spoken line is served from the clip cache');
- console.log('✅ one-off clip stash + arena voice cache');
- console.log('\n✅ All Arena voice tests passed');
- process.exit(0);
-})().catch(err => { console.error(err); process.exit(1); });
deleted file mode 100644
deleted file mode 100644
@@ -1,156 +0,0 @@
@@ -1,156 +0,0 @@
-'use strict';
-
-// Arena roster regression tests (Battle Cam mode): ratings are fair percentiles over MIC-ONLY
-// metrics, the behaviour-only filter blocks threats/minors/doxxing but never vocabulary, quotes
-// come from real transcript lines with VOD links, and the roster/card/stat API works end-to-end on
-// a temp DB without any AI configured (personas fall back, no LLM calls). Beefs + the ledger +
-// the listener: arena-mic.test.js.
-
-const assert = require('assert');
-const fs = require('fs');
-const os = require('os');
-const path = require('path');
-
-const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-arena-'));
-process.env.ARENA_IMAGE_PATH = path.join(tmp, 'arena');
-
-const db = require('../server/db/database');
-(async () => {
-await db.initDb();
-const arena = require('../server/arena/arena-service');
-
-// ── Pure helpers ──
-const base = { voice: { talk_ratio_pct: 10, speech_minutes: 20, wpm: 100 }, mic: { avg_quality: 2, hits_per_mic_hour: 0.5, wins: 0, clapback_rate: 0 } };
-const ratings = arena._computeRatings({
- 1: { voice: { talk_ratio_pct: 60, speech_minutes: 300, wpm: 180 }, mic: { avg_quality: 8, hits_per_mic_hour: 6, wins: 5, clapback_rate: 1 } },
- 2: { ...base },
- 3: { voice: { talk_ratio_pct: 30, speech_minutes: 100, wpm: 140 }, mic: { avg_quality: 5, hits_per_mic_hour: 2, wins: 2, clapback_rate: 0.5 } },
-});
-assert.strictEqual(ratings[1].heat, 99, 'top of every metric is a 99');
-assert.strictEqual(ratings[2].heat, 40, 'bottom is the floor, never 0');
-assert.strictEqual(ratings[1].kills, 99, 'KILLS ranks beef wins');
-assert.strictEqual(ratings[2].mouth, 40);
-assert.ok(ratings[3].aim > 40 && ratings[3].aim < 99, 'middle sits in between');
-assert.ok(ratings[1].power > ratings[3].power && ratings[3].power > ratings[2].power, 'power orders the roster');
-assert.ok(Math.abs(Object.values(arena.STAT_WEIGHTS).reduce((a, b) => a + b, 0) - 1) < 1e-9, 'stat weights sum to 1');
-const solo = arena._computeRatings({ 7: { ...base } });
-assert.strictEqual(solo[7].heat, 70, 'a roster of one is a flat 70 — no population to compare to');
-console.log('✅ ratings are percentile-based with a floor (7 mic stats)');
-
-// ── Filter: behaviour, not vocabulary (the platform does not censor language) ──
-for (const bad of ['kys', 'kill yourself', 'go drink bleach', 'I will kill you', "i'm gonna stab him", 'his home address is 12 elm st', 'her phone number is 555 0199', 'child porn link', 'cp links in bio', 'doxxed him already'])
- assert.ok(arena._isBannedText(bad), `banned (behaviour): ${bad}`);
-for (const ok of ['that beaner coon nonsense', 'stop being a faggot', 'what up my niggas', "he's a retard", 'such a whore', 'pussy move', 'we got raped in that match', 'this shell script is hell', 'kill the lights', 'his address is wrong on the form? no, the URL address'])
- assert.ok(!arena._isBannedText(ok), `allowed (speech): ${ok}`);
-console.log('✅ filter blocks threats / minors / doxxing only — slurs and profanity are speech');
-
-// ── End-to-end on a temp DB (no AI configured → fallback personas/quotes, no LLM calls) ──
-const mk = async (username, key) => {
- await db.createUser({ username, email: `${username}@x`, password_hash: 'x', display_name: username.toUpperCase(), stream_key: key });
- return (await db.get('SELECT id FROM users WHERE username = ?', [username])).id;
-};
-const u1 = await mk('alpha', 'a'.repeat(32)), u2 = await mk('bravo', 'b'.repeat(32)), u3 = await mk('charlie', 'c'.repeat(32)), idle = await mk('idle', 'd'.repeat(32)), mute = await mk('mute', 'e'.repeat(32));
-for (const uid of [u1, u2, u3, idle, mute]) await db.ensureChannel(uid);
-const stream = async (uid, hours, daysAgo) => {
- const id = Number((await db.createStream({ user_id: uid, title: `${uid} stream`, category: 'irl', protocol: 'rtmp' })).lastInsertRowid);
- await db.run(`UPDATE streams SET is_live = 0, started_at = datetime('now', ?), ended_at = datetime('now', ?), duration_seconds = ?, peak_viewers = 999 WHERE id = ?`,
- [`-${daysAgo} days`, `-${daysAgo} days`, Math.round(hours * 3600), id]);
- return id;
-};
-const s1 = await stream(u1, 6, 2); await stream(u1, 4, 5);
-const s2 = await stream(u2, 10, 1);
-const s3 = await stream(u3, 1, 9);
-const sIdle = await stream(idle, 50, 120); // too old → not on the roster even with speech
-await stream(mute, 50, 1); // streams a lot, never transcribed → not on the roster
-
-// Transcript for alpha: 40 lines over the 6 h stream, some hype, all linked to a VOD.
-const lines = [];
-for (let i = 0; i < 40; i++) {
- const t = i % 4 === 0 ? `Let's go chat, that was insane, no way we just did that number ${i}!` : `Okay so here is the current situation with the setup number ${i}, we are rebuilding it live.`;
- lines.push({ stream_id: s1, user_id: u1, vod_id: 901, kind: 'speech', start_sec: i * 60, end_sec: i * 60 + 20, text: t, label: null, confidence: 0.9 });
-}
-await db.addTimelineEvents(lines);
-await db.addTimelineEvents([{ stream_id: s1, user_id: u1, vod_id: 901, kind: 'sound', start_sec: 30, end_sec: 33, text: null, label: 'Laughter', confidence: 0.8 }, { stream_id: s1, user_id: u1, vod_id: 901, kind: 'sound', start_sec: 90, end_sec: 93, text: null, label: 'Rock music', confidence: 0.8 }]);
-// bravo + charlie: a few lines each so they are on the roster; idle: lines, but 120 days old.
-await db.addTimelineEvents(Array.from({ length: 8 }, (_, i) => ({ stream_id: s2, user_id: u2, vod_id: null, kind: 'speech', start_sec: i * 100, end_sec: i * 100 + 5, text: `bravo line ${i} hello chat`, label: null, confidence: 0.9 })));
-await db.addTimelineEvents(Array.from({ length: 5 }, (_, i) => ({ stream_id: s3, user_id: u3, vod_id: null, kind: 'speech', start_sec: i * 100, end_sec: i * 100 + 5, text: `charlie line ${i}`, label: null, confidence: 0.9 })));
-await db.addTimelineEvents(Array.from({ length: 3 }, (_, i) => ({ stream_id: sIdle, user_id: idle, vod_id: null, kind: 'speech', start_sec: i * 100, end_sec: i * 100 + 5, text: `idle line ${i}`, label: null, confidence: 0.9 })));
-await db.run(`UPDATE stream_timeline_events SET created_at = datetime('now', '-120 days') WHERE user_id = ?`, [idle]);
-
-// Offensive lines are fair game for quotes; threats / doxxing never are.
-await db.addTimelineEvents([
- { stream_id: s1, user_id: u1, vod_id: 901, kind: 'speech', start_sec: 5000, end_sec: 5010, text: "Let's go chat, you absolute clowns, that was insane you retards!", label: null, confidence: 0.9 },
- { stream_id: s1, user_id: u1, vod_id: 901, kind: 'speech', start_sec: 5100, end_sec: 5110, text: 'No way, chat, that guy should kill himself, kys buddy, insane!', label: null, confidence: 0.9 },
- { stream_id: s1, user_id: u1, vod_id: 901, kind: 'speech', start_sec: 5200, end_sec: 5210, text: 'Holy, that was insane, his home address is 12 elm street lol!', label: null, confidence: 0.9 },
-]);
-const cands = await arena._quoteCandidates(u1);
-assert.ok(cands.length >= 20, 'has candidates');
-assert.ok(cands.some(c => c.text.includes('retards')), 'offensive vocabulary is not filtered out of quotes');
-assert.ok(cands.every(c => !/kys|home address/.test(c.text)), 'threats / doxxing never become quotes');
-console.log('✅ quote candidates: speech stays, behaviour goes');
-
-const voice = await arena._voiceStatsFor(u1, '-90 days');
-assert.strictEqual(voice.has_data, true);
-assert.strictEqual(voice.lines, 43);
-assert.ok(voice.talk_ratio_pct > 0 && voice.talk_ratio_pct <= 100, `talk ratio: ${voice.talk_ratio_pct}`);
-assert.ok(voice.hype_hits >= 10, `hype hits counted: ${voice.hype_hits}`);
-assert.strictEqual(voice.laughs, 1);
-assert.deepStrictEqual(voice.top_sounds.map(s => s.label).sort(), ['Laughter', 'Rock music']);
-assert.strictEqual((await arena._voiceStatsFor(mute, '-90 days')).has_data, false, 'no transcript → no data');
-console.log('✅ voice stats from the transcription timeline');
-
-const fighters = await arena.listFighters();
-assert.deepStrictEqual(fighters.map(f => f.user.username).sort(), ['alpha', 'bravo', 'charlie'], `roster = the heard, not the loud-in-numbers: ${fighters.map(f => f.user.username)}`);
-assert.strictEqual(fighters[0].user.username, 'alpha', 'the biggest mouth tops the ladder');
-assert.strictEqual(fighters[0].rank, 1);
-assert.strictEqual(fighters[0].ratings.stamina, 99, 'the most speech tops STAMINA');
-assert.ok(fighters[0].persona.fighter_name, 'fallback persona has a name even with AI off');
-assert.strictEqual(fighters[0].persona_is_fallback, true);
-assert.strictEqual(fighters[0].voice.has_data, true);
-assert.deepStrictEqual(fighters[0].record, { wins: 0, losses: 0, draws: 0 }, 'beef record starts empty');
-assert.deepStrictEqual(fighters[0].level, { level: 1, xp: 0 }, 'Trash Level starts at 1');
-assert.strictEqual(fighters[0].ratings.talk_bonus, 0, 'no mouth bonus without XP');
-assert.ok(fighters[0].mic && fighters[0].mic.moments === 0, 'mic stats ride along');
-assert.ok(!('tier' in fighters[0]), 'tiers are gone');
-console.log('✅ roster from transcripts only; idle and never-transcribed streamers excluded');
-
-{
- const card = await arena.getFighter('alpha');
- assert.strictEqual(card.user.username, 'alpha');
- assert.strictEqual(card.rank, 1);
- assert.ok(card.raw.hours >= 9.9 && card.raw.hours <= 10.1, `hours aggregated: ${card.raw.hours}`);
- assert.ok(!('followers' in card.raw) && !('peak_viewers' in card.raw), 'no audience numbers on the card');
- assert.strictEqual(card.image_url, null);
- assert.strictEqual(card.image_generation, 'off', 'no AI → no image generation');
- assert.strictEqual(card.quotes, null, 'nothing is generated on view any more — no quotes, no persona, no portrait');
- assert.ok(card.persona_is_fallback, 'persona is the stats-only fallback (never generated)');
- assert.deepStrictEqual(card.beefs, []);
- assert.strictEqual(card.level.level, 1);
- const off = await arena.getFighter('mute');
- assert.strictEqual(off.not_on_roster, true);
- assert.ok(/mic/.test(off.reason));
- console.log('✅ fighter card (mic-only, nothing generated on view)');
-
- const detail = await arena.getStatDetail(u1, 'mouth');
- assert.strictEqual(detail.position, 1);
- assert.strictEqual(detail.series.length, 2, 'one point per stream in the window');
- assert.ok(detail.series.some(p => p.value > 0), 'mouth series = % of stream talking');
- assert.strictEqual(detail.top[0].user.username, 'alpha');
- assert.ok(detail.voice, 'voice detail rides along for voice stats');
- assert.strictEqual(await arena.getStatDetail(u1, 'hype'), null, 'audience stats do not exist');
- assert.strictEqual(await arena.getStatDetail(u1, 'nope'), null);
- console.log('✅ stat drill-down');
-
- assert.deepStrictEqual(await arena.liveFighters(), [], 'nobody live');
- const st = await arena.status();
- assert.strictEqual(st.mode, 'battle-cam');
- assert.strictEqual(st.roster, 3);
- assert.strictEqual(st.with_voice_data, 3);
- assert.strictEqual(st.beefs_open, 0);
- assert.strictEqual(st.listener, 15000, 'listener tick advertised');
- console.log('✅ live fighters + status');
-
- console.log('\n✅ All Arena roster tests passed');
- process.exit(0);
-}
-})().catch(err => { console.error(err); process.exit(1); });
@@ -1,32 +1,32 @@
@@ -1,32 +1,32 @@
{
{
"service": "live",
"service": "live",
- "release": "81dc337dc7ae261ccc498f5894241f1f0e9cc52f",
- "recorded_at": "2026-10-08T18:28:45.009Z",
+ "release": "f0fed5be6d1b25cfcabb1a3f3e2db347dfea88fb",
+ "recorded_at": "2026-10-19T23:30:31.918Z",
"note": "N-1 fixture: written by `npm run n-1:record`, replayed by test/n-1.test.js",
"note": "N-1 fixture: written by `npm run n-1:record`, replayed by test/n-1.test.js",
"manifest": {
"manifest": {
"service": "live",
"service": "live",
- "release": "81dc337dc7ae",
- "released_at": "2026-10-08T17:34:39.000Z",
- "booted_at": "2026-10-08T18:28:40.404Z",
- "contracts_version": "0.112.0",
+ "release": "f0fed5be6d1b",
+ "released_at": "2026-10-19T18:39:28.000Z",
+ "booted_at": "2026-10-19T23:30:27.637Z",
+ "contracts_version": "0.203.0",
"packages": {
"packages": {
- "openvibe-shared": "2.13.3",
- "openvibe-sdk": "0.35.1"
+ "openvibe-shared": "4.4.0",
+ "openvibe-sdk": "0.50.1"
},
},
"min_client_release": null,
"min_client_release": null,
"mixed_version_window_hours": 24,
"mixed_version_window_hours": 24,
"components": {
"components": {
"styles": {
"styles": {
"kind": "style",
"kind": "style",
- "version": "835d7a90863a"
+ "version": "77a2bdaee060"
},
},
"shell": {
"shell": {
"kind": "script",
"kind": "script",
- "version": "2fb9f8dc218c"
+ "version": "4ff89f6a092a"
},
},
"server": {
"server": {
"kind": "server",
"kind": "server",
- "version": "6175874d3913"
+ "version": "73e6552a2862"
}
}
},
},
"assets": {
"assets": {
@@ -41,39 +41,34 @@
@@ -41,39 +41,34 @@
"integrity": "sha384-+UNgBYuMRLHmTl0hBPmZI+91u+ud0yHLElwmd/4UrYgHoLHIYVElOwsOQ4G8UdWB"
"integrity": "sha384-+UNgBYuMRLHmTl0hBPmZI+91u+ud0yHLElwmd/4UrYgHoLHIYVElOwsOQ4G8UdWB"
},
},
"/css/context-cards.css": {
"/css/context-cards.css": {
- "url": "/css/context-cards.css?v=f2468014839c",
+ "url": "/css/context-cards.css?v=07823a1b5eb5",
"component": "styles",
"component": "styles",
- "integrity": "sha384-1onIAYDdYHytODnOQ1NtrwgWw4IEBgCclvW5Fy+Rvvfc4Pq+63uuCdAdNnU9SgKu"
+ "integrity": "sha384-Di4RzkxhOu9xj1RMyoQdPFeAbJQHuaU6pg8/bxcxaJI4YG9T9hX9lytrqcrulDN/"
},
},
"/css/cosmetics.css": {
"/css/cosmetics.css": {
- "url": "/css/cosmetics.css?v=73e2adf188e1",
+ "url": "/css/cosmetics.css?v=5d33ae6cb97b",
"component": "styles",
"component": "styles",
- "integrity": "sha384-JY2WKT34BDXzoB746LvRQn1fo28LK2El8c6SFRFaS3K9Pe+PAUXFz0zvkw7qdie9"
- },
- "/css/features/arena.css": {
- "url": "/css/features/arena.css?v=6993aaa22df5",
- "component": "styles",
- "integrity": "sha384-EVAZL6dcfj1jLiFB5BWHcV+Iv0dTz8V5oDaJDoQROVMAV2EGMKS+tfK7Sh+rQDq/"
+ "integrity": "sha384-MaYWbMCigFzwPGNlDtRg1XnF0Ak6FFOa5hqfe1wmfuRSUtqukbcqDOelKvO1RqH2"
},
},
"/css/features/broadcast.css": {
"/css/features/broadcast.css": {
- "url": "/css/features/broadcast.css?v=84d5e7bcf87a",
+ "url": "/css/features/broadcast.css?v=c27820784531",
"component": "styles",
"component": "styles",
- "integrity": "sha384-bBBAa4R6vi7XZrg0pVl3y/RTieFRKBzfqN7N8S2f24xJp0kxufeX13Mlm/3EW9uE"
+ "integrity": "sha384-RHi7esMaA13XfUINvU/2iDobTP+ntLrei0C0C/u03zXUAN/0fKVM3Lkg0BZXnUY4"
},
},
"/css/features/channel.css": {
"/css/features/channel.css": {
- "url": "/css/features/channel.css?v=63269f14afd5",
+ "url": "/css/features/channel.css?v=f41f8d4b31d8",
"component": "styles",
"component": "styles",
- "integrity": "sha384-jS8cgLWimI0gjaN/MtfZywtJ1U05pcGFtitrSjyRaPVxJgrWFT4s50I3KLb7R+04"
+ "integrity": "sha384-Lu0yUHFrYfSOV0Eu2ChVrPC5NGCWHqKojTlpZdW22N1NHqeoj145OSHfZkgEIJNx"
},
},
"/css/features/chatpage.css": {
"/css/features/chatpage.css": {
- "url": "/css/features/chatpage.css?v=c5c63e94503f",
+ "url": "/css/features/chatpage.css?v=1b2ebcebcbca",
"component": "styles",
"component": "styles",
- "integrity": "sha384-2CnY63wmfX6sJ3AgpNab5FRmu8HOKt2HZs3T2SLP+ghsZzmKLbtL0vsu6HgegwSQ"
+ "integrity": "sha384-TdtKvLlB6yr2QMBBD5EAYebE40Y3xXvOkr9Min86pR/jqRNTfQVXxuqqois5soM+"
},
},
"/css/features/content.css": {
"/css/features/content.css": {
- "url": "/css/features/content.css?v=95d237faf73a",
+ "url": "/css/features/content.css?v=ec113a44dd7a",
"component": "styles",
"component": "styles",
- "integrity": "sha384-iAF+LsoCNaMS6BM2YUZu95mDl8+jgtko6D++4d70Hs0d8xH8UceNDPINIfISe3uT"
+ "integrity": "sha384-mm5kmkmu8IXqBCHEke9GTvb1FO1Oncn77FhTR124WDryVYQSw3d3JDKr5UVlgqE6"
},
},
"/css/features/dashboard.css": {
"/css/features/dashboard.css": {
"url": "/css/features/dashboard.css?v=90998f175a26",
"url": "/css/features/dashboard.css?v=90998f175a26",
@@ -91,29 +86,29 @@
@@ -91,29 +86,29 @@
"integrity": "sha384-qxjHx5W7yoUfDcK9M8DcVBKQX2YcR+sHHeGwpKNYrg6zM9tjKFsrcXfB4FvHcuUC"
"integrity": "sha384-qxjHx5W7yoUfDcK9M8DcVBKQX2YcR+sHHeGwpKNYrg6zM9tjKFsrcXfB4FvHcuUC"
},
},
"/css/features/home.css": {
"/css/features/home.css": {
- "url": "/css/features/home.css?v=1da8ab368854",
+ "url": "/css/features/home.css?v=faa8fa066557",
"component": "styles",
"component": "styles",
- "integrity": "sha384-BcVmXyr+pzzoReBggf3M/VzsHepfNVJG9ImV8+IbOx7EcPPNOixHFwzk2mRZ2e0/"
+ "integrity": "sha384-jc4nJLKlKXq0lKUUQPKdiR0pxiqd4E57o6O/BRMQLVGPPrWDYaG4OIxnn5uayOmi"
},
},
- "/css/features/media.css": {
- "url": "/css/features/media.css?v=dea567ff26e8",
+ "/css/features/rundown.css": {
+ "url": "/css/features/rundown.css?v=05f1d8c7b95c",
"component": "styles",
"component": "styles",
- "integrity": "sha384-5fg7EMKpaBjMyM4mYM/iM+HPpZnUUaCd1FmVEcauKoN9QWfbFWVhNIhrb2oA5RiK"
+ "integrity": "sha384-dFsbWdy82ur5QrZ5M8TeJzcgYwMD6FdkLXub1odJKKlSWZuFHiGxDSRpTAYUNZpr"
},
},
"/css/features/search.css": {
"/css/features/search.css": {
- "url": "/css/features/search.css?v=59f2f133271c",
+ "url": "/css/features/search.css?v=9ddd7160c54c",
"component": "styles",
"component": "styles",
- "integrity": "sha384-RJYUmnxzvBcKyiQ5i2JiaKeDt7n04ZxD5fi5T1PT66PR0Yawvd0FsvFuTdBY8djz"
+ "integrity": "sha384-vBjTPlCMue0BX3XhCDjqRgbDgk+Rcmn2jUiBqfUYQXG+iH3VQ+GAvww3kICowLG8"
},
},
"/css/features/stats.css": {
"/css/features/stats.css": {
- "url": "/css/features/stats.css?v=7676bfc9ec0d",
+ "url": "/css/features/stats.css?v=0a1c1c6e4799",
"component": "styles",
"component": "styles",
- "integrity": "sha384-BO+i8mr3s9siQE0a/XSNd+MvZfi8GDyhIjQFf+9zC5CUXx5DvmoY71wRT0jUr42X"
+ "integrity": "sha384-tiqr/ezmfQZcqEoCOfoC49n9LBNfeEbY7pCmRNS26+C5+OB3vw3vCv6hBhRsPC/m"
},
},
"/css/home-fx.css": {
"/css/home-fx.css": {
- "url": "/css/home-fx.css?v=8f135af68e76",
+ "url": "/css/home-fx.css?v=e663894c29e3",
"component": "styles",
"component": "styles",
- "integrity": "sha384-nQYM/f49re/MHydZjlV6hNxCLoyuxQzovB+ui4pxQVAho58GRcwfrsC8ruxcmq4Y"
+ "integrity": "sha384-U8Kz03V5TkIF/hK5+EHWbMVu0usivPRna4gmXlTtX3AElTtjbkDrivAYyINJJ81J"
},
},
"/css/home-tour.css": {
"/css/home-tour.css": {
"url": "/css/home-tour.css?v=3f1293159aff",
"url": "/css/home-tour.css?v=3f1293159aff",
@@ -121,9 +116,9 @@
@@ -121,9 +116,9 @@
"integrity": "sha384-amhdltUcvPHMlKbKgAysH1oVa5LXmXYvie7lrfnBB7+hnqpcSiFHfLUDcwnSznho"
"integrity": "sha384-amhdltUcvPHMlKbKgAysH1oVa5LXmXYvie7lrfnBB7+hnqpcSiFHfLUDcwnSznho"
},
},
"/css/i18n-star.css": {
"/css/i18n-star.css": {
- "url": "/css/i18n-star.css?v=365567ee3c2e",
+ "url": "/css/i18n-star.css?v=c5b0b2261c10",
"component": "styles",
"component": "styles",
- "integrity": "sha384-ywRuEe6z23e3VtwROEu2yNUbrnajlBMv5M3vyZjZ1hfe1Dli6pGYLa8uu3z71Mo0"
+ "integrity": "sha384-reKMOVv9o0oQvhVOJLVJ2LIdqIAzmJK+8zknG6bitXFH6FYKLqHIXiFmyJgonCw5"
},
},
"/css/media-player.css": {
"/css/media-player.css": {
"url": "/css/media-player.css?v=7b7fdf4b15e5",
"url": "/css/media-player.css?v=7b7fdf4b15e5",
@@ -131,24 +126,24 @@
@@ -131,24 +126,24 @@
"integrity": "sha384-OsZjCMedAayAH/g66rIZS8/cND69xI8/Qx1ci04sfY8gbrncRjXT0WCDg/d+BYg9"
"integrity": "sha384-OsZjCMedAayAH/g66rIZS8/cND69xI8/Qx1ci04sfY8gbrncRjXT0WCDg/d+BYg9"
},
},
"/css/messenger.css": {
"/css/messenger.css": {
- "url": "/css/messenger.css?v=8d3312c1f857",
+ "url": "/css/messenger.css?v=b546de06782d",
"component": "styles",
"component": "styles",
- "integrity": "sha384-2+sE2r3ITTgGbYnYQ2TD5atavSwm9i2LyvxGPkKYYRn07kizleHqQYAYrrZVW5cr"
+ "integrity": "sha384-Gum2h1hkiOCHYGzL51Vkg18dykFocQYUKV5nj+em30urSOUV7DTkH0/qBZuRofIS"
},
},
"/css/ov-guide.css": {
"/css/ov-guide.css": {
- "url": "/css/ov-guide.css?v=5eb601c01a03",
+ "url": "/css/ov-guide.css?v=51e7b691671e",
"component": "styles",
"component": "styles",
- "integrity": "sha384-06D5yA90IAi0imrONhtPHVkzAe00RqUQM0w96tT5EUXx1lwLjn6P2f0UTruHZCss"
+ "integrity": "sha384-NgjftNH1wYEitNkPJWxk+J1rSLOeFiLzrujIouib3JjKTS0Z4Zv9HC8e4/IuI1IJ"
},
},
"/css/recap.css": {
"/css/recap.css": {
- "url": "/css/recap.css?v=3da2cd3feae6",
+ "url": "/css/recap.css?v=cda4dc52e8a4",
"component": "styles",
"component": "styles",
- "integrity": "sha384-iVDuJckwXLQPfhSFXjJKmyIIXM+XbRccOWefcHh1vChYrlfwFWAgWXinI4iihWor"
+ "integrity": "sha384-nnjFlUlLlwwU2ATKsy4JqdwUBltQRQrNx0U1rDfBnsCrWAlj2r91DGgH2GgXQZpZ"
},
},
"/css/style.css": {
"/css/style.css": {
- "url": "/css/style.css?v=57784f84656e",
+ "url": "/css/style.css?v=a417682204dd",
"component": "styles",
"component": "styles",
- "integrity": "sha384-2pazK2Qkcku6o4h6cZfkVZ8BiU3xw4wzvR0bVbXr7teT/+VuJoub8kY8OKTSkaBb"
+ "integrity": "sha384-qbBXlMdrglg4suN/qZtHyXjWNnPemV+vfe/yACjNZ9YhL1bfPUaM7RvwIwz1wWhF"
},
},
"/css/vendor/fontawesome-6.5.1.min.css": {
"/css/vendor/fontawesome-6.5.1.min.css": {
"url": "/css/vendor/fontawesome-6.5.1.min.css?v=d6c948f648fd",
"url": "/css/vendor/fontawesome-6.5.1.min.css?v=d6c948f648fd",
@@ -464,388 +459,6 @@
@@ -464,388 +459,6 @@
"public/js/app-channel.js"
"public/js/app-channel.js"
]
]
},
},
- {
- "method": "GET",
- "path": "/api/arena/beefs",
- "auth": "anon",
- "status": 200,
- "kind": "json",
- "reads": {
- "open": "array",
- "resolved": "array"
- },
- "from": [
- "public/js/arena.js"
- ]
- },
- {
- "method": "GET",
- "path": "/api/arena/beefs",
- "auth": "user",
- "status": 200,
- "kind": "json",
- "reads": {
- "open": "array",
- "resolved": "array"
- },
- "from": [
- "public/js/arena.js"
- ]
- },
- {
- "method": "GET",
- "path": "/api/arena/beefs/1",
- "auth": "anon",
- "status": 404,
- "kind": "json",
- "from": [
- "public/js/arena.js"
- ]
- },
- {
- "method": "GET",
- "path": "/api/arena/beefs/1",
- "auth": "user",
- "status": 404,
- "kind": "json",
- "from": [
- "public/js/arena.js"
- ]
- },
- {
- "method": "GET",
- "path": "/api/arena/console/n1star",
- "auth": "anon",
- "status": 200,
- "kind": "json",
- "reads": {
- "fighter": "object",
- "fighter.user": "object",
- "fighter.user.id": "number",
- "fighter.user.username": "string",
- "fighter.user.display_name": "string",
- "fighter.user.avatar_url": "null",
- "fighter.user.profile_color": "string",
- "fighter.user.bio": "string",
- "fighter.fighter_name": "string",
- "fighter.rank": "null",
- "fighter.image_url": "null",
- "fighter.live": "boolean",
- "fighter.level": "number",
- "on_roster": "boolean",
- "live": "boolean",
- "stream": "object",
- "stream.id": "number",
- "stream.title": "string",
- "stream.started_at": "string",
- "stream.viewer_count": "number",
- "transcribed": "boolean",
- "listener": "object",
- "listener.listening": "boolean",
- "level": "object",
- "level.level": "number",
- "level.xp": "number",
- "level.xp_into_level": "number",
- "level.xp_per_level": "number",
- "level.mic_moments": "number",
- "level.beef_hits": "number",
- "level.recent_xp": "number",
- "level.best_line": "null",
- "mic": "object",
- "mic.moments": "number",
- "mic.beef_hits": "number",
- "mic.bangers": "number",
- "mic.avg_quality": "number",
- "mic.targeted": "number",
- "mic.answered": "number",
- "mic.wins": "number",
- "mic.losses": "number",
- "mic.speech_minutes": "number",
- "open_beefs": "array",
- "hot_mic": "array",
- "recent_moments": "array"
- },
- "from": [
- "public/js/arena.js"
- ]
- },
- {
- "method": "GET",
- "path": "/api/arena/console/n1star",
- "auth": "user",
- "status": 200,
- "kind": "json",
- "reads": {
- "fighter": "object",
- "fighter.user": "object",
- "fighter.user.id": "number",
- "fighter.user.username": "string",
- "fighter.user.display_name": "string",
- "fighter.user.avatar_url": "null",
- "fighter.user.profile_color": "string",
- "fighter.user.bio": "string",
- "fighter.fighter_name": "string",
- "fighter.rank": "null",
- "fighter.image_url": "null",
- "fighter.live": "boolean",
- "fighter.level": "number",
- "on_roster": "boolean",
- "live": "boolean",
- "stream": "object",
- "stream.id": "number",
- "stream.title": "string",
- "stream.started_at": "string",
- "stream.viewer_count": "number",
- "transcribed": "boolean",
- "listener": "object",
- "listener.listening": "boolean",
- "level": "object",
- "level.level": "number",
- "level.xp": "number",
- "level.xp_into_level": "number",
- "level.xp_per_level": "number",
- "level.mic_moments": "number",
- "level.beef_hits": "number",
- "level.recent_xp": "number",
- "level.best_line": "null",
- "mic": "object",
- "mic.moments": "number",
- "mic.beef_hits": "number",
- "mic.bangers": "number",
- "mic.avg_quality": "number",
- "mic.targeted": "number",
- "mic.answered": "number",
- "mic.wins": "number",
- "mic.losses": "number",
- "mic.speech_minutes": "number",
- "open_beefs": "array",
- "hot_mic": "array",
- "recent_moments": "array"
- },
- "from": [
- "public/js/arena.js"
- ]
- },
- {
- "method": "GET",
- "path": "/api/arena/feed?limit=40",
- "auth": "anon",
- "status": 200,
- "kind": "json",
- "reads": {
- "feed": "array",
- "ai": "boolean"
- },
- "from": [
- "public/js/arena.js"
- ]
- },
- {
- "method": "GET",
- "path": "/api/arena/feed?limit=40",
- "auth": "user",
- "status": 200,
- "kind": "json",
- "reads": {
- "feed": "array",
- "ai": "boolean"
- },
- "from": [
- "public/js/arena.js"
- ]
- },
- {
- "method": "GET",
- "path": "/api/arena/fighters",
- "auth": "anon",
- "status": 200,
- "kind": "json",
- "reads": {
- "fighters": "array",
- "ai": "boolean",
- "stats": "array",
- "stats[]": "string",
- "stat_meta": "object",
- "stat_meta.heat": "object",
- "stat_meta.heat.label": "string",
- "stat_meta.heat.desc": "string",
- "stat_meta.aim": "object",
- "stat_meta.aim.label": "string",
- "stat_meta.aim.desc": "string",
- "stat_meta.kills": "object",
- "stat_meta.kills.label": "string",
- "stat_meta.kills.desc": "string",
- "stat_meta.mouth": "object",
- "stat_meta.mouth.label": "string",
- "stat_meta.mouth.desc": "string",
- "stat_meta.clapback": "object",
- "stat_meta.clapback.label": "string",
- "stat_meta.clapback.desc": "string",
- "stat_meta.stamina": "object",
- "stat_meta.stamina.label": "string",
- "stat_meta.stamina.desc": "string",
- "stat_meta.pace": "object",
- "stat_meta.pace.label": "string",
- "stat_meta.pace.desc": "string"
- },
- "from": [
- "public/js/arena.js"
- ]
- },
- {
- "method": "GET",
- "path": "/api/arena/fighters",
- "auth": "user",
- "status": 200,
- "kind": "json",
- "reads": {
- "fighters": "array",
- "ai": "boolean",
- "stats": "array",
- "stats[]": "string",
- "stat_meta": "object",
- "stat_meta.heat": "object",
- "stat_meta.heat.label": "string",
- "stat_meta.heat.desc": "string",
- "stat_meta.aim": "object",
- "stat_meta.aim.label": "string",
- "stat_meta.aim.desc": "string",
- "stat_meta.kills": "object",
- "stat_meta.kills.label": "string",
- "stat_meta.kills.desc": "string",
- "stat_meta.mouth": "object",
- "stat_meta.mouth.label": "string",
- "stat_meta.mouth.desc": "string",
- "stat_meta.clapback": "object",
- "stat_meta.clapback.label": "string",
- "stat_meta.clapback.desc": "string",
- "stat_meta.stamina": "object",
- "stat_meta.stamina.label": "string",
- "stat_meta.stamina.desc": "string",
- "stat_meta.pace": "object",
- "stat_meta.pace.label": "string",
- "stat_meta.pace.desc": "string"
- },
- "from": [
- "public/js/arena.js"
- ]
- },
- {
- "method": "GET",
- "path": "/api/arena/fighters/n1star",
- "auth": "anon",
- "status": 200,
- "kind": "json",
- "reads": {
- "user": "object",
- "user.id": "number",
- "user.username": "string",
- "user.display_name": "string",
- "user.avatar_url": "null",
- "user.profile_color": "string",
- "user.bio": "string",
- "not_on_roster": "boolean",
- "reason": "string"
- },
- "from": [
- "public/js/arena.js"
- ]
- },
- {
- "method": "GET",
- "path": "/api/arena/fighters/n1star",
- "auth": "user",
- "status": 200,
- "kind": "json",
- "reads": {
- "user": "object",
- "user.id": "number",
- "user.username": "string",
- "user.display_name": "string",
- "user.avatar_url": "null",
- "user.profile_color": "string",
- "user.bio": "string",
- "not_on_roster": "boolean",
- "reason": "string"
- },
- "from": [
- "public/js/arena.js"
- ]
- },
- {
- "method": "GET",
- "path": "/api/arena/fighters/n1star/stat/1",
- "auth": "anon",
- "status": 404,
- "kind": "json",
- "from": [
- "public/js/arena.js"
- ]
- },
- {
- "method": "GET",
- "path": "/api/arena/fighters/n1star/stat/1",
- "auth": "user",
- "status": 404,
- "kind": "json",
- "from": [
- "public/js/arena.js"
- ]
- },
- {
- "method": "GET",
- "path": "/api/arena/live",
- "auth": "anon",
- "status": 200,
- "kind": "json",
- "reads": {
- "live": "array"
- },
- "from": [
- "public/js/arena.js"
- ]
- },
- {
- "method": "GET",
- "path": "/api/arena/live",
- "auth": "user",
- "status": 200,
- "kind": "json",
- "reads": {
- "live": "array"
- },
- "from": [
- "public/js/arena.js"
- ]
- },
- {
- "method": "GET",
- "path": "/api/arena/me",
- "auth": "anon",
- "status": 401,
- "kind": "json",
- "from": [
- "public/js/arena.js"
- ]
- },
- {
- "method": "GET",
- "path": "/api/arena/me",
- "auth": "user",
- "status": 200,
- "kind": "json",
- "reads": {
- "on_roster": "boolean",
- "fighter": "null",
- "beefs": "array",
- "on_clock": "array",
- "moments": "array",
- "live": "boolean"
- },
- "from": [
- "public/js/arena.js"
- ]
- },
{
{
"method": "GET",
"method": "GET",
"path": "/api/auth/avatar/history",
"path": "/api/auth/avatar/history",
@@ -936,7 +549,6 @@
@@ -936,7 +549,6 @@
"user.capabilities.can_manage_channels": "boolean",
"user.capabilities.can_manage_channels": "boolean",
"user.capabilities.view_ip_info": "boolean",
"user.capabilities.view_ip_info": "boolean",
"user.capabilities.is_owner": "boolean",
"user.capabilities.is_owner": "boolean",
- "user.openvibe_coins_balance": "number",
"user.stream_key": "string",
"user.stream_key": "string",
"capabilities": "object",
"capabilities": "object",
"capabilities.admin_panel": "boolean",
"capabilities.admin_panel": "boolean",
@@ -1321,7 +933,6 @@
@@ -1321,7 +933,6 @@
"status": 502,
"status": 502,
"kind": "json",
"kind": "json",
"from": [
"from": [
- "public/js/app-media.js",
"public/js/stream-player.js"
"public/js/stream-player.js"
]
]
},
},
@@ -1332,7 +943,6 @@
@@ -1332,7 +943,6 @@
"status": 502,
"status": 502,
"kind": "json",
"kind": "json",
"from": [
"from": [
- "public/js/app-media.js",
"public/js/stream-player.js"
"public/js/stream-player.js"
]
]
},
},
@@ -1394,7 +1004,7 @@
@@ -1394,7 +1004,7 @@
"status": 200,
"status": 200,
"kind": "json",
"kind": "json",
"reads": {
"reads": {
- "balance": "number"
+ "balance": "null"
},
},
"from": [
"from": [
"public/js/app.js",
"public/js/app.js",
@@ -1522,235 +1132,6 @@
@@ -1522,235 +1132,6 @@
"public/js/opencoins.js"
"public/js/opencoins.js"
]
]
},
},
- {
- "method": "GET",
- "path": "/api/comments/1/1",
- "auth": "anon",
- "status": 400,
- "kind": "json",
- "from": [
- "public/js/app-media.js"
- ]
- },
- {
- "method": "GET",
- "path": "/api/comments/1/1",
- "auth": "user",
- "status": 400,
- "kind": "json",
- "from": [
- "public/js/app-media.js"
- ]
- },
- {
- "method": "GET",
- "path": "/api/controls/configs",
- "auth": "anon",
- "status": 401,
- "kind": "json",
- "from": [
- "public/js/broadcast-workspace.js",
- "public/js/broadcast.js",
- "public/js/dashboard.js"
- ]
- },
- {
- "method": "GET",
- "path": "/api/controls/configs",
- "auth": "user",
- "status": 200,
- "kind": "json",
- "reads": {
- "configs": "array"
- },
- "from": [
- "public/js/broadcast-workspace.js",
- "public/js/broadcast.js",
- "public/js/dashboard.js"
- ]
- },
- {
- "method": "GET",
- "path": "/api/controls/configs/1",
- "auth": "anon",
- "status": 401,
- "kind": "json",
- "from": [
- "public/js/dashboard.js"
- ]
- },
- {
- "method": "GET",
- "path": "/api/controls/configs/1",
- "auth": "user",
- "status": 404,
- "kind": "json",
- "from": [
- "public/js/dashboard.js"
- ]
- },
- {
- "method": "GET",
- "path": "/api/controls/configs/1/bridge-script?type=generic",
- "auth": "anon",
- "status": 401,
- "kind": "json",
- "from": [
- "public/js/dashboard.js"
- ]
- },
- {
- "method": "GET",
- "path": "/api/controls/configs/1/bridge-script?type=generic",
- "auth": "user",
- "status": 404,
- "kind": "json",
- "from": [
- "public/js/dashboard.js"
- ]
- },
- {
- "method": "GET",
- "path": "/api/controls/presets",
- "auth": "anon",
- "status": 200,
- "kind": "json",
- "reads": {
- "controls": "array",
- "settings": "object"
- },
- "from": [
- "public/js/dashboard.js"
- ]
- },
- {
- "method": "GET",
- "path": "/api/controls/presets",
- "auth": "user",
- "status": 200,
- "kind": "json",
- "reads": {
- "controls": "array",
- "settings": "object"
- },
- "from": [
- "public/js/dashboard.js"
- ]
- },
- {
- "method": "GET",
- "path": "/api/controls/settings/channel",
- "auth": "anon",
- "status": 401,
- "kind": "json",
- "from": [
- "public/js/broadcast.js",
- "public/js/dashboard.js"
- ]
- },
- {
- "method": "GET",
- "path": "/api/controls/settings/channel",
- "auth": "user",
- "status": 200,
- "kind": "json",
- "reads": {
- "control_mode": "string",
- "anon_controls_enabled": "boolean",
- "control_rate_limit_ms": "number",
- "video_click_enabled": "boolean",
- "video_click_rate_limit_ms": "number",
- "active_control_config_id": "null"
- },
- "from": [
- "public/js/broadcast.js",
- "public/js/dashboard.js"
- ]
- },
- {
- "method": "GET",
- "path": "/api/controls/whitelist",
- "auth": "anon",
- "status": 401,
- "kind": "json",
- "from": [
- "public/js/dashboard.js"
- ]
- },
- {
- "method": "GET",
- "path": "/api/controls/whitelist",
- "auth": "user",
- "status": 200,
- "kind": "json",
- "reads": {
- "whitelist": "array"
- },
- "from": [
- "public/js/dashboard.js"
- ]
- },
- {
- "method": "GET",
- "path": "/api/controls/1",
- "auth": "anon",
- "status": 200,
- "kind": "json",
- "reads": {
- "controls": "array",
- "settings": "object",
- "settings.video_click_enabled": "boolean",
- "settings.control_mode": "string",
- "settings.anon_controls_enabled": "boolean",
- "settings.control_rate_limit_ms": "number",
- "settings.video_click_rate_limit_ms": "number"
- },
- "from": [
- "public/js/controls.js"
- ]
- },
- {
- "method": "GET",
- "path": "/api/controls/1",
- "auth": "user",
- "status": 200,
- "kind": "json",
- "reads": {
- "controls": "array",
- "settings": "object",
- "settings.video_click_enabled": "boolean",
- "settings.control_mode": "string",
- "settings.anon_controls_enabled": "boolean",
- "settings.control_rate_limit_ms": "number",
- "settings.video_click_rate_limit_ms": "number"
- },
- "from": [
- "public/js/controls.js"
- ]
- },
- {
- "method": "GET",
- "path": "/api/controls/1/config",
- "auth": "anon",
- "status": 401,
- "kind": "json",
- "from": [
- "public/js/broadcast.js"
- ]
- },
- {
- "method": "GET",
- "path": "/api/controls/1/config",
- "auth": "user",
- "status": 200,
- "kind": "json",
- "reads": {
- "control_config_id": "null"
- },
- "from": [
- "public/js/broadcast.js"
- ]
- },
{
{
"method": "GET",
"method": "GET",
"path": "/api/cosmetics/catalog",
"path": "/api/cosmetics/catalog",
@@ -1909,34 +1290,6 @@
@@ -1909,34 +1290,6 @@
"public/js/app-home.js"
"public/js/app-home.js"
]
]
},
},
- {
- "method": "GET",
- "path": "/api/funds/balance",
- "auth": "anon",
- "status": 401,
- "kind": "json",
- "from": [
- "public/js/app.js",
- "public/js/dashboard.js",
- "public/js/vibes.js"
- ]
- },
- {
- "method": "GET",
- "path": "/api/funds/balance",
- "auth": "user",
- "status": 200,
- "kind": "json",
- "reads": {
- "balance": "number",
- "cashout_balance": "number"
- },
- "from": [
- "public/js/app.js",
- "public/js/dashboard.js",
- "public/js/vibes.js"
- ]
- },
{
{
"method": "GET",
"method": "GET",
"path": "/api/funds/goals/manage/mine",
"path": "/api/funds/goals/manage/mine",
@@ -1986,29 +1339,6 @@
@@ -1986,29 +1339,6 @@
"public/js/app-channel.js"
"public/js/app-channel.js"
]
]
},
},
- {
- "method": "GET",
- "path": "/api/funds/history?limit=100",
- "auth": "anon",
- "status": 401,
- "kind": "json",
- "from": [
- "public/js/dashboard.js"
- ]
- },
- {
- "method": "GET",
- "path": "/api/funds/history?limit=100",
- "auth": "user",
- "status": 200,
- "kind": "json",
- "reads": {
- "transactions": "array"
- },
- "from": [
- "public/js/dashboard.js"
- ]
- },
{
{
"method": "GET",
"method": "GET",
"path": "/api/home/hero",
"path": "/api/home/hero",
@@ -2020,12 +1350,12 @@
@@ -2020,12 +1350,12 @@
"stats.viewersNow": "number",
"stats.viewersNow": "number",
"stats.hoursWatched": "number",
"stats.hoursWatched": "number",
"stats.vibesTipped": "number",
"stats.vibesTipped": "number",
- "stats.activeSubs": "number",
+ "stats.activeSubs": "null",
"stats.pointsEarned": "number",
"stats.pointsEarned": "number",
"stats.pointsSpent": "number",
"stats.pointsSpent": "number",
"stats.redemptions": "number",
"stats.redemptions": "number",
"stats.supporters": "number",
"stats.supporters": "number",
- "stats.vibesBought": "number",
+ "stats.vibesBought": "null",
"stats.goalsActive": "number",
"stats.goalsActive": "number",
"stats.goalsReached": "number",
"stats.goalsReached": "number",
"stats.vods": "null",
"stats.vods": "null",
@@ -2116,16 +1446,8 @@
@@ -2116,16 +1446,8 @@
"stats.recent.redemptions.w": "number",
"stats.recent.redemptions.w": "number",
"stats.recent.redemptions.m": "number",
"stats.recent.redemptions.m": "number",
"stats.recent.redemptions.pw": "number",
"stats.recent.redemptions.pw": "number",
- "stats.recent.vibesBought": "object",
- "stats.recent.vibesBought.d": "number",
- "stats.recent.vibesBought.w": "number",
- "stats.recent.vibesBought.m": "number",
- "stats.recent.vibesBought.pw": "number",
- "stats.recent.subs": "object",
- "stats.recent.subs.d": "number",
- "stats.recent.subs.w": "number",
- "stats.recent.subs.m": "number",
- "stats.recent.subs.pw": "number",
+ "stats.recent.vibesBought": "null",
+ "stats.recent.subs": "null",
"stats.recent.follows": "object",
"stats.recent.follows": "object",
"stats.recent.follows.d": "number",
"stats.recent.follows.d": "number",
"stats.recent.follows.w": "number",
"stats.recent.follows.w": "number",
@@ -2169,12 +1491,12 @@
@@ -2169,12 +1491,12 @@
"stats.viewersNow": "number",
"stats.viewersNow": "number",
"stats.hoursWatched": "number",
"stats.hoursWatched": "number",
"stats.vibesTipped": "number",
"stats.vibesTipped": "number",
- "stats.activeSubs": "number",
+ "stats.activeSubs": "null",
"stats.pointsEarned": "number",
"stats.pointsEarned": "number",
"stats.pointsSpent": "number",
"stats.pointsSpent": "number",
"stats.redemptions": "number",
"stats.redemptions": "number",
"stats.supporters": "number",
"stats.supporters": "number",
- "stats.vibesBought": "number",
+ "stats.vibesBought": "null",
"stats.goalsActive": "number",
"stats.goalsActive": "number",
"stats.goalsReached": "number",
"stats.goalsReached": "number",
"stats.vods": "null",
"stats.vods": "null",
@@ -2265,16 +1587,8 @@
@@ -2265,16 +1587,8 @@
"stats.recent.redemptions.w": "number",
"stats.recent.redemptions.w": "number",
"stats.recent.redemptions.m": "number",
"stats.recent.redemptions.m": "number",
"stats.recent.redemptions.pw": "number",
"stats.recent.redemptions.pw": "number",
- "stats.recent.vibesBought": "object",
- "stats.recent.vibesBought.d": "number",
- "stats.recent.vibesBought.w": "number",
- "stats.recent.vibesBought.m": "number",
- "stats.recent.vibesBought.pw": "number",
- "stats.recent.subs": "object",
- "stats.recent.subs.d": "number",
- "stats.recent.subs.w": "number",
- "stats.recent.subs.m": "number",
- "stats.recent.subs.pw": "number",
+ "stats.recent.vibesBought": "null",
+ "stats.recent.subs": "null",
"stats.recent.follows": "object",
"stats.recent.follows": "object",
"stats.recent.follows.d": "number",
"stats.recent.follows.d": "number",
"stats.recent.follows.w": "number",
"stats.recent.follows.w": "number",
@@ -2556,7 +1870,6 @@
@@ -2556,7 +1870,6 @@
"live_stream.duration_seconds": "number",
"live_stream.duration_seconds": "number",
"live_stream.created_at": "string",
"live_stream.created_at": "string",
"live_stream.call_mode": "null",
"live_stream.call_mode": "null",
- "live_stream.control_config_id": "null",
"live_stream.managed_stream_id": "number",
"live_stream.managed_stream_id": "number",
"live_stream.ai_overview": "null",
"live_stream.ai_overview": "null",
"live_stream.ai_overview_short": "null",
"live_stream.ai_overview_short": "null",
@@ -2634,7 +1947,6 @@
@@ -2634,7 +1947,6 @@
"live_stream.duration_seconds": "number",
"live_stream.duration_seconds": "number",
"live_stream.created_at": "string",
"live_stream.created_at": "string",
"live_stream.call_mode": "null",
"live_stream.call_mode": "null",
- "live_stream.control_config_id": "null",
"live_stream.managed_stream_id": "number",
"live_stream.managed_stream_id": "number",
"live_stream.ai_overview": "null",
"live_stream.ai_overview": "null",
"live_stream.ai_overview_short": "null",
"live_stream.ai_overview_short": "null",
@@ -2801,97 +2113,30 @@
@@ -2801,97 +2113,30 @@
"status": 403,
"status": 403,
"kind": "json",
"kind": "json",
"from": [
"from": [
- "public/js/chat.js"
- ]
- },
- {
- "method": "GET",
- "path": "/api/news/my-settings",
- "auth": "anon",
- "status": 401,
- "kind": "json",
- "from": [
- "public/js/broadcast.js"
- ]
- },
- {
- "method": "GET",
- "path": "/api/news/my-settings",
- "auth": "user",
- "status": 200,
- "kind": "json",
- "reads": {
- "enabled": "null"
- },
- "from": [
- "public/js/broadcast.js"
- ]
- },
- {
- "method": "GET",
- "path": "/api/onvif/cameras",
- "auth": "anon",
- "status": 401,
- "kind": "json",
- "from": [
- "public/js/onvif-dashboard.js"
- ]
- },
- {
- "method": "GET",
- "path": "/api/onvif/cameras",
- "auth": "user",
- "status": 200,
- "kind": "json",
- "reads": {
- "cameras": "array"
- },
- "from": [
- "public/js/onvif-dashboard.js"
- ]
- },
- {
- "method": "GET",
- "path": "/api/onvif/cameras/1",
- "auth": "anon",
- "status": 401,
- "kind": "json",
- "from": [
- "public/js/controls.js",
- "public/js/onvif-dashboard.js"
- ]
- },
- {
- "method": "GET",
- "path": "/api/onvif/cameras/1",
- "auth": "user",
- "status": 404,
- "kind": "json",
- "from": [
- "public/js/controls.js",
- "public/js/onvif-dashboard.js"
+ "public/js/chat.js"
]
]
},
},
{
{
"method": "GET",
"method": "GET",
- "path": "/api/onvif/cameras/1/presets",
+ "path": "/api/news/my-settings",
"auth": "anon",
"auth": "anon",
"status": 401,
"status": 401,
"kind": "json",
"kind": "json",
"from": [
"from": [
- "public/js/controls.js",
- "public/js/onvif-dashboard.js"
+ "public/js/broadcast.js"
]
]
},
},
{
{
"method": "GET",
"method": "GET",
- "path": "/api/onvif/cameras/1/presets",
+ "path": "/api/news/my-settings",
"auth": "user",
"auth": "user",
- "status": 404,
+ "status": 200,
"kind": "json",
"kind": "json",
+ "reads": {
+ "enabled": "null"
+ },
"from": [
"from": [
- "public/js/controls.js",
- "public/js/onvif-dashboard.js"
+ "public/js/broadcast.js"
]
]
},
},
{
{
@@ -3034,6 +2279,36 @@
@@ -3034,6 +2279,36 @@
"public/js/app-home.js"
"public/js/app-home.js"
]
]
},
},
+ {
+ "method": "GET",
+ "path": "/api/payments/channel/1/elsewhere",
+ "auth": "anon",
+ "status": 200,
+ "kind": "json",
+ "reads": {
+ "memberships": "null",
+ "support_links": "null",
+ "tips_url": "null"
+ },
+ "from": [
+ "public/js/channel-support.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/payments/channel/1/elsewhere",
+ "auth": "user",
+ "status": 200,
+ "kind": "json",
+ "reads": {
+ "memberships": "null",
+ "support_links": "null",
+ "tips_url": "null"
+ },
+ "from": [
+ "public/js/channel-support.js"
+ ]
+ },
{
{
"method": "GET",
"method": "GET",
"path": "/api/payments/channel/n1star",
"path": "/api/payments/channel/n1star",
@@ -3376,6 +2651,216 @@
@@ -3376,6 +2651,216 @@
"public/js/guides-live.js"
"public/js/guides-live.js"
]
]
},
},
+ {
+ "method": "GET",
+ "path": "/api/rundown",
+ "auth": "anon",
+ "status": 200,
+ "kind": "json",
+ "reads": {
+ "on_air": "array",
+ "on_air[]": "object",
+ "on_air[].stream_id": "number",
+ "on_air[].user": "object",
+ "on_air[].user.id": "number",
+ "on_air[].user.username": "string",
+ "on_air[].user.display_name": "string",
+ "on_air[].user.avatar_url": "null",
+ "on_air[].user.profile_color": "string",
+ "on_air[].stream_title": "string",
+ "on_air[].talking_about": "null",
+ "on_air[].topic": "null",
+ "lead": "null",
+ "streams": "array",
+ "streams_next": "null",
+ "trending": "array",
+ "voices": "array",
+ "latest": "array",
+ "next": "null",
+ "counts": "object",
+ "counts.topics": "number",
+ "counts.episodes": "number",
+ "counts.takes": "number"
+ },
+ "from": [
+ "public/js/rundown.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/rundown",
+ "auth": "user",
+ "status": 200,
+ "kind": "json",
+ "reads": {
+ "on_air": "array",
+ "on_air[]": "object",
+ "on_air[].stream_id": "number",
+ "on_air[].user": "object",
+ "on_air[].user.id": "number",
+ "on_air[].user.username": "string",
+ "on_air[].user.display_name": "string",
+ "on_air[].user.avatar_url": "null",
+ "on_air[].user.profile_color": "string",
+ "on_air[].stream_title": "string",
+ "on_air[].talking_about": "null",
+ "on_air[].topic": "null",
+ "lead": "null",
+ "streams": "array",
+ "streams_next": "null",
+ "trending": "array",
+ "voices": "array",
+ "latest": "array",
+ "next": "null",
+ "counts": "object",
+ "counts.topics": "number",
+ "counts.episodes": "number",
+ "counts.takes": "number"
+ },
+ "from": [
+ "public/js/rundown.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/rundown/e/1",
+ "auth": "anon",
+ "status": 404,
+ "kind": "json",
+ "from": [
+ "public/js/rundown.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/rundown/e/1",
+ "auth": "user",
+ "status": 404,
+ "kind": "json",
+ "from": [
+ "public/js/rundown.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/rundown/s/n1star",
+ "auth": "anon",
+ "status": 200,
+ "kind": "json",
+ "reads": {
+ "user": "object",
+ "user.id": "number",
+ "user.username": "string",
+ "user.display_name": "string",
+ "user.avatar_url": "null",
+ "user.profile_color": "string",
+ "opted_out": "boolean",
+ "episodes": "array",
+ "topics": "array",
+ "lines": "array",
+ "crossovers": "array"
+ },
+ "from": [
+ "public/js/rundown.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/rundown/s/n1star",
+ "auth": "user",
+ "status": 200,
+ "kind": "json",
+ "reads": {
+ "user": "object",
+ "user.id": "number",
+ "user.username": "string",
+ "user.display_name": "string",
+ "user.avatar_url": "null",
+ "user.profile_color": "string",
+ "opted_out": "boolean",
+ "episodes": "array",
+ "topics": "array",
+ "lines": "array",
+ "crossovers": "array"
+ },
+ "from": [
+ "public/js/rundown.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/rundown/streams?before=",
+ "auth": "anon",
+ "status": 200,
+ "kind": "json",
+ "reads": {
+ "streams": "array",
+ "next": "null"
+ },
+ "from": [
+ "public/js/rundown.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/rundown/streams?before=",
+ "auth": "user",
+ "status": 200,
+ "kind": "json",
+ "reads": {
+ "streams": "array",
+ "next": "null"
+ },
+ "from": [
+ "public/js/rundown.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/rundown/t/n1star",
+ "auth": "anon",
+ "status": 404,
+ "kind": "json",
+ "from": [
+ "public/js/rundown.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/rundown/t/n1star",
+ "auth": "user",
+ "status": 404,
+ "kind": "json",
+ "from": [
+ "public/js/rundown.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/rundown/topics?sort=&kind=${kind}",
+ "auth": "anon",
+ "status": 200,
+ "kind": "json",
+ "reads": {
+ "topics": "array"
+ },
+ "from": [
+ "public/js/rundown.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/rundown/topics?sort=&kind=${kind}",
+ "auth": "user",
+ "status": 200,
+ "kind": "json",
+ "reads": {
+ "topics": "array"
+ },
+ "from": [
+ "public/js/rundown.js"
+ ]
+ },
{
{
"method": "GET",
"method": "GET",
"path": "/api/search/suggest",
"path": "/api/search/suggest",
@@ -3516,7 +3001,6 @@
@@ -3516,7 +3001,6 @@
"streams[].duration_seconds": "number",
"streams[].duration_seconds": "number",
"streams[].created_at": "string",
"streams[].created_at": "string",
"streams[].call_mode": "null",
"streams[].call_mode": "null",
- "streams[].control_config_id": "null",
"streams[].managed_stream_id": "number",
"streams[].managed_stream_id": "number",
"streams[].ai_overview": "null",
"streams[].ai_overview": "null",
"streams[].ai_overview_short": "null",
"streams[].ai_overview_short": "null",
@@ -3546,12 +3030,6 @@
@@ -3546,12 +3030,6 @@
"streams[].channel.updated_at": "string",
"streams[].channel.updated_at": "string",
"streams[].channel.weather_detail": "string",
"streams[].channel.weather_detail": "string",
"streams[].channel.weather_show_location": "number",
"streams[].channel.weather_show_location": "number",
- "streams[].channel.control_mode": "string",
- "streams[].channel.anon_controls_enabled": "number",
- "streams[].channel.control_rate_limit_ms": "number",
- "streams[].channel.active_control_config_id": "null",
- "streams[].channel.video_click_enabled": "number",
- "streams[].channel.video_click_rate_limit_ms": "number",
"streams[].channel.offline_screen_type": "string",
"streams[].channel.offline_screen_type": "string",
"streams[].channel.offline_screen_url": "null",
"streams[].channel.offline_screen_url": "null",
"streams[].channel.offline_html": "null",
"streams[].channel.offline_html": "null",
@@ -3597,7 +3075,6 @@
@@ -3597,7 +3075,6 @@
"streams[].duration_seconds": "number",
"streams[].duration_seconds": "number",
"streams[].created_at": "string",
"streams[].created_at": "string",
"streams[].call_mode": "null",
"streams[].call_mode": "null",
- "streams[].control_config_id": "null",
"streams[].managed_stream_id": "number",
"streams[].managed_stream_id": "number",
"streams[].ai_overview": "null",
"streams[].ai_overview": "null",
"streams[].ai_overview_short": "null",
"streams[].ai_overview_short": "null",
@@ -3627,12 +3104,6 @@
@@ -3627,12 +3104,6 @@
"streams[].channel.updated_at": "string",
"streams[].channel.updated_at": "string",
"streams[].channel.weather_detail": "string",
"streams[].channel.weather_detail": "string",
"streams[].channel.weather_show_location": "number",
"streams[].channel.weather_show_location": "number",
- "streams[].channel.control_mode": "string",
- "streams[].channel.anon_controls_enabled": "number",
- "streams[].channel.control_rate_limit_ms": "number",
- "streams[].channel.active_control_config_id": "null",
- "streams[].channel.video_click_enabled": "number",
- "streams[].channel.video_click_rate_limit_ms": "number",
"streams[].channel.offline_screen_type": "string",
"streams[].channel.offline_screen_type": "string",
"streams[].channel.offline_screen_url": "null",
"streams[].channel.offline_screen_url": "null",
"streams[].channel.offline_html": "null",
"streams[].channel.offline_html": "null",
@@ -3708,12 +3179,6 @@
@@ -3708,12 +3179,6 @@
"weather_zip": "null",
"weather_zip": "null",
"weather_detail": "string",
"weather_detail": "string",
"weather_show_location": "number",
"weather_show_location": "number",
- "control_mode": "string",
- "anon_controls_enabled": "number",
- "control_rate_limit_ms": "number",
- "active_control_config_id": "null",
- "video_click_enabled": "number",
- "video_click_rate_limit_ms": "number",
"offline_screen_type": "string",
"offline_screen_type": "string",
"offline_screen_url": "null",
"offline_screen_url": "null",
"offline_html": "null",
"offline_html": "null",
@@ -3758,12 +3223,6 @@
@@ -3758,12 +3223,6 @@
"channel.updated_at": "string",
"channel.updated_at": "string",
"channel.weather_detail": "string",
"channel.weather_detail": "string",
"channel.weather_show_location": "number",
"channel.weather_show_location": "number",
- "channel.control_mode": "string",
- "channel.anon_controls_enabled": "number",
- "channel.control_rate_limit_ms": "number",
- "channel.active_control_config_id": "null",
- "channel.video_click_enabled": "number",
- "channel.video_click_rate_limit_ms": "number",
"channel.offline_screen_type": "string",
"channel.offline_screen_type": "string",
"channel.offline_screen_url": "null",
"channel.offline_screen_url": "null",
"channel.offline_html": "null",
"channel.offline_html": "null",
@@ -3774,6 +3233,17 @@
@@ -3774,6 +3233,17 @@
"channel.ai_category": "null",
"channel.ai_category": "null",
"channel.ai_derivation_enabled": "number",
"channel.ai_derivation_enabled": "number",
"channel.social_links": "array",
"channel.social_links": "array",
+ "channel.social_links[]": "object",
+ "channel.social_links[].kind": "string",
+ "channel.social_links[].url": "string",
+ "channel.social_links[].handle": "string",
+ "channel.social_links[].label": "string",
+ "channel.social_links[].preview": "boolean",
+ "channel.social_links[].name": "string",
+ "channel.social_links[].icon": "string",
+ "channel.social_links[].color": "string",
+ "channel.social_links[].auto": "boolean",
+ "channel.social_links[].hidden": "boolean",
"channel.username": "string",
"channel.username": "string",
"channel.display_name": "string",
"channel.display_name": "string",
"channel.avatar_url": "null",
"channel.avatar_url": "null",
@@ -3814,7 +3284,6 @@
@@ -3814,7 +3284,6 @@
"stream.duration_seconds": "number",
"stream.duration_seconds": "number",
"stream.created_at": "string",
"stream.created_at": "string",
"stream.call_mode": "null",
"stream.call_mode": "null",
- "stream.control_config_id": "null",
"stream.managed_stream_id": "number",
"stream.managed_stream_id": "number",
"stream.ai_overview": "null",
"stream.ai_overview": "null",
"stream.ai_overview_short": "null",
"stream.ai_overview_short": "null",
@@ -3851,7 +3320,6 @@
@@ -3851,7 +3320,6 @@
"streams[].duration_seconds": "number",
"streams[].duration_seconds": "number",
"streams[].created_at": "string",
"streams[].created_at": "string",
"streams[].call_mode": "null",
"streams[].call_mode": "null",
- "streams[].control_config_id": "null",
"streams[].managed_stream_id": "number",
"streams[].managed_stream_id": "number",
"streams[].ai_overview": "null",
"streams[].ai_overview": "null",
"streams[].ai_overview_short": "null",
"streams[].ai_overview_short": "null",
@@ -3879,8 +3347,6 @@
@@ -3879,8 +3347,6 @@
"managed_streams[].tags": "string",
"managed_streams[].tags": "string",
"managed_streams[].protocol": "string",
"managed_streams[].protocol": "string",
"managed_streams[].is_nsfw": "number",
"managed_streams[].is_nsfw": "number",
- "managed_streams[].control_config_id": "null",
- "managed_streams[].sort_order": "number",
"managed_streams[].created_at": "string",
"managed_streams[].created_at": "string",
"managed_streams[].updated_at": "string",
"managed_streams[].updated_at": "string",
"managed_streams[].streaming_method": "string",
"managed_streams[].streaming_method": "string",
@@ -3949,12 +3415,6 @@
@@ -3949,12 +3415,6 @@
"channel.updated_at": "string",
"channel.updated_at": "string",
"channel.weather_detail": "string",
"channel.weather_detail": "string",
"channel.weather_show_location": "number",
"channel.weather_show_location": "number",
- "channel.control_mode": "string",
- "channel.anon_controls_enabled": "number",
- "channel.control_rate_limit_ms": "number",
- "channel.active_control_config_id": "null",
- "channel.video_click_enabled": "number",
- "channel.video_click_rate_limit_ms": "number",
"channel.offline_screen_type": "string",
"channel.offline_screen_type": "string",
"channel.offline_screen_url": "null",
"channel.offline_screen_url": "null",
"channel.offline_html": "null",
"channel.offline_html": "null",
@@ -3965,6 +3425,17 @@
@@ -3965,6 +3425,17 @@
"channel.ai_category": "null",
"channel.ai_category": "null",
"channel.ai_derivation_enabled": "number",
"channel.ai_derivation_enabled": "number",
"channel.social_links": "array",
"channel.social_links": "array",
+ "channel.social_links[]": "object",
+ "channel.social_links[].kind": "string",
+ "channel.social_links[].url": "string",
+ "channel.social_links[].handle": "string",
+ "channel.social_links[].label": "string",
+ "channel.social_links[].preview": "boolean",
+ "channel.social_links[].name": "string",
+ "channel.social_links[].icon": "string",
+ "channel.social_links[].color": "string",
+ "channel.social_links[].auto": "boolean",
+ "channel.social_links[].hidden": "boolean",
"channel.username": "string",
"channel.username": "string",
"channel.display_name": "string",
"channel.display_name": "string",
"channel.avatar_url": "null",
"channel.avatar_url": "null",
@@ -3981,6 +3452,17 @@
@@ -3981,6 +3452,17 @@
"channel.viewer_can_edit_about": "boolean",
"channel.viewer_can_edit_about": "boolean",
"channel.social_links_editor": "object",
"channel.social_links_editor": "object",
"channel.social_links_editor.connected": "array",
"channel.social_links_editor.connected": "array",
+ "channel.social_links_editor.connected[]": "object",
+ "channel.social_links_editor.connected[].kind": "string",
+ "channel.social_links_editor.connected[].url": "string",
+ "channel.social_links_editor.connected[].handle": "string",
+ "channel.social_links_editor.connected[].label": "string",
+ "channel.social_links_editor.connected[].preview": "boolean",
+ "channel.social_links_editor.connected[].name": "string",
+ "channel.social_links_editor.connected[].icon": "string",
+ "channel.social_links_editor.connected[].color": "string",
+ "channel.social_links_editor.connected[].auto": "boolean",
+ "channel.social_links_editor.connected[].hidden": "boolean",
"channel.social_links_editor.restreams_without_link": "array",
"channel.social_links_editor.restreams_without_link": "array",
"channel.social_links_editor.hidden_auto": "array",
"channel.social_links_editor.hidden_auto": "array",
"channel.ai_overview": "null",
"channel.ai_overview": "null",
@@ -4009,7 +3491,6 @@
@@ -4009,7 +3491,6 @@
"stream.duration_seconds": "number",
"stream.duration_seconds": "number",
"stream.created_at": "string",
"stream.created_at": "string",
"stream.call_mode": "null",
"stream.call_mode": "null",
- "stream.control_config_id": "null",
"stream.managed_stream_id": "number",
"stream.managed_stream_id": "number",
"stream.ai_overview": "null",
"stream.ai_overview": "null",
"stream.ai_overview_short": "null",
"stream.ai_overview_short": "null",
@@ -4046,7 +3527,6 @@
@@ -4046,7 +3527,6 @@
"streams[].duration_seconds": "number",
"streams[].duration_seconds": "number",
"streams[].created_at": "string",
"streams[].created_at": "string",
"streams[].call_mode": "null",
"streams[].call_mode": "null",
- "streams[].control_config_id": "null",
"streams[].managed_stream_id": "number",
"streams[].managed_stream_id": "number",
"streams[].ai_overview": "null",
"streams[].ai_overview": "null",
"streams[].ai_overview_short": "null",
"streams[].ai_overview_short": "null",
@@ -4074,8 +3554,6 @@
@@ -4074,8 +3554,6 @@
"managed_streams[].tags": "string",
"managed_streams[].tags": "string",
"managed_streams[].protocol": "string",
"managed_streams[].protocol": "string",
"managed_streams[].is_nsfw": "number",
"managed_streams[].is_nsfw": "number",
- "managed_streams[].control_config_id": "null",
- "managed_streams[].sort_order": "number",
"managed_streams[].created_at": "string",
"managed_streams[].created_at": "string",
"managed_streams[].updated_at": "string",
"managed_streams[].updated_at": "string",
"managed_streams[].streaming_method": "string",
"managed_streams[].streaming_method": "string",
@@ -4144,12 +3622,6 @@
@@ -4144,12 +3622,6 @@
"channel.updated_at": "string",
"channel.updated_at": "string",
"channel.weather_detail": "string",
"channel.weather_detail": "string",
"channel.weather_show_location": "number",
"channel.weather_show_location": "number",
- "channel.control_mode": "string",
- "channel.anon_controls_enabled": "number",
- "channel.control_rate_limit_ms": "number",
- "channel.active_control_config_id": "null",
- "channel.video_click_enabled": "number",
- "channel.video_click_rate_limit_ms": "number",
"channel.offline_screen_type": "string",
"channel.offline_screen_type": "string",
"channel.offline_screen_url": "null",
"channel.offline_screen_url": "null",
"channel.offline_html": "null",
"channel.offline_html": "null",
@@ -4160,6 +3632,17 @@
@@ -4160,6 +3632,17 @@
"channel.ai_category": "null",
"channel.ai_category": "null",
"channel.ai_derivation_enabled": "number",
"channel.ai_derivation_enabled": "number",
"channel.social_links": "array",
"channel.social_links": "array",
+ "channel.social_links[]": "object",
+ "channel.social_links[].kind": "string",
+ "channel.social_links[].url": "string",
+ "channel.social_links[].handle": "string",
+ "channel.social_links[].label": "string",
+ "channel.social_links[].preview": "boolean",
+ "channel.social_links[].name": "string",
+ "channel.social_links[].icon": "string",
+ "channel.social_links[].color": "string",
+ "channel.social_links[].auto": "boolean",
+ "channel.social_links[].hidden": "boolean",
"channel.username": "string",
"channel.username": "string",
"channel.display_name": "string",
"channel.display_name": "string",
"channel.avatar_url": "null",
"channel.avatar_url": "null",
@@ -4200,7 +3683,6 @@
@@ -4200,7 +3683,6 @@
"stream.duration_seconds": "number",
"stream.duration_seconds": "number",
"stream.created_at": "string",
"stream.created_at": "string",
"stream.call_mode": "null",
"stream.call_mode": "null",
- "stream.control_config_id": "null",
"stream.managed_stream_id": "number",
"stream.managed_stream_id": "number",
"stream.ai_overview": "null",
"stream.ai_overview": "null",
"stream.ai_overview_short": "null",
"stream.ai_overview_short": "null",
@@ -4237,7 +3719,6 @@
@@ -4237,7 +3719,6 @@
"streams[].duration_seconds": "number",
"streams[].duration_seconds": "number",
"streams[].created_at": "string",
"streams[].created_at": "string",
"streams[].call_mode": "null",
"streams[].call_mode": "null",
- "streams[].control_config_id": "null",
"streams[].managed_stream_id": "number",
"streams[].managed_stream_id": "number",
"streams[].ai_overview": "null",
"streams[].ai_overview": "null",
"streams[].ai_overview_short": "null",
"streams[].ai_overview_short": "null",
@@ -4265,8 +3746,6 @@
@@ -4265,8 +3746,6 @@
"managed_streams[].tags": "string",
"managed_streams[].tags": "string",
"managed_streams[].protocol": "string",
"managed_streams[].protocol": "string",
"managed_streams[].is_nsfw": "number",
"managed_streams[].is_nsfw": "number",
- "managed_streams[].control_config_id": "null",
- "managed_streams[].sort_order": "number",
"managed_streams[].created_at": "string",
"managed_streams[].created_at": "string",
"managed_streams[].updated_at": "string",
"managed_streams[].updated_at": "string",
"managed_streams[].streaming_method": "string",
"managed_streams[].streaming_method": "string",
@@ -4335,12 +3814,6 @@
@@ -4335,12 +3814,6 @@
"channel.updated_at": "string",
"channel.updated_at": "string",
"channel.weather_detail": "string",
"channel.weather_detail": "string",
"channel.weather_show_location": "number",
"channel.weather_show_location": "number",
- "channel.control_mode": "string",
- "channel.anon_controls_enabled": "number",
- "channel.control_rate_limit_ms": "number",
- "channel.active_control_config_id": "null",
- "channel.video_click_enabled": "number",
- "channel.video_click_rate_limit_ms": "number",
"channel.offline_screen_type": "string",
"channel.offline_screen_type": "string",
"channel.offline_screen_url": "null",
"channel.offline_screen_url": "null",
"channel.offline_html": "null",
"channel.offline_html": "null",
@@ -4351,6 +3824,17 @@
@@ -4351,6 +3824,17 @@
"channel.ai_category": "null",
"channel.ai_category": "null",
"channel.ai_derivation_enabled": "number",
"channel.ai_derivation_enabled": "number",
"channel.social_links": "array",
"channel.social_links": "array",
+ "channel.social_links[]": "object",
+ "channel.social_links[].kind": "string",
+ "channel.social_links[].url": "string",
+ "channel.social_links[].handle": "string",
+ "channel.social_links[].label": "string",
+ "channel.social_links[].preview": "boolean",
+ "channel.social_links[].name": "string",
+ "channel.social_links[].icon": "string",
+ "channel.social_links[].color": "string",
+ "channel.social_links[].auto": "boolean",
+ "channel.social_links[].hidden": "boolean",
"channel.username": "string",
"channel.username": "string",
"channel.display_name": "string",
"channel.display_name": "string",
"channel.avatar_url": "null",
"channel.avatar_url": "null",
@@ -4367,6 +3851,17 @@
@@ -4367,6 +3851,17 @@
"channel.viewer_can_edit_about": "boolean",
"channel.viewer_can_edit_about": "boolean",
"channel.social_links_editor": "object",
"channel.social_links_editor": "object",
"channel.social_links_editor.connected": "array",
"channel.social_links_editor.connected": "array",
+ "channel.social_links_editor.connected[]": "object",
+ "channel.social_links_editor.connected[].kind": "string",
+ "channel.social_links_editor.connected[].url": "string",
+ "channel.social_links_editor.connected[].handle": "string",
+ "channel.social_links_editor.connected[].label": "string",
+ "channel.social_links_editor.connected[].preview": "boolean",
+ "channel.social_links_editor.connected[].name": "string",
+ "channel.social_links_editor.connected[].icon": "string",
+ "channel.social_links_editor.connected[].color": "string",
+ "channel.social_links_editor.connected[].auto": "boolean",
+ "channel.social_links_editor.connected[].hidden": "boolean",
"channel.social_links_editor.restreams_without_link": "array",
"channel.social_links_editor.restreams_without_link": "array",
"channel.social_links_editor.hidden_auto": "array",
"channel.social_links_editor.hidden_auto": "array",
"channel.ai_overview": "null",
"channel.ai_overview": "null",
@@ -4395,7 +3890,6 @@
@@ -4395,7 +3890,6 @@
"stream.duration_seconds": "number",
"stream.duration_seconds": "number",
"stream.created_at": "string",
"stream.created_at": "string",
"stream.call_mode": "null",
"stream.call_mode": "null",
- "stream.control_config_id": "null",
"stream.managed_stream_id": "number",
"stream.managed_stream_id": "number",
"stream.ai_overview": "null",
"stream.ai_overview": "null",
"stream.ai_overview_short": "null",
"stream.ai_overview_short": "null",
@@ -4432,7 +3926,6 @@
@@ -4432,7 +3926,6 @@
"streams[].duration_seconds": "number",
"streams[].duration_seconds": "number",
"streams[].created_at": "string",
"streams[].created_at": "string",
"streams[].call_mode": "null",
"streams[].call_mode": "null",
- "streams[].control_config_id": "null",
"streams[].managed_stream_id": "number",
"streams[].managed_stream_id": "number",
"streams[].ai_overview": "null",
"streams[].ai_overview": "null",
"streams[].ai_overview_short": "null",
"streams[].ai_overview_short": "null",
@@ -4460,8 +3953,6 @@
@@ -4460,8 +3953,6 @@
"managed_streams[].tags": "string",
"managed_streams[].tags": "string",
"managed_streams[].protocol": "string",
"managed_streams[].protocol": "string",
"managed_streams[].is_nsfw": "number",
"managed_streams[].is_nsfw": "number",
- "managed_streams[].control_config_id": "null",
- "managed_streams[].sort_order": "number",
"managed_streams[].created_at": "string",
"managed_streams[].created_at": "string",
"managed_streams[].updated_at": "string",
"managed_streams[].updated_at": "string",
"managed_streams[].streaming_method": "string",
"managed_streams[].streaming_method": "string",
@@ -4530,12 +4021,6 @@
@@ -4530,12 +4021,6 @@
"channel.updated_at": "string",
"channel.updated_at": "string",
"channel.weather_detail": "string",
"channel.weather_detail": "string",
"channel.weather_show_location": "number",
"channel.weather_show_location": "number",
- "channel.control_mode": "string",
- "channel.anon_controls_enabled": "number",
- "channel.control_rate_limit_ms": "number",
- "channel.active_control_config_id": "null",
- "channel.video_click_enabled": "number",
- "channel.video_click_rate_limit_ms": "number",
"channel.offline_screen_type": "string",
"channel.offline_screen_type": "string",
"channel.offline_screen_url": "null",
"channel.offline_screen_url": "null",
"channel.offline_html": "null",
"channel.offline_html": "null",
@@ -4546,6 +4031,17 @@
@@ -4546,6 +4031,17 @@
"channel.ai_category": "null",
"channel.ai_category": "null",
"channel.ai_derivation_enabled": "number",
"channel.ai_derivation_enabled": "number",
"channel.social_links": "array",
"channel.social_links": "array",
+ "channel.social_links[]": "object",
+ "channel.social_links[].kind": "string",
+ "channel.social_links[].url": "string",
+ "channel.social_links[].handle": "string",
+ "channel.social_links[].label": "string",
+ "channel.social_links[].preview": "boolean",
+ "channel.social_links[].name": "string",
+ "channel.social_links[].icon": "string",
+ "channel.social_links[].color": "string",
+ "channel.social_links[].auto": "boolean",
+ "channel.social_links[].hidden": "boolean",
"channel.username": "string",
"channel.username": "string",
"channel.display_name": "string",
"channel.display_name": "string",
"channel.avatar_url": "null",
"channel.avatar_url": "null",
@@ -4586,7 +4082,6 @@
@@ -4586,7 +4082,6 @@
"stream.duration_seconds": "number",
"stream.duration_seconds": "number",
"stream.created_at": "string",
"stream.created_at": "string",
"stream.call_mode": "null",
"stream.call_mode": "null",
- "stream.control_config_id": "null",
"stream.managed_stream_id": "number",
"stream.managed_stream_id": "number",
"stream.ai_overview": "null",
"stream.ai_overview": "null",
"stream.ai_overview_short": "null",
"stream.ai_overview_short": "null",
@@ -4623,7 +4118,6 @@
@@ -4623,7 +4118,6 @@
"streams[].duration_seconds": "number",
"streams[].duration_seconds": "number",
"streams[].created_at": "string",
"streams[].created_at": "string",
"streams[].call_mode": "null",
"streams[].call_mode": "null",
- "streams[].control_config_id": "null",
"streams[].managed_stream_id": "number",
"streams[].managed_stream_id": "number",
"streams[].ai_overview": "null",
"streams[].ai_overview": "null",
"streams[].ai_overview_short": "null",
"streams[].ai_overview_short": "null",
@@ -4651,8 +4145,6 @@
@@ -4651,8 +4145,6 @@
"managed_streams[].tags": "string",
"managed_streams[].tags": "string",
"managed_streams[].protocol": "string",
"managed_streams[].protocol": "string",
"managed_streams[].is_nsfw": "number",
"managed_streams[].is_nsfw": "number",
- "managed_streams[].control_config_id": "null",
- "managed_streams[].sort_order": "number",
"managed_streams[].created_at": "string",
"managed_streams[].created_at": "string",
"managed_streams[].updated_at": "string",
"managed_streams[].updated_at": "string",
"managed_streams[].streaming_method": "string",
"managed_streams[].streaming_method": "string",
@@ -4721,12 +4213,6 @@
@@ -4721,12 +4213,6 @@
"channel.updated_at": "string",
"channel.updated_at": "string",
"channel.weather_detail": "string",
"channel.weather_detail": "string",
"channel.weather_show_location": "number",
"channel.weather_show_location": "number",
- "channel.control_mode": "string",
- "channel.anon_controls_enabled": "number",
- "channel.control_rate_limit_ms": "number",
- "channel.active_control_config_id": "null",
- "channel.video_click_enabled": "number",
- "channel.video_click_rate_limit_ms": "number",
"channel.offline_screen_type": "string",
"channel.offline_screen_type": "string",
"channel.offline_screen_url": "null",
"channel.offline_screen_url": "null",
"channel.offline_html": "null",
"channel.offline_html": "null",
@@ -4737,6 +4223,17 @@
@@ -4737,6 +4223,17 @@
"channel.ai_category": "null",
"channel.ai_category": "null",
"channel.ai_derivation_enabled": "number",
"channel.ai_derivation_enabled": "number",
"channel.social_links": "array",
"channel.social_links": "array",
+ "channel.social_links[]": "object",
+ "channel.social_links[].kind": "string",
+ "channel.social_links[].url": "string",
+ "channel.social_links[].handle": "string",
+ "channel.social_links[].label": "string",
+ "channel.social_links[].preview": "boolean",
+ "channel.social_links[].name": "string",
+ "channel.social_links[].icon": "string",
+ "channel.social_links[].color": "string",
+ "channel.social_links[].auto": "boolean",
+ "channel.social_links[].hidden": "boolean",
"channel.username": "string",
"channel.username": "string",
"channel.display_name": "string",
"channel.display_name": "string",
"channel.avatar_url": "null",
"channel.avatar_url": "null",
@@ -4753,6 +4250,17 @@
@@ -4753,6 +4250,17 @@
"channel.viewer_can_edit_about": "boolean",
"channel.viewer_can_edit_about": "boolean",
"channel.social_links_editor": "object",
"channel.social_links_editor": "object",
"channel.social_links_editor.connected": "array",
"channel.social_links_editor.connected": "array",
+ "channel.social_links_editor.connected[]": "object",
+ "channel.social_links_editor.connected[].kind": "string",
+ "channel.social_links_editor.connected[].url": "string",
+ "channel.social_links_editor.connected[].handle": "string",
+ "channel.social_links_editor.connected[].label": "string",
+ "channel.social_links_editor.connected[].preview": "boolean",
+ "channel.social_links_editor.connected[].name": "string",
+ "channel.social_links_editor.connected[].icon": "string",
+ "channel.social_links_editor.connected[].color": "string",
+ "channel.social_links_editor.connected[].auto": "boolean",
+ "channel.social_links_editor.connected[].hidden": "boolean",
"channel.social_links_editor.restreams_without_link": "array",
"channel.social_links_editor.restreams_without_link": "array",
"channel.social_links_editor.hidden_auto": "array",
"channel.social_links_editor.hidden_auto": "array",
"channel.ai_overview": "null",
"channel.ai_overview": "null",
@@ -4781,7 +4289,6 @@
@@ -4781,7 +4289,6 @@
"stream.duration_seconds": "number",
"stream.duration_seconds": "number",
"stream.created_at": "string",
"stream.created_at": "string",
"stream.call_mode": "null",
"stream.call_mode": "null",
- "stream.control_config_id": "null",
"stream.managed_stream_id": "number",
"stream.managed_stream_id": "number",
"stream.ai_overview": "null",
"stream.ai_overview": "null",
"stream.ai_overview_short": "null",
"stream.ai_overview_short": "null",
@@ -4818,7 +4325,6 @@
@@ -4818,7 +4325,6 @@
"streams[].duration_seconds": "number",
"streams[].duration_seconds": "number",
"streams[].created_at": "string",
"streams[].created_at": "string",
"streams[].call_mode": "null",
"streams[].call_mode": "null",
- "streams[].control_config_id": "null",
"streams[].managed_stream_id": "number",
"streams[].managed_stream_id": "number",
"streams[].ai_overview": "null",
"streams[].ai_overview": "null",
"streams[].ai_overview_short": "null",
"streams[].ai_overview_short": "null",
@@ -4846,8 +4352,6 @@
@@ -4846,8 +4352,6 @@
"managed_streams[].tags": "string",
"managed_streams[].tags": "string",
"managed_streams[].protocol": "string",
"managed_streams[].protocol": "string",
"managed_streams[].is_nsfw": "number",
"managed_streams[].is_nsfw": "number",
- "managed_streams[].control_config_id": "null",
- "managed_streams[].sort_order": "number",
"managed_streams[].created_at": "string",
"managed_streams[].created_at": "string",
"managed_streams[].updated_at": "string",
"managed_streams[].updated_at": "string",
"managed_streams[].streaming_method": "string",
"managed_streams[].streaming_method": "string",
@@ -4920,7 +4424,6 @@
@@ -4920,7 +4424,6 @@
"streams[].duration_seconds": "number",
"streams[].duration_seconds": "number",
"streams[].created_at": "string",
"streams[].created_at": "string",
"streams[].call_mode": "null",
"streams[].call_mode": "null",
- "streams[].control_config_id": "null",
"streams[].managed_stream_id": "number",
"streams[].managed_stream_id": "number",
"streams[].ai_overview": "null",
"streams[].ai_overview": "null",
"streams[].ai_overview_short": "null",
"streams[].ai_overview_short": "null",
@@ -4971,7 +4474,6 @@
@@ -4971,7 +4474,6 @@
"streams[].duration_seconds": "number",
"streams[].duration_seconds": "number",
"streams[].created_at": "string",
"streams[].created_at": "string",
"streams[].call_mode": "null",
"streams[].call_mode": "null",
- "streams[].control_config_id": "null",
"streams[].managed_stream_id": "number",
"streams[].managed_stream_id": "number",
"streams[].ai_overview": "null",
"streams[].ai_overview": "null",
"streams[].ai_overview_short": "null",
"streams[].ai_overview_short": "null",
@@ -5018,12 +4520,6 @@
@@ -5018,12 +4520,6 @@
"channel.updated_at": "string",
"channel.updated_at": "string",
"channel.weather_detail": "string",
"channel.weather_detail": "string",
"channel.weather_show_location": "number",
"channel.weather_show_location": "number",
- "channel.control_mode": "string",
- "channel.anon_controls_enabled": "number",
- "channel.control_rate_limit_ms": "number",
- "channel.active_control_config_id": "null",
- "channel.video_click_enabled": "number",
- "channel.video_click_rate_limit_ms": "number",
"channel.offline_screen_type": "string",
"channel.offline_screen_type": "string",
"channel.offline_screen_url": "null",
"channel.offline_screen_url": "null",
"channel.offline_html": "null",
"channel.offline_html": "null",
@@ -5034,6 +4530,17 @@
@@ -5034,6 +4530,17 @@
"channel.ai_category": "null",
"channel.ai_category": "null",
"channel.ai_derivation_enabled": "number",
"channel.ai_derivation_enabled": "number",
"channel.social_links": "array",
"channel.social_links": "array",
+ "channel.social_links[]": "object",
+ "channel.social_links[].kind": "string",
+ "channel.social_links[].url": "string",
+ "channel.social_links[].handle": "string",
+ "channel.social_links[].label": "string",
+ "channel.social_links[].preview": "boolean",
+ "channel.social_links[].name": "string",
+ "channel.social_links[].icon": "string",
+ "channel.social_links[].color": "string",
+ "channel.social_links[].auto": "boolean",
+ "channel.social_links[].hidden": "boolean",
"channel.username": "string",
"channel.username": "string",
"channel.display_name": "string",
"channel.display_name": "string",
"channel.avatar_url": "null",
"channel.avatar_url": "null",
@@ -5074,7 +4581,6 @@
@@ -5074,7 +4581,6 @@
"stream.duration_seconds": "number",
"stream.duration_seconds": "number",
"stream.created_at": "string",
"stream.created_at": "string",
"stream.call_mode": "null",
"stream.call_mode": "null",
- "stream.control_config_id": "null",
"stream.managed_stream_id": "number",
"stream.managed_stream_id": "number",
"stream.ai_overview": "null",
"stream.ai_overview": "null",
"stream.ai_overview_short": "null",
"stream.ai_overview_short": "null",
@@ -5111,7 +4617,6 @@
@@ -5111,7 +4617,6 @@
"streams[].duration_seconds": "number",
"streams[].duration_seconds": "number",
"streams[].created_at": "string",
"streams[].created_at": "string",
"streams[].call_mode": "null",
"streams[].call_mode": "null",
- "streams[].control_config_id": "null",
"streams[].managed_stream_id": "number",
"streams[].managed_stream_id": "number",
"streams[].ai_overview": "null",
"streams[].ai_overview": "null",
"streams[].ai_overview_short": "null",
"streams[].ai_overview_short": "null",
@@ -5139,8 +4644,6 @@
@@ -5139,8 +4644,6 @@
"managed_streams[].tags": "string",
"managed_streams[].tags": "string",
"managed_streams[].protocol": "string",
"managed_streams[].protocol": "string",
"managed_streams[].is_nsfw": "number",
"managed_streams[].is_nsfw": "number",
- "managed_streams[].control_config_id": "null",
- "managed_streams[].sort_order": "number",
"managed_streams[].created_at": "string",
"managed_streams[].created_at": "string",
"managed_streams[].updated_at": "string",
"managed_streams[].updated_at": "string",
"managed_streams[].streaming_method": "string",
"managed_streams[].streaming_method": "string",
@@ -5209,12 +4712,6 @@
@@ -5209,12 +4712,6 @@
"channel.updated_at": "string",
"channel.updated_at": "string",
"channel.weather_detail": "string",
"channel.weather_detail": "string",
"channel.weather_show_location": "number",
"channel.weather_show_location": "number",
- "channel.control_mode": "string",
- "channel.anon_controls_enabled": "number",
- "channel.control_rate_limit_ms": "number",
- "channel.active_control_config_id": "null",
- "channel.video_click_enabled": "number",
- "channel.video_click_rate_limit_ms": "number",
"channel.offline_screen_type": "string",
"channel.offline_screen_type": "string",
"channel.offline_screen_url": "null",
"channel.offline_screen_url": "null",
"channel.offline_html": "null",
"channel.offline_html": "null",
@@ -5225,6 +4722,17 @@
@@ -5225,6 +4722,17 @@
"channel.ai_category": "null",
"channel.ai_category": "null",
"channel.ai_derivation_enabled": "number",
"channel.ai_derivation_enabled": "number",
"channel.social_links": "array",
"channel.social_links": "array",
+ "channel.social_links[]": "object",
+ "channel.social_links[].kind": "string",
+ "channel.social_links[].url": "string",
+ "channel.social_links[].handle": "string",
+ "channel.social_links[].label": "string",
+ "channel.social_links[].preview": "boolean",
+ "channel.social_links[].name": "string",
+ "channel.social_links[].icon": "string",
+ "channel.social_links[].color": "string",
+ "channel.social_links[].auto": "boolean",
+ "channel.social_links[].hidden": "boolean",
"channel.username": "string",
"channel.username": "string",
"channel.display_name": "string",
"channel.display_name": "string",
"channel.avatar_url": "null",
"channel.avatar_url": "null",
@@ -5241,6 +4749,17 @@
@@ -5241,6 +4749,17 @@
"channel.viewer_can_edit_about": "boolean",
"channel.viewer_can_edit_about": "boolean",
"channel.social_links_editor": "object",
"channel.social_links_editor": "object",
"channel.social_links_editor.connected": "array",
"channel.social_links_editor.connected": "array",
+ "channel.social_links_editor.connected[]": "object",
+ "channel.social_links_editor.connected[].kind": "string",
+ "channel.social_links_editor.connected[].url": "string",
+ "channel.social_links_editor.connected[].handle": "string",
+ "channel.social_links_editor.connected[].label": "string",
+ "channel.social_links_editor.connected[].preview": "boolean",
+ "channel.social_links_editor.connected[].name": "string",
+ "channel.social_links_editor.connected[].icon": "string",
+ "channel.social_links_editor.connected[].color": "string",
+ "channel.social_links_editor.connected[].auto": "boolean",
+ "channel.social_links_editor.connected[].hidden": "boolean",
"channel.social_links_editor.restreams_without_link": "array",
"channel.social_links_editor.restreams_without_link": "array",
"channel.social_links_editor.hidden_auto": "array",
"channel.social_links_editor.hidden_auto": "array",
"channel.ai_overview": "null",
"channel.ai_overview": "null",
@@ -5269,7 +4788,6 @@
@@ -5269,7 +4788,6 @@
"stream.duration_seconds": "number",
"stream.duration_seconds": "number",
"stream.created_at": "string",
"stream.created_at": "string",
"stream.call_mode": "null",
"stream.call_mode": "null",
- "stream.control_config_id": "null",
"stream.managed_stream_id": "number",
"stream.managed_stream_id": "number",
"stream.ai_overview": "null",
"stream.ai_overview": "null",
"stream.ai_overview_short": "null",
"stream.ai_overview_short": "null",
@@ -5306,7 +4824,6 @@
@@ -5306,7 +4824,6 @@
"streams[].duration_seconds": "number",
"streams[].duration_seconds": "number",
"streams[].created_at": "string",
"streams[].created_at": "string",
"streams[].call_mode": "null",
"streams[].call_mode": "null",
- "streams[].control_config_id": "null",
"streams[].managed_stream_id": "number",
"streams[].managed_stream_id": "number",
"streams[].ai_overview": "null",
"streams[].ai_overview": "null",
"streams[].ai_overview_short": "null",
"streams[].ai_overview_short": "null",
@@ -5334,8 +4851,6 @@
@@ -5334,8 +4851,6 @@
"managed_streams[].tags": "string",
"managed_streams[].tags": "string",
"managed_streams[].protocol": "string",
"managed_streams[].protocol": "string",
"managed_streams[].is_nsfw": "number",
"managed_streams[].is_nsfw": "number",
- "managed_streams[].control_config_id": "null",
- "managed_streams[].sort_order": "number",
"managed_streams[].created_at": "string",
"managed_streams[].created_at": "string",
"managed_streams[].updated_at": "string",
"managed_streams[].updated_at": "string",
"managed_streams[].streaming_method": "string",
"managed_streams[].streaming_method": "string",
@@ -5404,12 +4919,6 @@
@@ -5404,12 +4919,6 @@
"channel.updated_at": "string",
"channel.updated_at": "string",
"channel.weather_detail": "string",
"channel.weather_detail": "string",
"channel.weather_show_location": "number",
"channel.weather_show_location": "number",
- "channel.control_mode": "string",
- "channel.anon_controls_enabled": "number",
- "channel.control_rate_limit_ms": "number",
- "channel.active_control_config_id": "null",
- "channel.video_click_enabled": "number",
- "channel.video_click_rate_limit_ms": "number",
"channel.offline_screen_type": "string",
"channel.offline_screen_type": "string",
"channel.offline_screen_url": "null",
"channel.offline_screen_url": "null",
"channel.offline_html": "null",
"channel.offline_html": "null",
@@ -5420,6 +4929,17 @@
@@ -5420,6 +4929,17 @@
"channel.ai_category": "null",
"channel.ai_category": "null",
"channel.ai_derivation_enabled": "number",
"channel.ai_derivation_enabled": "number",
"channel.social_links": "array",
"channel.social_links": "array",
+ "channel.social_links[]": "object",
+ "channel.social_links[].kind": "string",
+ "channel.social_links[].url": "string",
+ "channel.social_links[].handle": "string",
+ "channel.social_links[].label": "string",
+ "channel.social_links[].preview": "boolean",
+ "channel.social_links[].name": "string",
+ "channel.social_links[].icon": "string",
+ "channel.social_links[].color": "string",
+ "channel.social_links[].auto": "boolean",
+ "channel.social_links[].hidden": "boolean",
"channel.username": "string",
"channel.username": "string",
"channel.display_name": "string",
"channel.display_name": "string",
"channel.avatar_url": "null",
"channel.avatar_url": "null",
@@ -5460,7 +4980,6 @@
@@ -5460,7 +4980,6 @@
"stream.duration_seconds": "number",
"stream.duration_seconds": "number",
"stream.created_at": "string",
"stream.created_at": "string",
"stream.call_mode": "null",
"stream.call_mode": "null",
- "stream.control_config_id": "null",
"stream.managed_stream_id": "number",
"stream.managed_stream_id": "number",
"stream.ai_overview": "null",
"stream.ai_overview": "null",
"stream.ai_overview_short": "null",
"stream.ai_overview_short": "null",
@@ -5497,7 +5016,6 @@
@@ -5497,7 +5016,6 @@
"streams[].duration_seconds": "number",
"streams[].duration_seconds": "number",
"streams[].created_at": "string",
"streams[].created_at": "string",
"streams[].call_mode": "null",
"streams[].call_mode": "null",
- "streams[].control_config_id": "null",
"streams[].managed_stream_id": "number",
"streams[].managed_stream_id": "number",
"streams[].ai_overview": "null",
"streams[].ai_overview": "null",
"streams[].ai_overview_short": "null",
"streams[].ai_overview_short": "null",
@@ -5525,8 +5043,6 @@
@@ -5525,8 +5043,6 @@
"managed_streams[].tags": "string",
"managed_streams[].tags": "string",
"managed_streams[].protocol": "string",
"managed_streams[].protocol": "string",
"managed_streams[].is_nsfw": "number",
"managed_streams[].is_nsfw": "number",
- "managed_streams[].control_config_id": "null",
- "managed_streams[].sort_order": "number",
"managed_streams[].created_at": "string",
"managed_streams[].created_at": "string",
"managed_streams[].updated_at": "string",
"managed_streams[].updated_at": "string",
"managed_streams[].streaming_method": "string",
"managed_streams[].streaming_method": "string",
@@ -5595,12 +5111,6 @@
@@ -5595,12 +5111,6 @@
"channel.updated_at": "string",
"channel.updated_at": "string",
"channel.weather_detail": "string",
"channel.weather_detail": "string",
"channel.weather_show_location": "number",
"channel.weather_show_location": "number",
- "channel.control_mode": "string",
- "channel.anon_controls_enabled": "number",
- "channel.control_rate_limit_ms": "number",
- "channel.active_control_config_id": "null",
- "channel.video_click_enabled": "number",
- "channel.video_click_rate_limit_ms": "number",
"channel.offline_screen_type": "string",
"channel.offline_screen_type": "string",
"channel.offline_screen_url": "null",
"channel.offline_screen_url": "null",
"channel.offline_html": "null",
"channel.offline_html": "null",
@@ -5611,6 +5121,17 @@
@@ -5611,6 +5121,17 @@
"channel.ai_category": "null",
"channel.ai_category": "null",
"channel.ai_derivation_enabled": "number",
"channel.ai_derivation_enabled": "number",
"channel.social_links": "array",
"channel.social_links": "array",
+ "channel.social_links[]": "object",
+ "channel.social_links[].kind": "string",
+ "channel.social_links[].url": "string",
+ "channel.social_links[].handle": "string",
+ "channel.social_links[].label": "string",
+ "channel.social_links[].preview": "boolean",
+ "channel.social_links[].name": "string",
+ "channel.social_links[].icon": "string",
+ "channel.social_links[].color": "string",
+ "channel.social_links[].auto": "boolean",
+ "channel.social_links[].hidden": "boolean",
"channel.username": "string",
"channel.username": "string",
"channel.display_name": "string",
"channel.display_name": "string",
"channel.avatar_url": "null",
"channel.avatar_url": "null",
@@ -5627,6 +5148,17 @@
@@ -5627,6 +5148,17 @@
"channel.viewer_can_edit_about": "boolean",
"channel.viewer_can_edit_about": "boolean",
"channel.social_links_editor": "object",
"channel.social_links_editor": "object",
"channel.social_links_editor.connected": "array",
"channel.social_links_editor.connected": "array",
+ "channel.social_links_editor.connected[]": "object",
+ "channel.social_links_editor.connected[].kind": "string",
+ "channel.social_links_editor.connected[].url": "string",
+ "channel.social_links_editor.connected[].handle": "string",
+ "channel.social_links_editor.connected[].label": "string",
+ "channel.social_links_editor.connected[].preview": "boolean",
+ "channel.social_links_editor.connected[].name": "string",
+ "channel.social_links_editor.connected[].icon": "string",
+ "channel.social_links_editor.connected[].color": "string",
+ "channel.social_links_editor.connected[].auto": "boolean",
+ "channel.social_links_editor.connected[].hidden": "boolean",
"channel.social_links_editor.restreams_without_link": "array",
"channel.social_links_editor.restreams_without_link": "array",
"channel.social_links_editor.hidden_auto": "array",
"channel.social_links_editor.hidden_auto": "array",
"channel.ai_overview": "null",
"channel.ai_overview": "null",
@@ -5655,7 +5187,6 @@
@@ -5655,7 +5187,6 @@
"stream.duration_seconds": "number",
"stream.duration_seconds": "number",
"stream.created_at": "string",
"stream.created_at": "string",
"stream.call_mode": "null",
"stream.call_mode": "null",
- "stream.control_config_id": "null",
"stream.managed_stream_id": "number",
"stream.managed_stream_id": "number",
"stream.ai_overview": "null",
"stream.ai_overview": "null",
"stream.ai_overview_short": "null",
"stream.ai_overview_short": "null",
@@ -5692,7 +5223,6 @@
@@ -5692,7 +5223,6 @@
"streams[].duration_seconds": "number",
"streams[].duration_seconds": "number",
"streams[].created_at": "string",
"streams[].created_at": "string",
"streams[].call_mode": "null",
"streams[].call_mode": "null",
- "streams[].control_config_id": "null",
"streams[].managed_stream_id": "number",
"streams[].managed_stream_id": "number",
"streams[].ai_overview": "null",
"streams[].ai_overview": "null",
"streams[].ai_overview_short": "null",
"streams[].ai_overview_short": "null",
@@ -5720,8 +5250,6 @@
@@ -5720,8 +5250,6 @@
"managed_streams[].tags": "string",
"managed_streams[].tags": "string",
"managed_streams[].protocol": "string",
"managed_streams[].protocol": "string",
"managed_streams[].is_nsfw": "number",
"managed_streams[].is_nsfw": "number",
- "managed_streams[].control_config_id": "null",
- "managed_streams[].sort_order": "number",
"managed_streams[].created_at": "string",
"managed_streams[].created_at": "string",
"managed_streams[].updated_at": "string",
"managed_streams[].updated_at": "string",
"managed_streams[].streaming_method": "string",
"managed_streams[].streaming_method": "string",
@@ -5938,8 +5466,6 @@
@@ -5938,8 +5466,6 @@
"managed_streams[].protocol": "string",
"managed_streams[].protocol": "string",
"managed_streams[].stream_key": "string",
"managed_streams[].stream_key": "string",
"managed_streams[].is_nsfw": "number",
"managed_streams[].is_nsfw": "number",
- "managed_streams[].control_config_id": "null",
- "managed_streams[].sort_order": "number",
"managed_streams[].created_at": "string",
"managed_streams[].created_at": "string",
"managed_streams[].updated_at": "string",
"managed_streams[].updated_at": "string",
"managed_streams[].broadcast_settings": "string",
"managed_streams[].broadcast_settings": "string",
@@ -5996,8 +5522,6 @@
@@ -5996,8 +5522,6 @@
"managed_stream.tags": "string",
"managed_stream.tags": "string",
"managed_stream.protocol": "string",
"managed_stream.protocol": "string",
"managed_stream.is_nsfw": "number",
"managed_stream.is_nsfw": "number",
- "managed_stream.control_config_id": "null",
- "managed_stream.sort_order": "number",
"managed_stream.created_at": "string",
"managed_stream.created_at": "string",
"managed_stream.updated_at": "string",
"managed_stream.updated_at": "string",
"managed_stream.broadcast_settings": "string",
"managed_stream.broadcast_settings": "string",
@@ -6104,7 +5628,6 @@
@@ -6104,7 +5628,6 @@
"streams[].duration_seconds": "number",
"streams[].duration_seconds": "number",
"streams[].created_at": "string",
"streams[].created_at": "string",
"streams[].call_mode": "null",
"streams[].call_mode": "null",
- "streams[].control_config_id": "null",
"streams[].managed_stream_id": "number",
"streams[].managed_stream_id": "number",
"streams[].ai_overview": "null",
"streams[].ai_overview": "null",
"streams[].ai_overview_short": "null",
"streams[].ai_overview_short": "null",
@@ -6181,6 +5704,12 @@
@@ -6181,6 +5704,12 @@
"streamers[].managed_streams[].protocol": "string",
"streamers[].managed_streams[].protocol": "string",
"streamers[].managed_streams[].last_live_at": "string",
"streamers[].managed_streams[].last_live_at": "string",
"streamers[].managed_streams[].vod_thumbnail": "null",
"streamers[].managed_streams[].vod_thumbnail": "null",
+ "streamers[].managed_streams[].is_live": "boolean",
+ "streamers[].managed_streams[].live_started_at": "string",
+ "streamers[].live": "object",
+ "streamers[].live.started_at": "string",
+ "streamers[].live.viewer_count": "number",
+ "streamers[].live.title": "string",
"total": "number",
"total": "number",
"limit": "number",
"limit": "number",
"offset": "number",
"offset": "number",
@@ -6215,6 +5744,12 @@
@@ -6215,6 +5744,12 @@
"streamers[].managed_streams[].protocol": "string",
"streamers[].managed_streams[].protocol": "string",
"streamers[].managed_streams[].last_live_at": "string",
"streamers[].managed_streams[].last_live_at": "string",
"streamers[].managed_streams[].vod_thumbnail": "null",
"streamers[].managed_streams[].vod_thumbnail": "null",
+ "streamers[].managed_streams[].is_live": "boolean",
+ "streamers[].managed_streams[].live_started_at": "string",
+ "streamers[].live": "object",
+ "streamers[].live.started_at": "string",
+ "streamers[].live.viewer_count": "number",
+ "streamers[].live.title": "string",
"total": "number",
"total": "number",
"limit": "number",
"limit": "number",
"offset": "number",
"offset": "number",
@@ -6265,52 +5800,6 @@
@@ -6265,52 +5800,6 @@
"public/js/guides-live.js"
"public/js/guides-live.js"
]
]
},
},
- {
- "method": "GET",
- "path": "/api/streams/voice-channels",
- "auth": "anon",
- "status": 200,
- "kind": "json",
- "reads": {
- "channels": "array",
- "channels[]": "object",
- "channels[].id": "string",
- "channels[].name": "string",
- "channels[].mode": "string",
- "channels[].createdBy": "null",
- "channels[].streamId": "null",
- "channels[].permanent": "boolean",
- "channels[].maxParticipants": "number",
- "channels[].participantCount": "number",
- "channels[].participants": "array"
- },
- "from": [
- "public/js/voice-channels.js"
- ]
- },
- {
- "method": "GET",
- "path": "/api/streams/voice-channels",
- "auth": "user",
- "status": 200,
- "kind": "json",
- "reads": {
- "channels": "array",
- "channels[]": "object",
- "channels[].id": "string",
- "channels[].name": "string",
- "channels[].mode": "string",
- "channels[].createdBy": "null",
- "channels[].streamId": "null",
- "channels[].permanent": "boolean",
- "channels[].maxParticipants": "number",
- "channels[].participantCount": "number",
- "channels[].participants": "array"
- },
- "from": [
- "public/js/voice-channels.js"
- ]
- },
{
{
"method": "GET",
"method": "GET",
"path": "/api/streams/1",
"path": "/api/streams/1",
@@ -6338,7 +5827,6 @@
@@ -6338,7 +5827,6 @@
"stream.duration_seconds": "number",
"stream.duration_seconds": "number",
"stream.created_at": "string",
"stream.created_at": "string",
"stream.call_mode": "null",
"stream.call_mode": "null",
- "stream.control_config_id": "null",
"stream.managed_stream_id": "number",
"stream.managed_stream_id": "number",
"stream.ai_overview": "null",
"stream.ai_overview": "null",
"stream.ai_overview_short": "null",
"stream.ai_overview_short": "null",
@@ -6369,12 +5857,6 @@
@@ -6369,12 +5857,6 @@
"stream.channel.updated_at": "string",
"stream.channel.updated_at": "string",
"stream.channel.weather_detail": "string",
"stream.channel.weather_detail": "string",
"stream.channel.weather_show_location": "number",
"stream.channel.weather_show_location": "number",
- "stream.channel.control_mode": "string",
- "stream.channel.anon_controls_enabled": "number",
- "stream.channel.control_rate_limit_ms": "number",
- "stream.channel.active_control_config_id": "null",
- "stream.channel.video_click_enabled": "number",
- "stream.channel.video_click_rate_limit_ms": "number",
"stream.channel.offline_screen_type": "string",
"stream.channel.offline_screen_type": "string",
"stream.channel.offline_screen_url": "null",
"stream.channel.offline_screen_url": "null",
"stream.channel.offline_html": "null",
"stream.channel.offline_html": "null",
@@ -6388,7 +5870,7 @@
@@ -6388,7 +5870,7 @@
"stream.pip_overlay": "null"
"stream.pip_overlay": "null"
},
},
"from": [
"from": [
- "public/js/app-media.js"
+ "public/popout-chat.html"
]
]
},
},
{
{
@@ -6418,7 +5900,6 @@
@@ -6418,7 +5900,6 @@
"stream.duration_seconds": "number",
"stream.duration_seconds": "number",
"stream.created_at": "string",
"stream.created_at": "string",
"stream.call_mode": "null",
"stream.call_mode": "null",
- "stream.control_config_id": "null",
"stream.managed_stream_id": "number",
"stream.managed_stream_id": "number",
"stream.ai_overview": "null",
"stream.ai_overview": "null",
"stream.ai_overview_short": "null",
"stream.ai_overview_short": "null",
@@ -6449,12 +5930,6 @@
@@ -6449,12 +5930,6 @@
"stream.channel.updated_at": "string",
"stream.channel.updated_at": "string",
"stream.channel.weather_detail": "string",
"stream.channel.weather_detail": "string",
"stream.channel.weather_show_location": "number",
"stream.channel.weather_show_location": "number",
- "stream.channel.control_mode": "string",
- "stream.channel.anon_controls_enabled": "number",
- "stream.channel.control_rate_limit_ms": "number",
- "stream.channel.active_control_config_id": "null",
- "stream.channel.video_click_enabled": "number",
- "stream.channel.video_click_rate_limit_ms": "number",
"stream.channel.offline_screen_type": "string",
"stream.channel.offline_screen_type": "string",
"stream.channel.offline_screen_url": "null",
"stream.channel.offline_screen_url": "null",
"stream.channel.offline_html": "null",
"stream.channel.offline_html": "null",
@@ -6468,39 +5943,7 @@
@@ -6468,39 +5943,7 @@
"stream.pip_overlay": "null"
"stream.pip_overlay": "null"
},
},
"from": [
"from": [
- "public/js/app-media.js"
- ]
- },
- {
- "method": "GET",
- "path": "/api/streams/1/call",
- "auth": "anon",
- "status": 200,
- "kind": "json",
- "reads": {
- "call_mode": "null",
- "channelId": "null",
- "participants": "array",
- "participant_count": "number"
- },
- "from": [
- "public/js/broadcast.js"
- ]
- },
- {
- "method": "GET",
- "path": "/api/streams/1/call",
- "auth": "user",
- "status": 200,
- "kind": "json",
- "reads": {
- "call_mode": "null",
- "channelId": "null",
- "participants": "array",
- "participant_count": "number"
- },
- "from": [
- "public/js/broadcast.js"
+ "public/popout-chat.html"
]
]
},
},
{
{
@@ -6650,100 +6093,40 @@
@@ -6650,100 +6093,40 @@
"method": "GET",
"method": "GET",
"path": "/api/vods/stream/1/live",
"path": "/api/vods/stream/1/live",
"auth": "anon",
"auth": "anon",
- "status": 404,
- "kind": "json",
- "from": [
- "public/js/app-channel.js"
- ]
- },
- {
- "method": "GET",
- "path": "/api/vods/stream/1/live",
- "auth": "user",
- "status": 404,
- "kind": "json",
- "from": [
- "public/js/app-channel.js"
- ]
- },
- {
- "method": "GET",
- "path": "/api/vods/1/context",
- "auth": "anon",
- "status": 404,
- "kind": "json",
- "from": [
- "public/js/context-cards.js"
- ]
- },
- {
- "method": "GET",
- "path": "/api/vods/1/context",
- "auth": "user",
- "status": 404,
- "kind": "json",
- "from": [
- "public/js/context-cards.js"
- ]
- },
- {
- "method": "GET",
- "path": "/api/vods/1/live-info",
- "auth": "anon",
- "status": 502,
- "kind": "json",
- "from": [
- "public/js/app-media.js"
- ]
- },
- {
- "method": "GET",
- "path": "/api/vods/1/live-info",
- "auth": "user",
- "status": 502,
- "kind": "json",
- "from": [
- "public/js/app-media.js"
- ]
- },
- {
- "method": "GET",
- "path": "/api/vods/1",
- "auth": "anon",
- "status": 502,
+ "status": 404,
"kind": "json",
"kind": "json",
"from": [
"from": [
- "public/js/app-media.js"
+ "public/js/app-channel.js"
]
]
},
},
{
{
"method": "GET",
"method": "GET",
- "path": "/api/vods/1",
+ "path": "/api/vods/stream/1/live",
"auth": "user",
"auth": "user",
- "status": 502,
+ "status": 404,
"kind": "json",
"kind": "json",
"from": [
"from": [
- "public/js/app-media.js"
+ "public/js/app-channel.js"
]
]
},
},
{
{
"method": "GET",
"method": "GET",
- "path": "/api/vods/1/memories",
+ "path": "/api/vods/1/live-info",
"auth": "anon",
"auth": "anon",
"status": 502,
"status": 502,
"kind": "json",
"kind": "json",
"from": [
"from": [
- "public/js/app-media.js"
+ "public/js/stream-player.js"
]
]
},
},
{
{
"method": "GET",
"method": "GET",
- "path": "/api/vods/1/memories",
+ "path": "/api/vods/1/live-info",
"auth": "user",
"auth": "user",
"status": 502,
"status": 502,
"kind": "json",
"kind": "json",
"from": [
"from": [
- "public/js/app-media.js"
+ "public/js/stream-player.js"
]
]
},
},
{
{
@@ -6798,7 +6181,18 @@
@@ -6798,7 +6181,18 @@
},
},
{
{
"method": "GET",
"method": "GET",
- "path": "/css/style.css?v=57784f84656e",
+ "path": "/css/style.core.css?v=39879bee1919",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "text",
+ "from": [
+ "page /"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/css/style.css?v=a417682204dd",
"auth": "anon",
"auth": "anon",
"accept": "html",
"accept": "html",
"status": 200,
"status": 200,
@@ -6809,7 +6203,7 @@
@@ -6809,7 +6203,7 @@
},
},
{
{
"method": "GET",
"method": "GET",
- "path": "/css/features/home.css?v=1da8ab368854",
+ "path": "/css/features/home.css?v=faa8fa066557",
"auth": "anon",
"auth": "anon",
"accept": "html",
"accept": "html",
"status": 200,
"status": 200,
@@ -6820,7 +6214,7 @@
@@ -6820,7 +6214,7 @@
},
},
{
{
"method": "GET",
"method": "GET",
- "path": "/css/home-fx.css?v=8f135af68e76",
+ "path": "/css/home-fx.css?v=e663894c29e3",
"auth": "anon",
"auth": "anon",
"accept": "html",
"accept": "html",
"status": 200,
"status": 200,
@@ -6842,7 +6236,7 @@
@@ -6842,7 +6236,7 @@
},
},
{
{
"method": "GET",
"method": "GET",
- "path": "/css/i18n-star.css?v=365567ee3c2e",
+ "path": "/css/i18n-star.css?v=c5b0b2261c10",
"auth": "anon",
"auth": "anon",
"accept": "html",
"accept": "html",
"status": 200,
"status": 200,
@@ -6886,7 +6280,7 @@
@@ -6886,7 +6280,7 @@
},
},
{
{
"method": "GET",
"method": "GET",
- "path": "/css/cosmetics.css?v=73e2adf188e1",
+ "path": "/css/cosmetics.css?v=5d33ae6cb97b",
"auth": "anon",
"auth": "anon",
"accept": "html",
"accept": "html",
"status": 200,
"status": 200,
@@ -6897,7 +6291,7 @@
@@ -6897,7 +6291,7 @@
},
},
{
{
"method": "GET",
"method": "GET",
- "path": "/css/messenger.css?v=8d3312c1f857",
+ "path": "/css/messenger.css?v=b546de06782d",
"auth": "anon",
"auth": "anon",
"accept": "html",
"accept": "html",
"status": 200,
"status": 200,
@@ -7117,7 +6511,7 @@
@@ -7117,7 +6511,7 @@
},
},
{
{
"method": "GET",
"method": "GET",
- "path": "/shared/navbar.js?v=555cc6d59b78",
+ "path": "/shared/navbar.js?v=70456901fbfe",
"auth": "anon",
"auth": "anon",
"accept": "html",
"accept": "html",
"status": 200,
"status": 200,
@@ -7128,7 +6522,7 @@
@@ -7128,7 +6522,7 @@
},
},
{
{
"method": "GET",
"method": "GET",
- "path": "/js/app.js?v=a34333556690",
+ "path": "/js/app.js?v=3dd6d6052d5b",
"auth": "anon",
"auth": "anon",
"accept": "html",
"accept": "html",
"status": 200,
"status": 200,
@@ -7139,7 +6533,7 @@
@@ -7139,7 +6533,7 @@
},
},
{
{
"method": "GET",
"method": "GET",
- "path": "/js/ov-navbar-live.js?v=67c0ab5cc8f1",
+ "path": "/js/ov-navbar-live.js?v=d54491455927",
"auth": "anon",
"auth": "anon",
"accept": "html",
"accept": "html",
"status": 200,
"status": 200,
@@ -7150,7 +6544,7 @@
@@ -7150,7 +6544,7 @@
},
},
{
{
"method": "GET",
"method": "GET",
- "path": "/js/chat-shared.js?v=bd4b18592b1a",
+ "path": "/js/chat-shared.js?v=045f792bf138",
"auth": "anon",
"auth": "anon",
"accept": "html",
"accept": "html",
"status": 200,
"status": 200,
@@ -7172,7 +6566,7 @@
@@ -7172,7 +6566,7 @@
},
},
{
{
"method": "GET",
"method": "GET",
- "path": "/js/chat.js?v=214e3c481b2f",
+ "path": "/js/chat.js?v=aede89fcff0c",
"auth": "anon",
"auth": "anon",
"accept": "html",
"accept": "html",
"status": 200,
"status": 200,
@@ -7183,7 +6577,7 @@
@@ -7183,7 +6577,7 @@
},
},
{
{
"method": "GET",
"method": "GET",
- "path": "/js/vibes.js?v=aef505d77f34",
+ "path": "/js/vibes.js?v=636a00a18df2",
"auth": "anon",
"auth": "anon",
"accept": "html",
"accept": "html",
"status": 200,
"status": 200,
@@ -7227,7 +6621,7 @@
@@ -7227,7 +6621,7 @@
},
},
{
{
"method": "GET",
"method": "GET",
- "path": "/shared/footer.js?v=9f69d9ce17b9",
+ "path": "/shared/footer.js?v=885f7fd5835b",
"auth": "anon",
"auth": "anon",
"accept": "html",
"accept": "html",
"status": 200,
"status": 200,
@@ -7238,7 +6632,7 @@
@@ -7238,7 +6632,7 @@
},
},
{
{
"method": "GET",
"method": "GET",
- "path": "/js/footer-live.js?v=a10429d40713",
+ "path": "/js/footer-live.js?v=02da2af6b629",
"auth": "anon",
"auth": "anon",
"accept": "html",
"accept": "html",
"status": 200,
"status": 200,
@@ -7271,7 +6665,7 @@
@@ -7271,7 +6665,7 @@
},
},
{
{
"method": "GET",
"method": "GET",
- "path": "/shared/notification-ui.js?v=a889be53ea64",
+ "path": "/shared/notification-ui.js?v=5eb3ef9fa337",
"auth": "anon",
"auth": "anon",
"accept": "html",
"accept": "html",
"status": 200,
"status": 200,
@@ -7304,7 +6698,7 @@
@@ -7304,7 +6698,7 @@
},
},
{
{
"method": "GET",
"method": "GET",
- "path": "/js/app-home.js?v=1dc431184ccc",
+ "path": "/js/app-home.js?v=63d6e4a54fd8",
"auth": "anon",
"auth": "anon",
"accept": "html",
"accept": "html",
"status": 200,
"status": 200,
@@ -7359,7 +6753,7 @@
@@ -7359,7 +6753,7 @@
},
},
{
{
"method": "GET",
"method": "GET",
- "path": "/css/features/channel.css?v=63269f14afd5",
+ "path": "/css/features/channel.css?v=f41f8d4b31d8",
"auth": "anon",
"auth": "anon",
"accept": "html",
"accept": "html",
"status": 200,
"status": 200,
@@ -7370,7 +6764,7 @@
@@ -7370,7 +6764,7 @@
},
},
{
{
"method": "GET",
"method": "GET",
- "path": "/css/context-cards.css?v=f2468014839c",
+ "path": "/css/context-cards.css?v=07823a1b5eb5",
"auth": "anon",
"auth": "anon",
"accept": "html",
"accept": "html",
"status": 200,
"status": 200,
@@ -7392,7 +6786,7 @@
@@ -7392,7 +6786,7 @@
},
},
{
{
"method": "GET",
"method": "GET",
- "path": "/js/stream-player.js?v=fe62065746fd",
+ "path": "/js/stream-player.js?v=00262645004f",
"auth": "anon",
"auth": "anon",
"accept": "html",
"accept": "html",
"status": 200,
"status": 200,
@@ -7414,7 +6808,7 @@
@@ -7414,7 +6808,7 @@
},
},
{
{
"method": "GET",
"method": "GET",
- "path": "/js/controls.js?v=04c5242890ee",
+ "path": "/js/context-cards.js?v=c5fd219fa47f",
"auth": "anon",
"auth": "anon",
"accept": "html",
"accept": "html",
"status": 200,
"status": 200,
@@ -7425,7 +6819,7 @@
@@ -7425,7 +6819,7 @@
},
},
{
{
"method": "GET",
"method": "GET",
- "path": "/js/context-cards.js?v=edb4f671e62d",
+ "path": "/js/emotes.js?v=346f6a37bfb3",
"auth": "anon",
"auth": "anon",
"accept": "html",
"accept": "html",
"status": 200,
"status": 200,
@@ -7436,7 +6830,7 @@
@@ -7436,7 +6830,7 @@
},
},
{
{
"method": "GET",
"method": "GET",
- "path": "/js/emotes.js?v=0099deffc3c0",
+ "path": "/js/gif-picker.js?v=fde186491f4e",
"auth": "anon",
"auth": "anon",
"accept": "html",
"accept": "html",
"status": 200,
"status": 200,
@@ -7447,7 +6841,7 @@
@@ -7447,7 +6841,7 @@
},
},
{
{
"method": "GET",
"method": "GET",
- "path": "/js/gif-picker.js?v=5ee269f4e9c1",
+ "path": "/js/chat-autocomplete.js?v=b726f226680f",
"auth": "anon",
"auth": "anon",
"accept": "html",
"accept": "html",
"status": 200,
"status": 200,
@@ -7458,7 +6852,7 @@
@@ -7458,7 +6852,7 @@
},
},
{
{
"method": "GET",
"method": "GET",
- "path": "/js/chat-autocomplete.js?v=b726f226680f",
+ "path": "/js/channel-uploads.js?v=5e4c26112cbf",
"auth": "anon",
"auth": "anon",
"accept": "html",
"accept": "html",
"status": 200,
"status": 200,
@@ -7469,7 +6863,7 @@
@@ -7469,7 +6863,7 @@
},
},
{
{
"method": "GET",
"method": "GET",
- "path": "/js/channel-uploads.js?v=5e4c26112cbf",
+ "path": "/js/channel-socials.js?v=4444b3116395",
"auth": "anon",
"auth": "anon",
"accept": "html",
"accept": "html",
"status": 200,
"status": 200,
@@ -7480,7 +6874,7 @@
@@ -7480,7 +6874,7 @@
},
},
{
{
"method": "GET",
"method": "GET",
- "path": "/js/channel-socials.js?v=983c9f5760d8",
+ "path": "/js/channel-chat-resize.js?v=3dfe687e2a68",
"auth": "anon",
"auth": "anon",
"accept": "html",
"accept": "html",
"status": 200,
"status": 200,
@@ -7491,7 +6885,7 @@
@@ -7491,7 +6885,7 @@
},
},
{
{
"method": "GET",
"method": "GET",
- "path": "/js/channel-chat-resize.js?v=3dfe687e2a68",
+ "path": "/js/channel-support.js?v=744f82e1cdc6",
"auth": "anon",
"auth": "anon",
"accept": "html",
"accept": "html",
"status": 200,
"status": 200,
@@ -7502,7 +6896,7 @@
@@ -7502,7 +6896,7 @@
},
},
{
{
"method": "GET",
"method": "GET",
- "path": "/js/app-channel.js?v=024dbdf78e62",
+ "path": "/js/app-channel.js?v=5aa423964298",
"auth": "anon",
"auth": "anon",
"accept": "html",
"accept": "html",
"status": 200,
"status": 200,
@@ -7601,16 +6995,6 @@
@@ -7601,16 +6995,6 @@
"public/js/ai-viewers.js"
"public/js/ai-viewers.js"
]
]
},
},
- {
- "method": "POST",
- "path": "/api/arena/beefs/1/hype",
- "auth": "anon",
- "status": 400,
- "kind": "json",
- "from": [
- "public/js/arena.js"
- ]
- },
{
{
"method": "POST",
"method": "POST",
"path": "/api/auth/avatar",
"path": "/api/auth/avatar",
@@ -7728,20 +7112,9 @@
@@ -7728,20 +7112,9 @@
"status": 401,
"status": 401,
"kind": "json",
"kind": "json",
"from": [
"from": [
- "public/js/app-media.js",
"public/js/dashboard.js"
"public/js/dashboard.js"
]
]
},
},
- {
- "method": "POST",
- "path": "/api/clips/1/recut",
- "auth": "anon",
- "status": 401,
- "kind": "json",
- "from": [
- "public/js/app-media.js"
- ]
- },
{
{
"method": "PUT",
"method": "PUT",
"path": "/api/clips/1/title",
"path": "/api/clips/1/title",
@@ -7749,7 +7122,6 @@
@@ -7749,7 +7122,6 @@
"status": 401,
"status": 401,
"kind": "json",
"kind": "json",
"from": [
"from": [
- "public/js/app-media.js",
"public/js/stream-player.js"
"public/js/stream-player.js"
]
]
},
},
@@ -7760,7 +7132,6 @@
@@ -7760,7 +7132,6 @@
"status": 401,
"status": 401,
"kind": "json",
"kind": "json",
"from": [
"from": [
- "public/js/app-media.js",
"public/js/dashboard.js"
"public/js/dashboard.js"
]
]
},
},
@@ -7844,136 +7215,6 @@
@@ -7844,136 +7215,6 @@
"public/js/opencoins.js"
"public/js/opencoins.js"
]
]
},
},
- {
- "method": "DELETE",
- "path": "/api/comments/1",
- "auth": "anon",
- "status": 401,
- "kind": "json",
- "from": [
- "public/js/app-media.js"
- ]
- },
- {
- "method": "PUT",
- "path": "/api/comments/1",
- "auth": "anon",
- "status": 401,
- "kind": "json",
- "from": [
- "public/js/app-media.js"
- ]
- },
- {
- "method": "POST",
- "path": "/api/comments/1/1",
- "auth": "anon",
- "status": 401,
- "kind": "json",
- "from": [
- "public/js/app-media.js"
- ]
- },
- {
- "method": "POST",
- "path": "/api/controls/configs",
- "auth": "anon",
- "status": 401,
- "kind": "json",
- "from": [
- "public/js/dashboard.js"
- ]
- },
- {
- "method": "POST",
- "path": "/api/controls/configs/deactivate",
- "auth": "anon",
- "status": 401,
- "kind": "json",
- "from": [
- "public/js/dashboard.js"
- ]
- },
- {
- "method": "DELETE",
- "path": "/api/controls/configs/1",
- "auth": "anon",
- "status": 401,
- "kind": "json",
- "from": [
- "public/js/dashboard.js"
- ]
- },
- {
- "method": "POST",
- "path": "/api/controls/configs/1/activate",
- "auth": "anon",
- "status": 401,
- "kind": "json",
- "from": [
- "public/js/dashboard.js"
- ]
- },
- {
- "method": "DELETE",
- "path": "/api/controls/configs/1/buttons/1",
- "auth": "anon",
- "status": 401,
- "kind": "json",
- "from": [
- "public/js/dashboard.js"
- ]
- },
- {
- "method": "POST",
- "path": "/api/controls/configs/1/buttons",
- "auth": "anon",
- "status": 401,
- "kind": "json",
- "from": [
- "public/js/dashboard.js"
- ]
- },
- {
- "method": "PUT",
- "path": "/api/controls/settings/channel",
- "auth": "anon",
- "status": 401,
- "kind": "json",
- "from": [
- "public/js/dashboard.js"
- ]
- },
- {
- "method": "POST",
- "path": "/api/controls/whitelist",
- "auth": "anon",
- "status": 401,
- "kind": "json",
- "from": [
- "public/js/dashboard.js"
- ]
- },
- {
- "method": "DELETE",
- "path": "/api/controls/whitelist/1",
- "auth": "anon",
- "status": 401,
- "kind": "json",
- "from": [
- "public/js/dashboard.js"
- ]
- },
- {
- "method": "PUT",
- "path": "/api/controls/1/config",
- "auth": "anon",
- "status": 401,
- "kind": "json",
- "from": [
- "public/js/broadcast.js"
- ]
- },
{
{
"method": "POST",
"method": "POST",
"path": "/api/cosmetics/equip",
"path": "/api/cosmetics/equip",
@@ -8322,66 +7563,6 @@
@@ -8322,66 +7563,6 @@
"public/js/broadcast.js"
"public/js/broadcast.js"
]
]
},
},
- {
- "method": "POST",
- "path": "/api/onvif/cameras",
- "auth": "anon",
- "status": 401,
- "kind": "json",
- "from": [
- "public/js/onvif-dashboard.js"
- ]
- },
- {
- "method": "DELETE",
- "path": "/api/onvif/cameras/1",
- "auth": "anon",
- "status": 401,
- "kind": "json",
- "from": [
- "public/js/onvif-dashboard.js"
- ]
- },
- {
- "method": "PUT",
- "path": "/api/onvif/cameras/1",
- "auth": "anon",
- "status": 401,
- "kind": "json",
- "from": [
- "public/js/onvif-dashboard.js"
- ]
- },
- {
- "method": "POST",
- "path": "/api/onvif/cameras/1/presets",
- "auth": "anon",
- "status": 401,
- "kind": "json",
- "from": [
- "public/js/onvif-dashboard.js"
- ]
- },
- {
- "method": "DELETE",
- "path": "/api/onvif/cameras/1/presets/1",
- "auth": "anon",
- "status": 401,
- "kind": "json",
- "from": [
- "public/js/onvif-dashboard.js"
- ]
- },
- {
- "method": "POST",
- "path": "/api/onvif/discover",
- "auth": "anon",
- "status": 401,
- "kind": "json",
- "from": [
- "public/js/onvif-dashboard.js"
- ]
- },
{
{
"method": "POST",
"method": "POST",
"path": "/api/pastes",
"path": "/api/pastes",
@@ -8668,6 +7849,36 @@
@@ -8668,6 +7849,36 @@
"public/js/broadcast.js"
"public/js/broadcast.js"
]
]
},
},
+ {
+ "method": "POST",
+ "path": "/api/rum",
+ "auth": "anon",
+ "status": 204,
+ "kind": "empty",
+ "from": [
+ "public/js/ov-rum.js"
+ ]
+ },
+ {
+ "method": "PUT",
+ "path": "/api/rundown/opt-out",
+ "auth": "anon",
+ "status": 401,
+ "kind": "json",
+ "from": [
+ "public/js/rundown.js"
+ ]
+ },
+ {
+ "method": "POST",
+ "path": "/api/rundown/react",
+ "auth": "anon",
+ "status": 400,
+ "kind": "json",
+ "from": [
+ "public/js/rundown.js"
+ ]
+ },
{
{
"method": "POST",
"method": "POST",
"path": "/api/streams",
"path": "/api/streams",
@@ -8804,36 +8015,6 @@
@@ -8804,36 +8015,6 @@
"public/js/app-channel.js"
"public/js/app-channel.js"
]
]
},
},
- {
- "method": "POST",
- "path": "/api/streams/voice-channels",
- "auth": "anon",
- "status": 401,
- "kind": "json",
- "from": [
- "public/js/voice-channels.js"
- ]
- },
- {
- "method": "POST",
- "path": "/api/streams/voice-channels/call-user",
- "auth": "anon",
- "status": 401,
- "kind": "json",
- "from": [
- "public/js/chat.js"
- ]
- },
- {
- "method": "POST",
- "path": "/api/streams/voice-channels/call-user/respond",
- "auth": "anon",
- "status": 401,
- "kind": "json",
- "from": [
- "public/js/chat.js"
- ]
- },
{
{
"method": "DELETE",
"method": "DELETE",
"path": "/api/streams/1",
"path": "/api/streams/1",
@@ -8901,7 +8082,6 @@
@@ -8901,7 +8082,6 @@
"status": 401,
"status": 401,
"kind": "json",
"kind": "json",
"from": [
"from": [
- "public/js/app-media.js",
"public/js/broadcast.js",
"public/js/broadcast.js",
"public/js/stream-player.js"
"public/js/stream-player.js"
]
]
@@ -8953,7 +8133,6 @@
@@ -8953,7 +8133,6 @@
"status": 401,
"status": 401,
"kind": "json",
"kind": "json",
"from": [
"from": [
- "public/js/app-media.js",
"public/js/dashboard.js"
"public/js/dashboard.js"
]
]
},
},
@@ -8964,7 +8143,6 @@
@@ -8964,7 +8143,6 @@
"status": 401,
"status": 401,
"kind": "json",
"kind": "json",
"from": [
"from": [
- "public/js/app-media.js",
"public/js/dashboard.js"
"public/js/dashboard.js"
]
]
},
},
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
{
{
"service": "live",
"service": "live",
- "release": "81dc337dc7ae261ccc498f5894241f1f0e9cc52f",
- "recorded_at": "2026-10-08T18:28:45.009Z",
+ "release": "f0fed5be6d1b25cfcabb1a3f3e2db347dfea88fb",
+ "recorded_at": "2026-10-19T23:30:31.918Z",
"note": "N-1 fixture: written by `npm run n-1:record`, replayed by test/n-1.test.js",
"note": "N-1 fixture: written by `npm run n-1:record`, replayed by test/n-1.test.js",
"engine": "postgresql",
"engine": "postgresql",
"migrations": [
"migrations": [
@@ -12,6 +12,38 @@
@@ -12,6 +12,38 @@
{
{
"name": "0002_live.sql",
"name": "0002_live.sql",
"sha256": "616fa09e397160ca847d72ac7f3d91824cf4c92d9318ea55b91d49501bc5dc59"
"sha256": "616fa09e397160ca847d72ac7f3d91824cf4c92d9318ea55b91d49501bc5dc59"
+ },
+ {
+ "name": "0003_users_identity_expand.sql",
+ "sha256": "57d14f58a8f1e139b9892b393c65c9e79c54f7079fbcc5bb530032acb8a2683e"
+ },
+ {
+ "name": "0004_comment_thread_refs_numeric_optional.sql",
+ "sha256": "6ec5f068b21d67ddb55134656c0d873d944b4a4bf7dab64729d04faec27d2f19"
+ },
+ {
+ "name": "0005_drop_live_cosmetics.sql",
+ "sha256": "3a1653ace7041e159358522dedfb46c91cfcdf9bb451046609bb89848ac4e351"
+ },
+ {
+ "name": "0006_telemetry.sql",
+ "sha256": "aadd653d2480dbb3264bd0f4898b9560bdf3160cd7b86eae854f910213613b69"
+ },
+ {
+ "name": "0007_setup_tasks.sql",
+ "sha256": "e14a4931df19751ec09dc65731abef52de6ced2704f4b3cfb618e14ec9354e93"
+ },
+ {
+ "name": "0008_arena_drama.sql",
+ "sha256": "d45a7c18d61e56c7c4cd3414e3846ec7b94ecf245dcf89c3b51d573ccf3e8ead"
+ },
+ {
+ "name": "0009_channel_tips.sql",
+ "sha256": "20352293553d3edbe49535cd48cc3f9a00eeda0bddf011f06e7ddd772c1e65be"
+ },
+ {
+ "name": "0010_rundown.sql",
+ "sha256": "7477f41b9d41a067f03adb79a5c198721ef88910774d8a1f52fefce8d8ddc251"
}
}
],
],
"statements": []
"statements": []
@@ -82,11 +82,10 @@ const ANN = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPA';
@@ -82,11 +82,10 @@ const ANN = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPA';
assert.strictEqual(await live.scan({ now: now + 5 * 60000 }), 0, 'the next scan starts where this one ended');
assert.strictEqual(await live.scan({ now: now + 5 * 60000 }), 0, 'the next scan starts where this one ended');
assert.strictEqual(await live.refresh({ now: now + DAY }), 1, 'the daily refresh visits everyone with recent streams or followers');
assert.strictEqual(await live.refresh({ now: now + DAY }), 1, 'the daily refresh visits everyone with recent streams or followers');
- // ── live.loyalty (contracts 0.56.0, WS-K task 9): channel points and the Arena level, private ──
+ // ── live.loyalty (contracts 0.56.0, WS-K task 9): channel points, private (the Arena's level went with the Arena) ──
await d.prepare("INSERT INTO channel_points (user_id, streamer_id, balance, updated_at) VALUES (3, 1, 300, ?), (3, 2, 50, ?), (3, 3, 0, ?)").run(at(DAY), at(DAY), at(DAY));
await d.prepare("INSERT INTO channel_points (user_id, streamer_id, balance, updated_at) VALUES (3, 1, 300, ?), (3, 2, 50, ?), (3, 3, 0, ?)").run(at(DAY), at(DAY), at(DAY));
- await d.prepare('INSERT INTO arena_trash_levels (user_id, xp, level) VALUES (3, 450, 3)').run();
- assert.deepStrictEqual(await live.loyaltyOf(3), { channel_points_total: 350, channels: [{ channel: 'ann', points: 300 }, { channel: 'bob', points: 50 }], arena_level: 3, arena_xp: 450 });
- assert.strictEqual(await live.loyaltyOf(2), null, 'no points and no Arena: nothing');
+ assert.deepStrictEqual(await live.loyaltyOf(3), { channel_points_total: 350, channels: [{ channel: 'ann', points: 300 }, { channel: 'bob', points: 50 }] });
+ assert.strictEqual(await live.loyaltyOf(2), null, 'no points: nothing');
const before = puts.length;
const before = puts.length;
assert.deepStrictEqual(await live.push(3, { now }), ['live.loyalty'], 'a viewer with points gets only the loyalty record');
assert.deepStrictEqual(await live.push(3, { now }), ['live.loyalty'], 'a viewer with points gets only the loyalty record');
const loyal = puts[before];
const loyal = puts[before];
@@ -101,7 +100,6 @@ const ANN = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPA';
@@ -101,7 +100,6 @@ const ANN = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPA';
assert.strictEqual(puts[puts.length - 1].data.channel_points_total, 360);
assert.strictEqual(puts[puts.length - 1].data.channel_points_total, 360);
// The scan picks up changed points; points gone after a write leave zeros, not a stale record.
// The scan picks up changed points; points gone after a write leave zeros, not a stale record.
await d.prepare('UPDATE channel_points SET balance = 0, updated_at = ? WHERE user_id = 3').run(at(-10 * 60000)); // after the previous scan's window
await d.prepare('UPDATE channel_points SET balance = 0, updated_at = ? WHERE user_id = 3').run(at(-10 * 60000)); // after the previous scan's window
- await d.prepare('DELETE FROM arena_trash_levels WHERE user_id = 3').run();
assert.deepStrictEqual(await live.loyaltyOf(3), { channel_points_total: 0, channels: [] });
assert.deepStrictEqual(await live.loyaltyOf(3), { channel_points_total: 0, channels: [] });
assert.ok((await live.scan({ now: now + 45 * 60000 })) >= 1, 'the scan finds the changed points');
assert.ok((await live.scan({ now: now + 45 * 60000 })) >= 1, 'the scan finds the changed points');
assert.strictEqual(puts[puts.length - 1].data.channel_points_total, 0);
assert.strictEqual(puts[puts.length - 1].data.channel_points_total, 0);
new file mode 100644
new file mode 100644
@@ -0,0 +1,57 @@
@@ -0,0 +1,57 @@
+'use strict';
+/**
+ * The Rundown's chat commands (server/rundown/chat-commands.js): the Arena's three, which Chat still forwards.
+ * !hype, !beef beefs are gone: a pointer to The Rundown
+ * !arena [user] the streamer's latest episode and topics, a streamer who opted out, nobody by that name
+ * other commands not ours (the Arena's !topic, !bounty and !board are gone)
+ */
+const assert = require('assert');
+const db = require('../server/db/database');
+
+(async () => {
+await db.initDb();
+const chat = require('../server/rundown/chat-commands');
+const read = require('../server/rundown/read');
+
+const mk = async (username) => {
+ await db.createUser({ username, email: `${username}@x`, password_hash: 'x', display_name: username[0].toUpperCase() + username.slice(1), 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 nova = await mk('nova'), quiet = await mk('quietguy'), viewer = await mk('viewer');
+const liveId = Number((await db.createStream({ user_id: nova, title: 'Night', category: 'irl', protocol: 'rtmp' })).lastInsertRowid);
+const ended = Number((await db.createStream({ user_id: nova, title: 'Fridges', category: 'irl', protocol: 'rtmp' })).lastInsertRowid);
+await db.run("UPDATE streams SET is_live = 0, ended_at = ov_now() WHERE id = ?", [ended]);
+const topic = (await db.run("INSERT INTO rundown_topics (slug, title, kind, first_seen_at, last_seen_at) VALUES ('fridges', 'Fridges', 'tech', ov_now(), ov_now()) RETURNING id")).lastInsertRowid;
+await db.run("INSERT INTO rundown_segments (stream_id, user_id, topic_id, start_sec, end_sec, title, what, score, said_at) VALUES (?, ?, ?, 1, 9, 'Fridge take', 'nova on fridges.', 4, ov_now())", [ended, nova, topic]);
+await db.run("INSERT INTO rundown_episodes (stream_id, user_id, title, recap, chapters_json, aired_at) VALUES (?, ?, 'The Fridge Files', 'x', '[]', ov_now())", [ended, nova]);
+
+const sent = [];
+let pending = [];
+// track() is how a caller that answers once waits for the command's replies (Chat's arena-command call).
+const fakeChat = { sendTo: (ws, m) => sent.push(m.message), track: (p) => pending.push(p) };
+const run = async (client, line) => { const parts = line.split(' '); const handled = await chat.handle(fakeChat, {}, client, parts[0], parts); await Promise.all(pending); pending = []; return handled; };
+
+for (const [n, cmd] of [['b1', '!topic anything'], ['b2', '!bounty nova'], ['b3', '!board']]) assert.strictEqual(await run({ anonId: n, streamId: liveId }, cmd), false, `${cmd.split(' ')[0]} is not ours`);
+assert.strictEqual(await run({ user: { id: viewer, username: 'viewer' }, streamId: liveId, ip: '1.1.1.1' }, '!hype'), true);
+assert.match(sent.pop(), /Beefs are gone.*\/rundown$/);
+await run({ anonId: 'zz', streamId: liveId }, '!beef');
+assert.match(sent.pop(), /Beefs are gone/);
+await run({ anonId: 'zy', streamId: liveId }, '!arena');
+assert.match(sent.pop(), /^📰 Nova — latest episode: “The Fridge Files” · talks about Fridges → .*\/rundown\/nova$/, 'the streamer whose chat it is');
+await run({ anonId: 'zx', streamId: liveId }, '!arena quietguy');
+assert.match(sent.pop(), /Nothing on The Rundown about Quietguy yet/);
+await run({ anonId: 'zw', streamId: liveId }, '!arena nobody_here');
+assert.match(sent.pop(), /Nobody called nobody_here here/);
+await read.setOptOut(nova, true);
+await run({ anonId: 'zv', streamId: liveId }, '!arena');
+assert.match(sent.pop(), /Nova keeps themselves out of The Rundown/);
+await run({ anonId: 'zv', streamId: liveId }, '!arena');
+assert.match(sent.pop(), /one command every few seconds/, 'one command every few seconds per chatter');
+assert.ok(quiet);
+console.log('✅ chat: !hype / !beef point to The Rundown; !arena answers with the streamer\'s Rundown');
+
+console.log('rundown chat: ok');
+process.exit(0);
+})().catch((err) => { console.error(err); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,67 @@
@@ -0,0 +1,67 @@
+'use strict';
+// The Rundown's voice (server/rundown/voice.js, was the Arena's): one synthesis per (voice, text), cached on disk; equipped cosmetic voice wins; limits.
+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-voice-'));
+process.env.TTS_CACHE_PATH = path.join(tmp, 'tts-cache');
+const db = require('../server/db/database');
+const voice = require('../server/rundown/voice');
+let calls = [];
+voice._setSynth(async (v, text, username) => { calls.push({ sig: v.sig, text, username }); return { audio: Buffer.from(`AUDIO:${text}`).toString('base64'), mimeType: 'audio/mpeg', engine: 'espeak-ng', voiceId: 'auto:gary' }; });
+
+(async () => {
+ await db.initDb();
+ await db.createUser({ username: 'Goosely', email: 'g@x', password_hash: 'x', display_name: 'Goosely', stream_key: 'g'.repeat(32) });
+ const user = await db.get('SELECT * FROM users WHERE username = ?', ['Goosely']);
+ const v = await voice.voiceFor(user);
+ assert.strictEqual(v.kind, 'auto'); assert.strictEqual(v.identityKey, 'user:goosely', 'same identity key chat uses');
+ assert.strictEqual((await voice.voiceFor(null)).kind, 'announcer');
+ const a = await voice.speak({ user, text: ' you think your chat is loud? ' });
+ assert.strictEqual(a.cached, false); assert.strictEqual(a.mimeType, 'audio/mpeg'); assert.ok(fs.existsSync(a.path));
+ assert.strictEqual(fs.readFileSync(a.path, 'utf8'), 'AUDIO:you think your chat is loud?', 'text is normalised before synthesis');
+ const b = await voice.speak({ user, text: 'you think your chat is loud?' });
+ assert.strictEqual(b.cached, true); assert.strictEqual(b.path, a.path); assert.strictEqual(calls.length, 1, 'second click never re-synthesizes');
+ const c = await voice.speak({ user: null, text: 'you think your chat is loud?' });
+ assert.notStrictEqual(c.path, a.path, 'same text in another voice is another file'); assert.strictEqual(calls.length, 2);
+ assert.strictEqual((await db.get(`SELECT COUNT(*) AS n FROM ai_usage WHERE kind = 'tts'`)).n, 2, 'every synthesis is metered');
+ await assert.rejects(voice.speak({ user, text: 'x' }), /Nothing to say/);
+ await db.setSetting('arena_voice_daily_max', '2');
+ await assert.rejects(voice.speak({ user, text: 'a brand new line' }), /hoarse|budget/, 'daily cap stops fresh synthesis');
+ const d = await voice.speak({ user, text: 'you think your chat is loud?' });
+ assert.strictEqual(d.cached, true, 'cache hits still work past the cap');
+ console.log('✅ voice: cached per (voice, text), metered, capped');
+
+ let ok = 0; for (let i = 0; i < 12; i++) if (voice.allow('1.2.3.4', false)) ok++;
+ assert.strictEqual(ok, 8, 'anonymous: 8 per minute'); assert.ok(voice.allow('1.2.3.4', true) === false || true);
+ let ok2 = 0; for (let i = 0; i < 40; i++) if (voice.allow('5.6.7.8', true)) ok2++;
+ assert.strictEqual(ok2, 30, 'signed in: 30 per minute');
+ console.log('✅ voice: per-IP limits');
+
+ // HTTP: streams the file with a week of caching; announcer works without a user.
+ const express = require('express'); const app = express(); app.use('/api/rundown', require('../server/rundown/routes'));
+ const srv = await new Promise(r => { const s = app.listen(0, () => r(s)); });
+ await db.setSetting('arena_voice_daily_max', '100');
+ const res = await fetch(`http://127.0.0.1:${srv.address().port}/api/rundown/voice/goosely?t=${encodeURIComponent('you think your chat is loud?')}`);
+ assert.strictEqual(res.status, 200); assert.strictEqual(res.headers.get('content-type'), 'audio/mpeg'); assert.ok(/max-age=604800/.test(res.headers.get('cache-control'))); assert.strictEqual(res.headers.get('x-cache'), 'HIT');
+ assert.strictEqual((await res.text()), 'AUDIO:you think your chat is loud?');
+ assert.strictEqual((await fetch(`http://127.0.0.1:${srv.address().port}/api/rundown/voice/nobody?t=hi`)).status, 404);
+ assert.strictEqual((await fetch(`http://127.0.0.1:${srv.address().port}/api/rundown/voice/goosely?t=`)).status, 400);
+ srv.close();
+ console.log('✅ voice: HTTP streaming + caching headers');
+
+ // One-off clips (admin Test Voice / mod preview) get a same-origin URL from the same cache —
+ // no blob:/data: needed in the browser, and the filename is traversal-proof.
+ const st = voice.stash(Buffer.from('RIFFfakewav').toString('base64'), 'audio/wav');
+ assert.ok(/^[a-f0-9]{32}\.wav$/.test(st.file) && fs.existsSync(path.join(voice.CACHE_DIR, st.file)));
+ assert.strictEqual(voice.stash(Buffer.from('RIFFfakewav').toString('base64'), 'audio/wav').file, st.file, 'same clip → same file');
+ assert.strictEqual(voice.stash('', 'audio/wav'), null);
+ assert.strictEqual(voice.cachedByName('../../etc/passwd'), null, 'no traversal');
+ assert.strictEqual(voice.cachedByName('zz.wav'), null);
+ assert.ok(voice.cachedByName(st.file).mimeType === 'audio/wav');
+ // An arena voice line lands in the clip cache by filename (the /api/tts clip route is Chat's now).
+ const line = await voice.speak({ user, text: 'you think your chat is loud?' });
+ assert.ok(voice.cachedByName(path.basename(line.path)), 'a spoken line is served from the clip cache');
+ console.log('✅ one-off clip stash + arena voice cache');
+ console.log('\n✅ All Rundown voice tests passed');
+ process.exit(0);
+})().catch(err => { console.error(err); process.exit(1); });