openvibers/openvibe.live Commits
Arena: a beef is public only once both sides have named each other; a one-sided callout is pending (told only to the named streamer) and expires quietly (one-sided matches were a greeting to the only viewer, the Addams Family and a friendly 'go live', 2026-10-18) (#250)
Changes
@@@ -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** (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 their opening, 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).++- **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.@@@ -58,7 +58,7 @@ Every 15 s, for every roster stream that is live **and** has a transcript line i| Rule | Value ||---|---|--| Opens when | the beef judge confirms ≥ 20 words aimed at a fighter after their whole name was said |++| 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 |@@@ -14,6 +14,12 @@* 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';@@@ -56,6 +62,14 @@ function templateHeadline(kind, ctx) {}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;}@@@ -79,36 +93,44 @@ function pushFeed(beef, event) {*/async function recordHit(speakerId, targetId, hit) {if (speakerId === targetId) return null;-- let beef = await openBeefBetween(speakerId, targetId);++ 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, on_clock, clock_until, ends_at, feed_json, opener_line, rematch, bounty_topic_id, headline) VALUES (?, ?, 'b', ?, ?, '[]', ?, ?, NULL, ?)`,++ 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 openBeefBetween(speakerId, targetId);++ beef = await activeBeefBetween(speakerId, targetId);opened = true;-- await mic().addXp(speakerId, XP_BEEF_OPEN, 'beef_open', beef.id);-- console.log(`[Arena] beef #${beef.id} opened: user ${speakerId} → user ${targetId}${history.fights ? ' (rematch)' : ''}`);-- try { require('./notify').arenaNotify(targetId, { type: 'beef_open', title: `${await nameOf(speakerId)} has words for you${history.fights ? ' — rematch' : ''}`, message: `${hit.best_line ? `“${String(hit.best_line).slice(0, 120)}” — ` : ''}answer on your own stream within ${await isLive(targetId) ? `${RESPONSE_LIVE_MIN} min` : `${RESPONSE_OFFLINE_HOURS} h`} or forfeit.`, icon: '🥊', url: `/arena/beef/${beef.id}`, key: `open:${beef.id}`, senderId: speakerId, senderName: await nameOf(speakerId) }); } catch { /* */ }-- const id = beef.id;-- require('./drama').beefStory('open', { id, a_user_id: speakerId, b_user_id: targetId }, { about: hit.about, opening_line: hit.best_line }).catch(() => {});-- headlineFor('open', { a: await nameOf(speakerId), b: await nameOf(targetId), opening_line: hit.best_line, about: hit.about, rematch: history.fights > 0, record_between: history }).then(async h => { if (h) await db.run('UPDATE arena_beefs SET headline = ? WHERE id = ?', [h, id]); }).catch(() => {});++ 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,-- on_clock = ?, clock_until = ?, feed_json = ? WHERE id = ?`,-- [quality, side === 'b' ? 1 : 0, other, await clockFor(otherId), feed, beef.id]);++ 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 { /* */ }-- if (!opened) { 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, side, first_response: firstResponse };++ { 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) {@@@ -166,7 +188,7 @@ async function rivalry(u1, u2) {}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 status <> 'void' GROUP BY 1 HAVING COUNT(*) >= 2 ORDER BY n DESC LIMIT ?`, [userId, userId, userId, limit]);++ 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) }; })));@@@ -175,6 +197,11 @@ async function rivalriesFor(userId, limit = 5) {/** 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) {@@@ -201,7 +228,7 @@ 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 status <> 'void'`, [userId, userId, userId, userId]) || {};++ 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 };}@@@ -240,14 +267,14 @@ async function list({ limitResolved = 12 } = {}) {}async function get(id) {-- // 'void': a beef nobody started (no name said; voided 2026-10-18) is not shown.-- const b = await db.get("SELECT * FROM arena_beefs WHERE id = ? AND status <> 'void'", [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 status <> 'void' 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))));++ 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 = {@@@ -167,7 +167,7 @@ async function micStats(userId, days = 30) {COUNT(*) FILTER (WHERE quality >= 7) AS bangersFROM 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 <> 'void'`, [userId, userId, userId, userId, userId, userId, userId]) || {}; } catch { 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;@@@ -71,13 +71,17 @@ console.log('✅ roster + ratings are pure mic');// ── 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.beef.on_clock, 'b');++ 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, u2); assert.strictEqual(fm.beef_id, h1.beef.id); assert.strictEqual(fm.sec, 90);-- assert.strictEqual((await mic.levelView(u1)).beef_hits, 1);-- const h2 = await beef.recordHit(u2, u1, { quality: 9, best_line: 'Nova needed a bit to get views, I just exist', about: 'clout' });-- assert.strictEqual(h2.first_response, true); assert.strictEqual(h2.beef.on_clock, 'a');++ 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]);@@@ -127,7 +131,8 @@ console.log('✅ roster + ratings are pure mic');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, 1);++ 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).@@@ -152,7 +157,17 @@ console.log('✅ roster + ratings are pure mic');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 beef.openBeefsFor(u3)).length, 1, 'still the one beef a whole name opened');++ 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');
@@ -2,7 +2,7 @@
@@ -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.
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 their opening, 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).
+- **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).
- **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 🐄.
- **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.
- **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.
@@ -58,7 +58,7 @@ Every 15 s, for every roster stream that is live **and** has a transcript line i
@@ -58,7 +58,7 @@ Every 15 s, for every roster stream that is live **and** has a transcript line i
| Rule | Value |
| Rule | Value |
|---|---|
|---|---|
-| Opens when | the beef judge confirms ≥ 20 words aimed at a fighter after their whole name was said |
+| 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) |
| 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 |
| Answer | any judged hit from the side on the clock; every hit flips the clock |
| Forfeit | clock runs out → the silent side loses |
| Forfeit | clock runs out → the silent side loses |
@@ -14,6 +14,12 @@
@@ -14,6 +14,12 @@
* clock and you ducked — forfeit.
* clock and you ducked — forfeit.
* end → a beef also hard-ends MAX_BEEF_HOURS after it opened: higher score
* 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.
* 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';
'use strict';
@@ -56,6 +62,14 @@ function templateHeadline(kind, ctx) {
@@ -56,6 +62,14 @@ function templateHeadline(kind, ctx) {
}
}
function sqlDate(ms) { return new Date(ms).toISOString().replace('T', ' ').slice(0, 19); }
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) {
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;
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;
}
}
@@ -79,36 +93,44 @@ function pushFeed(beef, event) {
@@ -79,36 +93,44 @@ function pushFeed(beef, event) {
*/
*/
async function recordHit(speakerId, targetId, hit) {
async function recordHit(speakerId, targetId, hit) {
if (speakerId === targetId) return null;
if (speakerId === targetId) return null;
- let beef = await openBeefBetween(speakerId, targetId);
+ let beef = await activeBeefBetween(speakerId, targetId);
let opened = false;
let opened = false;
if (!beef) {
if (!beef) {
const endsAt = sqlDate(Date.now() + MAX_BEEF_HOURS * 3600 * 1000);
const endsAt = sqlDate(Date.now() + MAX_BEEF_HOURS * 3600 * 1000);
const history = await rivalry(speakerId, targetId);
const history = await rivalry(speakerId, targetId);
- await db.run(`INSERT INTO arena_beefs (a_user_id, b_user_id, on_clock, clock_until, ends_at, feed_json, opener_line, rematch, bounty_topic_id, headline) VALUES (?, ?, 'b', ?, ?, '[]', ?, ?, NULL, ?)`,
+ 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 })]);
[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 openBeefBetween(speakerId, targetId);
+ beef = await activeBeefBetween(speakerId, targetId);
opened = true;
opened = true;
- await mic().addXp(speakerId, XP_BEEF_OPEN, 'beef_open', beef.id);
- console.log(`[Arena] beef #${beef.id} opened: user ${speakerId} → user ${targetId}${history.fights ? ' (rematch)' : ''}`);
- try { require('./notify').arenaNotify(targetId, { type: 'beef_open', title: `${await nameOf(speakerId)} has words for you${history.fights ? ' — rematch' : ''}`, message: `${hit.best_line ? `“${String(hit.best_line).slice(0, 120)}” — ` : ''}answer on your own stream within ${await isLive(targetId) ? `${RESPONSE_LIVE_MIN} min` : `${RESPONSE_OFFLINE_HOURS} h`} or forfeit.`, icon: '🥊', url: `/arena/beef/${beef.id}`, key: `open:${beef.id}`, senderId: speakerId, senderName: await nameOf(speakerId) }); } catch { /* */ }
- const id = beef.id;
- require('./drama').beefStory('open', { id, a_user_id: speakerId, b_user_id: targetId }, { about: hit.about, opening_line: hit.best_line }).catch(() => {});
- headlineFor('open', { a: await nameOf(speakerId), b: await nameOf(targetId), opening_line: hit.best_line, about: hit.about, rematch: history.fights > 0, record_between: history }).then(async h => { if (h) await db.run('UPDATE arena_beefs SET headline = ? WHERE id = ?', [h, id]); }).catch(() => {});
+ 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 side = beef.a_user_id === speakerId ? 'a' : 'b';
const other = side === 'a' ? 'b' : 'a';
const other = side === 'a' ? 'b' : 'a';
const otherId = side === 'a' ? beef.b_user_id : beef.a_user_id;
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 quality = Math.max(0, Math.min(10, Number(hit.quality) || 0));
const firstResponse = side === 'b' && !beef.responded;
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 });
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,
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,
- on_clock = ?, clock_until = ?, feed_json = ? WHERE id = ?`,
- [quality, side === 'b' ? 1 : 0, other, await clockFor(otherId), feed, beef.id]);
+ 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 });
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).
// 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 { 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 { /* */ }
- if (!opened) { 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, side, first_response: firstResponse };
+ { 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) {
async function hype(beefId, side, voterKey) {
@@ -166,7 +188,7 @@ async function rivalry(u1, u2) {
@@ -166,7 +188,7 @@ async function rivalry(u1, u2) {
}
}
async function rivalriesFor(userId, limit = 5) {
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 status <> 'void' GROUP BY 1 HAVING COUNT(*) >= 2 ORDER BY n DESC LIMIT ?`, [userId, userId, userId, limit]);
+ 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 b = mic();
const roster = await arena().loadRoster();
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) }; })));
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) }; })));
@@ -175,6 +197,11 @@ async function rivalriesFor(userId, limit = 5) {
@@ -175,6 +197,11 @@ async function rivalriesFor(userId, limit = 5) {
/** Ticker: enforce clocks and hard ends; tighten an offline clock when the target goes live. */
/** Ticker: enforce clocks and hard ends; tighten an offline clock when the target goes live. */
async function tick() {
async function tick() {
const now = Date.now();
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'`)) {
for (const beef of await db.all(`SELECT * FROM arena_beefs WHERE status = 'open'`)) {
const t = totals(beef);
const t = totals(beef);
if (beef.ends_at && Date.parse(beef.ends_at + 'Z') <= now) {
if (beef.ends_at && Date.parse(beef.ends_at + 'Z') <= now) {
@@ -201,7 +228,7 @@ async function recordFor(userId) {
@@ -201,7 +228,7 @@ async function recordFor(userId) {
const r = await db.get(`SELECT COUNT(*) FILTER (WHERE winner_user_id = ?) AS wins,
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 NOT NULL AND winner_user_id != ?) AS losses,
COUNT(*) FILTER (WHERE status = 'resolved' AND winner_user_id IS NULL) AS draws
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 status <> 'void'`, [userId, userId, userId, userId]) || {};
+ 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 };
return { wins: r.wins || 0, losses: r.losses || 0, draws: r.draws || 0 };
}
}
@@ -240,14 +267,14 @@ async function list({ limitResolved = 12 } = {}) {
@@ -240,14 +267,14 @@ async function list({ limitResolved = 12 } = {}) {
}
}
async function get(id) {
async function get(id) {
- // 'void': a beef nobody started (no name said; voided 2026-10-18) is not shown.
- const b = await db.get("SELECT * FROM arena_beefs WHERE id = ? AND status <> 'void'", [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;
return b ? await beefView(b, await arena().loadRoster()) : null;
}
}
async function forUser(userId, limit = 8) {
async function forUser(userId, limit = 8) {
const roster = await arena().loadRoster();
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 status <> 'void' 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))));
+ 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 = {
module.exports = {
@@ -167,7 +167,7 @@ async function micStats(userId, days = 30) {
@@ -167,7 +167,7 @@ async function micStats(userId, days = 30) {
COUNT(*) FILTER (WHERE quality >= 7) AS bangers
COUNT(*) FILTER (WHERE quality >= 7) AS bangers
FROM arena_mic_moments WHERE user_id = ? AND created_at >= datetime('now', ?)`, [userId, win]) || {};
FROM arena_mic_moments WHERE user_id = ? AND created_at >= datetime('now', ?)`, [userId, win]) || {};
let b = {};
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 <> 'void'`, [userId, userId, userId, userId, userId, userId, userId]) || {}; } catch { 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 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 hours = Math.max(speechMin / 60, 0.05);
const targeted = b.targeted || 0;
const targeted = b.targeted || 0;
@@ -71,13 +71,17 @@ console.log('✅ roster + ratings are pure mic');
@@ -71,13 +71,17 @@ console.log('✅ roster + ratings are pure mic');
// ── Beefs: a hit logs into the feed too; clocks flip; forfeit; KILLS ──
// ── 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 });
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.beef.on_clock, 'b');
+ 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');
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];
const fm = (await mic.feed({ limit: 1 }))[0];
- assert.strictEqual(fm.kind, 'beef_hit'); assert.strictEqual(fm.target.user.id, u2); assert.strictEqual(fm.beef_id, h1.beef.id); assert.strictEqual(fm.sec, 90);
- assert.strictEqual((await mic.levelView(u1)).beef_hits, 1);
- const h2 = await beef.recordHit(u2, u1, { quality: 9, best_line: 'Nova needed a bit to get views, I just exist', about: 'clout' });
- assert.strictEqual(h2.first_response, true); assert.strictEqual(h2.beef.on_clock, 'a');
+ 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 });
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 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 db.run(`UPDATE arena_beefs SET clock_until = datetime('now', '-1 minute') WHERE id = ?`, [h1.beef.id]);
@@ -127,7 +131,8 @@ console.log('✅ roster + ratings are pure mic');
@@ -127,7 +131,8 @@ console.log('✅ roster + ratings are pure mic');
ev = await listener.tick();
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(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.ok((await listener.consoleState(u3)).focus && (await listener.consoleState(u3)).focus.target_id === u2, 'locked on grizzly');
- assert.strictEqual((await beef.openBeefsFor(u3)).length, 1);
+ 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');
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"
// 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).
// scored a 107–0 beef, 2026-10-18).
@@ -152,7 +157,17 @@ console.log('✅ roster + ratings are pure mic');
@@ -152,7 +157,17 @@ console.log('✅ roster + ratings are pure mic');
ev = await listener.tick();
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.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 listener.consoleState(u3)).focus, null);
- assert.strictEqual((await beef.openBeefsFor(u3)).length, 1, 'still the one beef a whole name opened');
+ 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('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');
assert.strictEqual(await listener._aimedAtUnnamed('chat', u3, await arena.loadRoster()), 'chat');
const names = require('../server/arena/names');
const names = require('../server/arena/names');