openvibers/openvibe.live Commits

Arena: big ARENA ALERT stories are announced in chat (the subject's stream room while live, mirrored to global; else global; 6 an hour at most) (#146)

OP OpenVibers committed 28 days ago · 6a8d630d0c · 3 files, +39 −3

Changes

ddiff --git a/docs/arena.md b/docs/arena.md
iindex c420c931..be9dbce7 100644
---- a/docs/arena.md
++++ b/docs/arena.md
@@@ -2,7 +2,7 @@
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** and **tears** (3+ yells/screams/shouts, or sobs, within 2 min on the sound timeline, quoted with what was said around them), **marathons** (8 h+), **ghost towns** (an hour+ to 0-1 viewers and no chat), **comebacks** (14+ days away), **title drama**, **new personal peaks**; once a day **fell off** (4+ streams the month before, none for 7 days), **no days off** (every 7 days in a row) and the **quote of the day**; beefs report their opening, win and forfeit. A story gets a template headline at once (facts and numbers only) and an AI rewrite from OpenVibe.AI's `live.arena.drama` (≤ 20 an hour): drama-news voice, roast what happened on stream, never invent an event or accuse, no identity slurs, sexual content, threats, doxxing or self-harm jokes; quotes are the streamer's own words. Alert level 1-5 (🚨). A subject with alert ≥ 3 is notified.
++- **ARENA ALERT** (`server/arena/drama.js`, `public/js/arena-alert.js`) — every 5 min the desk reads the last 3 hours of activity and writes one story per event (`arena_drama`, deduped): **rage quits** (a stream over in < 15 min from someone who usually goes 45+), **viewer crashes** (halved within 20 min, from 3+) and **spikes** (doubled, to 4+), **chat explosions** (3× the stream's usual rate, 15+ in 5 min), **meltdowns** and **tears** (3+ yells/screams/shouts, or sobs, within 2 min on the sound timeline, quoted with what was said around them), **marathons** (8 h+), **ghost towns** (an hour+ to 0-1 viewers and no chat), **comebacks** (14+ days away), **title drama**, **new personal peaks**; once a day **fell off** (4+ streams the month before, none for 7 days), **no days off** (every 7 days in a row) and the **quote of the day**; beefs report their opening, win and forfeit. A story gets a template headline at once (facts and numbers only) and an AI rewrite from OpenVibe.AI's `live.arena.drama` (≤ 20 an hour): drama-news voice, roast what happened on stream, never invent an event or accuse, no identity slurs, sexual content, threats, doxxing or self-harm jokes; quotes are the streamer's own words. Alert level 1-5 (🚨). A subject with alert ≥ 3 is notified, 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, tears, ghost towns, dramatic titles, falling off, ducking a beef), 0-100 against the top cow, who wears the 🐄.
- **Clout Rankings** — the last 7 days: hours live ×2 + average viewers ×10 + peak ×3 + chat messages ÷ 50 + new followers ×5 + the clout points of their stories (spikes, records, comebacks, streaks, beef wins) + beef wins ×10 − half their cow points. This week's moves against the snapshot taken each Monday (`arena_clout_weeks`); tags COOKED (cow meter 70+), ON THE RISE, FALLING, UNDEFEATED.
ddiff --git a/server/arena/drama.js b/server/arena/drama.js
iindex a5cb1939..a934a10a 100644
---- a/server/arena/drama.js
++++ b/server/arena/drama.js
@@@ -27,6 +27,7 @@
* 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';
@@@ -100,10 +101,32 @@ async function createStory({ kind, userId, otherUserId = null, streamId = null,
if (alert >= 3) {
try { require('./notify').arenaNotify(userId, { type: 'drama', title: `You made the Arena: ${headline.slice(0, 100)}`, message: blurb.slice(0, 200), icon: '🚨', url: `/arena/drama/${id}`, key: `drama:${id}` }); } catch { /* */ }
}
-- rewrite(id, { kind, streamer: name, other: fx.other || null, facts: fx, quote: cleanQuote }, userId).catch(() => {});
++ // The AI headline first (when it comes), then the chat hears about it.
++ rewrite(id, { kind, streamer: name, other: fx.other || null, 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;
++}
++
async function rewrite(id, payload, userId) {
if (!aiBudgetLeft() || !await arena().aiOn()) return;
_aiCalls.push(Date.now());
@@@ -402,4 +425,4 @@ async function setOptOut(userId, out) {
async function hide(id) { await db.run("UPDATE arena_drama SET status = 'hidden' WHERE id = ?", [Number(id)]); }
--module.exports = { detect, beefStory, createStory, feed, story, react, lolcowIndex, cloutRankings, rapSheet, setOptOut, optedOut, hide, REACTIONS, TEMPLATES: T, _test: { detectStreams, detectSnapshots, detectSounds, detectDaily, weekStart, toMs, fmt } };
++module.exports = { detect, beefStory, createStory, announce, feed, story, react, lolcowIndex, cloutRankings, rapSheet, setOptOut, optedOut, hide, REACTIONS, TEMPLATES: T, _test: { detectStreams, detectSnapshots, detectSounds, detectDaily, weekStart, toMs, fmt } };
ddiff --git a/test/arena-drama.test.js b/test/arena-drama.test.js
iindex 695f8f49..9616e303 100644
---- a/test/arena-drama.test.js
++++ b/test/arena-drama.test.js
@@@ -51,7 +51,12 @@ const db = require('../server/db/database');
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(', ')}`);
@@@ -69,6 +74,14 @@ const db = require('../server/db/database');
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}`);