openvibers/openvibe.live Commits

Arena: a beef hit needs the target's name in the lines judged; what follows a name-drop is context only (a League stream's voice lines after one transcribed "Adam" scored a 107-0 beef, 2026-10-18) (#245)

OP OpenVibers committed 26 days ago · 8e5935ddae · 3 files, +16 −6

Changes

ddiff --git a/docs/arena.md b/docs/arena.md
iindex fef52529..a80bfb0a 100644
---- a/docs/arena.md
++++ b/docs/arena.md
@@@ -48,7 +48,7 @@ Every 15 s, for every roster stream that is live **and** has a transcript line i
1. New speech lines are read.
2. **Name detection** (`server/arena/names.js`) — a fighter is named only when their **whole** username or display name is said as it is spelled (camelCase/snake split, leet undone and spacing are fine: "mnss 22741", "grizzly bear"); pieces of a handle ("fix it" of finditfixit, "mnss"), nicknames (fighter name, `spoken_as`, a handle's first word) and fuzzy or phonetic sound-alikes never count (`findMentions(…, { wholeOnly: true })`). On 2026-10-18, 46 of the first 51 beefs had no whole name said (a League stream's "Minions" had matched mnss22741 phonetically); they were voided (`status = 'void'`, out of every record). The speaker never matches themselves.
--3. **Focus lock** — a name-drop locks the ears on that fighter for 2 min; everything said afterwards goes to the **beef judge** with the context of what was already said (`about_target`, `aimed_at_target`, quality, best verbatim line, announcer call). A hit → `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.
++3. **Focus lock** — a name-drop locks the ears on that fighter for 2 min; everything said afterwards goes to the **beef judge** with the context of what was already said (`about_target`, `aimed_at_target`, quality, best verbatim line, announcer call). A hit, only in lines that say the target's name (what follows is context: a game's voice lines after one transcribed "Adam" once scored 107–0), → `beef.recordHit()`; every hit extends the lock 3 min (cap 20 min without a fresh name-drop); two off-target chunks drop it; a different name switches targets.
4. **Free talk** — lines said while not locked pool for the **mic judge** (≥ 20 words, ≥ 30 s between calls per stream): `is_trash_talk`, quality, best line, `aimed_at`, announcer. Quality ≥ 4 → a `trash` moment in the feed. An `aimed_at` that is a roster fighter is dropped (nobody named them; a whole name said would have locked the ears instead), so free talk never opens a beef.
5. One judge call per stream per tick; keyword heuristics when AI is off.
ddiff --git a/server/arena/listener.js b/server/arena/listener.js
iindex 52818a3d..1e3622da 100644
---- a/server/arena/listener.js
++++ b/server/arena/listener.js
@@@ -5,9 +5,10 @@
* lines. Everything the Arena is comes from here; chat, votes and submissions do not exist.
*
* name-drop → a line that says another fighter's whole name, as it is spelled (names.js
-- * wholeOnly: no sound-alikes, no pieces of a handle) LOCKS the ears on that fighter. Everything said while locked goes to
-- * the BEEF JUDGE with the earlier context; a judged hit → beef.recordHit() (opens
-- * the beef, scores it, puts the other side on the clock). See beef.js.
++ * wholeOnly: no sound-alikes, no pieces of a handle) LOCKS the ears on that fighter.
++ * Everything said while locked goes to the BEEF JUDGE with the earlier context; a
++ * judged hit in lines that say the name → beef.recordHit() (opens the beef, scores
++ * it, puts the other side on the clock); unnamed lines are context only. See beef.js.
* free talk → everything said while NOT locked pools for the MIC JUDGE: is this shit talk at
* all (roast, rant, callout, brag, disrespect)? Who is it aimed at ("chat", "the
* mods"; a fighter the judge guesses at is dropped, since nobody named them)? It
@@@ -174,7 +175,9 @@ async function judgeFocus(stream, roster, st, events, { reason }) {
const j = await judgeBeef(stream.user_id, f.targetId, text, roster, { context: f.context, named, how: f.how });
const now = Date.now();
const minQ = f.hits > 0 ? 3 : (f.how === 'exact' ? 3 : 5);
-- if (j.aimed_at_target && j.quality >= minQ) {
++ // A hit needs the target's name in the lines judged: what follows a name-drop is context, never a hit by itself
++ // (a League stream's voice lines after one transcribed "Adam" scored a 107–0 beef, 2026-10-18).
++ if (j.aimed_at_target && j.quality >= minQ && named) {
const ref = lineRefFor(lines, j.best_line);
const res = await beef().recordHit(stream.user_id, f.targetId, { quality: j.quality, best_line: j.best_line, about: j.about, announcer: j.announcer, vod_id: ref.vod_id, sec: ref.sec, stream_id: stream.id });
f.hits++; f.misses = 0; f.lockUntil = Math.min(now + FOCUS_EXTEND_MS, f.namedAt + FOCUS_MAX_MS);
ddiff --git a/test/arena-mic.test.js b/test/arena-mic.test.js
iindex 8678adfc..3f4dd32e 100644
---- a/test/arena-mic.test.js
++++ b/test/arena-mic.test.js
@@@ -129,7 +129,14 @@ console.log('✅ roster + ratings are pure mic');
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 arena.liveFighters())[0].ears.focus.target_id, u2, 'live cam shows the lock');
-- // Drop the lock (two off-target chunks), then a callout WITHOUT the name in the speech but with the judge's aimed_at.
++ // Unnamed trash while locked is context, never a hit (a League stream's voice lines after one transcribed "Adam"
++ // scored a 107–0 beef, 2026-10-18).
++ await db.run(`UPDATE streams SET started_at = datetime('now', '-760 seconds') WHERE id = ?`, [liveId]);
++ await db.addTimelineEvents([{ stream_id: liveId, user_id: u3, vod_id: null, kind: 'speech', start_sec: 746, end_sec: 752, text: 'He is trash, washed, a fraud, scared of the smoke, nobody watches that garbage stream, I would cook him, bet!', label: null, confidence: 0.9 }]);
++ listener._state.get(liveId).lastJudgeAt = 0;
++ ev = await listener.tick();
++ assert.ok(!ev.some(e => e.kind === 'beef_hit'), `unnamed lines never score: ${JSON.stringify(ev)}`);
++ // Drop the lock (two off-target chunks).
for (let k = 0; k < 2; k++) {
await db.run(`UPDATE streams SET started_at = datetime('now', ?) WHERE id = ?`, [`-${800 + k * 100} seconds`, liveId]);
await db.addTimelineEvents([{ stream_id: liveId, user_id: u3, vod_id: null, kind: 'speech', start_sec: 786 + k * 100, end_sec: 792 + k * 100, text: 'Okay chat back to the game, we need to farm this boss and then do the quest line for the sword upgrade, let me check the map real quick.', label: null, confidence: 0.9 }]);