openvibers/openvibe.live Commits

Arena: a beef needs a fighter's whole name said (a League stream's "Minions" matched mnss22741 phonetically and game voice lines became "iZoR_One DECLARES DEATH THREATS"; 46 of 51 beefs had no name said), a meltdown needs the streamer's own heated words beside the noise, no tears stories, an AI headline adds no accusation and "ducked" needs the person live on the clock (owner, 2026-10-18) (#241)

OP OpenVibers committed 26 days ago · c2920d020f · 9 files, +146 −55

Changes

ddiff --git a/docs/arena.md b/docs/arena.md
iindex be9dbce7..fef52529 100644
---- a/docs/arena.md
++++ b/docs/arena.md
@@@ -2,13 +2,13 @@
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, 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 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).
- **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 🐄.
++- **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 name said on mic opens a beef, the other side is on the clock, silence is a forfeit.
++- **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>`.
@@@ -47,9 +47,9 @@ POWER = weighted sum + a **mouth bonus** (≤ +12: recent XP ÷ 25 + 3 per beef
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`) — every roster name (username, display name, fighter name, the persona's `spoken_as`) in the forms a transcriber produces (camelCase/snake split, digits/decorations dropped, leet undone, glued), matched exact → fuzzy → phonetic. The speaker never matches themselves.
++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 → `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. If `aimed_at` resolves to a roster fighter and quality ≥ 5 → it is a **callout** and feeds a beef exactly like a name-drop.
++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.
@@@ -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 a name-drop, or the mic judge's `aimed_at` resolves to a fighter (quality ≥ 5) |
++| Opens when | the beef judge confirms ≥ 20 words aimed at a fighter after their whole name was said |
| 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 |
ddiff --git a/server/arena/backfill.js b/server/arena/backfill.js
iindex 4de6a053..5c8800f1 100644
---- a/server/arena/backfill.js
++++ b/server/arena/backfill.js
@@@ -97,8 +97,8 @@ async function backfillFighter(uid, roster, budget) {
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);
-- const target = j.aimed_at ? (await L._mentionsDetailed(j.aimed_at, uid, roster))[0] : null;
-- if (await mic().addMoment({ userId: uid, streamId: c.stream_id, vodId: r.vod_id, sec: r.sec, kind: target ? 'callout' : 'trash', targetUserId: target ? target.userId : null, aimedAt: target ? await mic().nameOf(target.userId) : (j.aimed_at || null), text: j.best_line || text.slice(0, 220), about: j.about, quality: j.quality, announcer: j.announcer, saidAt: saidAtFor(c, r.sec) })) moments++;
++ // 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); }
}
ddiff --git a/server/arena/beef.js b/server/arena/beef.js
iindex 169d5bb8..b2028cd3 100644
---- a/server/arena/beef.js
++++ b/server/arena/beef.js
@@@ -42,7 +42,10 @@ async function headlineFor(kind, ctx) {
// 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' } });
-- return out && out.headline ? String(out.headline).slice(0, 120) : null;
++ 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) {
@@@ -163,7 +166,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 = ?) 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 status <> 'void' 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) }; })));
@@@ -198,7 +201,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 = ?`, [userId, userId, userId, userId]) || {};
++ FROM arena_beefs WHERE (a_user_id = ? OR b_user_id = ?) AND status <> 'void'`, [userId, userId, userId, userId]) || {};
return { wins: r.wins || 0, losses: r.losses || 0, draws: r.draws || 0 };
}
@@@ -237,13 +240,14 @@ async function list({ limitResolved = 12 } = {}) {
}
async function get(id) {
-- const b = await db.get('SELECT * FROM arena_beefs WHERE id = ?', [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]);
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 = ? 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 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))));
}
module.exports = {
ddiff --git a/server/arena/drama.js b/server/arena/drama.js
iindex 19af2f84..2b1e843d 100644
---- a/server/arena/drama.js
++++ b/server/arena/drama.js
@@@ -7,8 +7,8 @@
*
* rage_quit a stream over in under 15 min from someone who usually goes 45+
* viewer_crash viewers halve within 20 min (from 3+) viewer_spike viewers double within 20 min (to 4+)
-- * chat_explosion chat 3x the stream's usual rate (15+ in 5 min) meltdown / tears 3+ yells, screams, shouts or sobs
-- * heard within 2 min (the sound timeline), with what they said around it
++ * 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+)
@@@ -17,7 +17,8 @@
*
* 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.
++ * 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.
*
@@@ -38,7 +39,11 @@ const AI_PER_HOUR = 20;
const WINDOW_MS = 3 * 3600 * 1000;
const REACTIONS = ['fire', 'skull', 'clown', 'w', 'l'];
const RAGE_SOUNDS = new Set(['Yell', 'Screaming', 'Shout', 'Bellow', 'Battle cry', 'Groan', 'Wail, moan']);
--const TEAR_SOUNDS = new Set(['Crying, sobbing', 'Whimper']);
++// 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`); };
@@@ -65,7 +70,6 @@ const T = {
viewer_spike: (n, f) => [`📈 ${n} IS BLOWING UP: ${f.from} → ${f.to} viewers`, `${n} went from ${f.from} to ${f.to} viewers in ${f.minutes} minutes. Something is happening.`, 2, 0, 9],
chat_explosion: (n, f) => [`💬 ${n}'S CHAT GOES FERAL: ${f.messages_5m} messages in 5 minutes`, `${n}'s chat usually runs at ${f.usual_5m} messages every 5 minutes. It just hit ${f.messages_5m}.`, 2, 0, 7],
meltdown: (n, f) => [`😤 ${n} LOSES IT ON STREAM`, `${f.count} yells, screams and shouts in ${f.seconds} seconds on ${n}'s stream.`, 3, 12, 2],
-- tears: (n, f) => [`😭 ${n} IN TEARS ON STREAM?`, `Crying was heard ${f.count} times in ${f.seconds} seconds on ${n}'s stream.`, 3, 14, 0],
marathon: (n, f) => [`⏰ ${n} HAS BEEN LIVE FOR ${f.hours} HOURS. TOUCH GRASS?`, `${n} started at ${f.started} UTC and has not stopped.`, 2, 3, 6],
ghost_town: (n, f) => [`👻 ${n} STREAMED ${f.minutes} MINUTES TO AN EMPTY ROOM`, `Peak: ${f.peak} viewer${f.peak === 1 ? '' : 's'}. Chat messages: ${f.messages}.`, 1, 7, 0],
the_return: (n, f) => [`👀 ${n} IS BACK after ${f.days_away} days`, `${n}'s last stream was ${f.days_away} days ago. They are live again${f.title ? `: "${f.title}"` : ''}.`, 2, 0, 6],
@@@ -128,6 +132,14 @@ async function announce(id) {
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());
@@@ -138,6 +150,8 @@ async function rewrite(id, payload, userId) {
// 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]);
}
@@@ -215,12 +229,12 @@ async function detectSnapshots(streams, since) {
return out;
}
--/** Meltdowns and tears: 3+ rage (or crying) sounds within 2 minutes, with the longest line said around them. */
++/** 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], ['tears', TEAR_SOUNDS]]) {
++ 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++) {
@@@ -231,7 +245,8 @@ async function detectSounds(streams, sinceTxt) {
}
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, best.from.start_sec, best.to.start_sec);
++ 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)}`,
@@@ -299,11 +314,17 @@ async function detect({ now = Date.now() } = {}) {
} 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() });
-- if (kind === 'forfeit' && ctx.loserId && ctx.winnerId) return await createStory({ kind: 'beef_forfeit', userId: ctx.loserId, otherUserId: ctx.winnerId, dedupeKey: `beef_forfeit:${beef.id}`, facts: {}, happenedAt: Date.now() });
++ // "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;
}
@@@ -442,4 +463,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, announce, feed, story, react, lolcowIndex, cloutRankings, rapSheet, setOptOut, optedOut, hide, REACTIONS, TEMPLATES: T, _test: { detectStreams, detectSnapshots, detectSounds, detectDaily, weekStart, toMs, fmt, pickQuote } };
++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 } };
ddiff --git a/server/arena/listener.js b/server/arena/listener.js
iindex 7ef33464..52818a3d 100644
---- a/server/arena/listener.js
++++ b/server/arena/listener.js
@@@ -4,15 +4,14 @@
* 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 names another fighter (however the transcriber spelled it — see
-- * names.js) LOCKS the ears on that fighter. Everything said while locked goes to
++ * 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 → beef.recordHit() (opens
* the beef, scores it, puts the other side on the clock). 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? If the judge's
-- * `aimed_at` resolves to a roster fighter, it is a callout and feeds a beef like a
-- * name-drop would; otherwise it lands in the shit-talk feed as a `trash` moment
-- * and pays Trash Level XP (mic.js).
++ * 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.
@@@ -29,7 +28,6 @@ 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 CALLOUT_MIN_QUALITY = 6; // …and this good for an aimed_at name to open a beef
const MIN_LINE_WORDS = 6; // a quotable line is a complete thought, not a fragment
const BUFFER_MAX_CHARS = 1400;
@@@ -61,8 +59,11 @@ async function aliases(roster) {
_aliasCache = { at: Date.now(), list };
return list;
}
--async function mentionsIn(text, speakerId, roster) { return names.findMentions(text, await aliases(roster), { excludeUserId: speakerId }).map(m => m.userId); }
--async function mentionsDetailed(text, speakerId, roster) { return names.findMentions(text, await aliases(roster), { excludeUserId: speakerId }); }
++// 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 ───────────────────────────────────────────────────
@@@ -196,6 +197,16 @@ async function judgeFocus(stream, roster, st, events, { reason }) {
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 = [];
@@@ -206,15 +217,8 @@ async function judgeFreeTalk(stream, roster, st, events) {
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; }
-- // "aimed_at" that resolves to a roster fighter = a callout → it feeds a beef exactly like a name-drop.
-- const target = j.aimed_at ? (await mentionsDetailed(j.aimed_at, stream.user_id, roster))[0] : null;
-- if (target && j.quality >= CALLOUT_MIN_QUALITY) {
-- const res = await beef().recordHit(stream.user_id, target.userId, { 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 });
-- st.lastMicJudgement.target_id = target.userId; st.lastMicJudgement.opened = res?.opened;
-- events.push({ kind: 'beef_hit', streamId: stream.id, speakerId: stream.user_id, targetId: target.userId, opened: res?.opened, quality: j.quality, line: j.best_line, named: false, callout: true });
-- return;
-- }
-- const m = await mic().addMoment({ userId: stream.user_id, streamId: stream.id, vodId: ref.vod_id, sec: ref.sec, kind: 'trash', aimedAt: j.aimed_at || null, text: j.best_line || text.slice(0, 220), about: j.about, quality: j.quality, announcer: j.announcer });
++ 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 });
}
@@@ -297,4 +301,4 @@ function start() {
}
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, CALLOUT_MIN_QUALITY, MIN_LINE_WORDS, cleanLine, FOCUS_TAIL_MS, FOCUS_EXTEND_MS, FOCUS_MAX_MS, SPICY, _mentionsIn: mentionsIn, _mentionsDetailed: mentionsDetailed, _aliases: aliases, _heuristicBeef: heuristicBeef, _heuristicMic: heuristicMic, _judgeBeef: judgeBeef, _judgeMic: judgeMic, _state: state };
++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 };
ddiff --git a/server/arena/mic.js b/server/arena/mic.js
iindex 1cb0471a..9bf155ae 100644
---- a/server/arena/mic.js
++++ b/server/arena/mic.js
@@@ -167,7 +167,7 @@ async function micStats(userId, days = 30) {
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 = ?`, [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 <> 'void'`, [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;
ddiff --git a/server/arena/names.js b/server/arena/names.js
iindex 83d13875..1af05a75 100644
---- a/server/arena/names.js
++++ b/server/arena/names.js
@@@ -109,14 +109,29 @@ function variantRegex(v) {
return new RegExp(`(?:^|[^a-z0-9])@?${parts.join('[\\s\\-_.]*')}(?:'?s)?(?![a-z0-9])`, 'i');
}
--/** Build an alias entry: { userId, name, words, re, key } for each variant of each name. */
--function aliasEntries(userId, names) {
-- const seen = new Set();
++/** 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)) {
-- if (seen.has(v)) continue; seen.add(v);
-- out.push({ userId, name: v, words: v.split(' '), re: variantRegex(v), key: phonetic(v), letters: v.replace(/\s+/g, '') });
++ 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;
@@@ -124,12 +139,14 @@ function aliasEntries(userId, names) {
/**
* Which alias entries occur in `text`. Returns [{ userId, name, how: 'exact'|'fuzzy'|'phonetic', hit }].
-- * `excludeUserId` = the speaker (never mentions themselves).
++ * `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 } = {}) {
++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()); }
@@@ -174,10 +191,11 @@ function rosterEntries(roster, extra = null) {
const list = [];
for (const id of roster.order) {
const u = roster.byId[id].user;
-- const names = [u.username, u.display_name, ...(extra ? (extra(id) || []) : [])];
++ 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)) names.push(first);
-- list.push(...aliasEntries(id, names));
++ 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);
}
ddiff --git a/test/arena-drama.test.js b/test/arena-drama.test.js
iindex d970bcbd..67aa982a 100644
---- a/test/arena-drama.test.js
++++ b/test/arena-drama.test.js
@@@ -29,7 +29,7 @@ const db = require('../server/db/database');
[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');
++ 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 });
@@@ -45,6 +45,14 @@ const db = require('../server/db/database');
{ 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.
@@@ -71,6 +79,8 @@ const db = require('../server/db/database');
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]);
@@@ -111,6 +121,10 @@ const db = require('../server/db/database');
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));
@@@ -132,6 +146,16 @@ const db = require('../server/db/database');
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([
ddiff --git a/test/arena-mic.test.js b/test/arena-mic.test.js
iindex 55e46b18..8678adfc 100644
---- a/test/arena-mic.test.js
++++ b/test/arena-mic.test.js
@@@ -1,7 +1,8 @@
'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 from name-drops AND from the mic judge's "aimed_at",
++// 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).
@@@ -136,6 +137,25 @@ console.log('✅ roster + ratings are pure mic');
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 beef.openBeefsFor(u3)).length, 1, 'still the one beef a whole name opened');
++ 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 ──