openvibers/openvibe.live Commits

Arena: transcriber-proof name detection, focus lock on the target, personas in the chatter's own voice, per-fighter stats

OP OpenVibers committed 30 Aug 2026 · 11d69fb35c · 9 files, +399 −78

- server/arena/names.js: every spoken form of a handle (JapaneseOldGuy → 'japanese
  old guy', lofi_dan99 → 'lofi dan', x_Goosely_TV → 'goosely', leet undone, glued
  forms) matched exact → fuzzy (same word count, length-scaled edit distance) →
  phonetic ('goose lee', 'Goosley', 'mattie cuss', 'pixel queen'); common words,
  prefixes and self-mentions can never match. Personas add spoken_as aliases.
- Listener focus lock: a name-drop locks the ears on that fighter; following
  speech goes to the judge with the prior context and the judge decides whether
  they are still on them (pronouns, 'her chat', the same story) — hits extend the
  lock, two off-target chunks drop it, a new name switches targets. Console
  shows the lock, context and continuation hits.
- Persona voice now comes from chat history first (their own chat lines with
  rooms, chat-AI overview/memory/timeline as a chatter), streaming profile as
  flavor; taunts must read like messages they would actually type. Six
  custom_stats per fighter drawn as their own radar on ladder rows + profile.
- Bounty keywords use the same spoken variants. Tests + docs.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017AyeSFu7qdLdR64Zv99QNm

Changes

ddiff --git a/docs/arena.md b/docs/arena.md
iindex 178fdc19..9da2bd3f 100644
---- a/docs/arena.md
++++ b/docs/arena.md
@@@ -44,9 +44,10 @@ No voting anywhere. The board is a list of **subjects** the community is actuall
Every 15 s, for every roster stream that is live **and** has a transcript line in the last 30 min:
1. New `stream_timeline_events` speech lines are read.
--2. A fighter's name (username, display name, fighter name; `@name`; underscores/dots spoken as spaces; longest match first, never yourself) opens a **mention buffer**; lines in the following 45 s join it. At ≥ 20 words the **beef judge** runs (`aimed_at_target`, `quality`, `best_line` verbatim, `about`, `announcer`, `flagged`) → `beef.recordHit`.
--3. Lines matching a board subject's keywords become on-mic moments (and auto-join the fighter). Other lines pool for the **subject judge** against the streamer's active subject — or the one they just brought up (≥ 20 words, ≥ 30 s between calls): `on_topic`, `quality`, `best_line`, `about`.
--4. One judge call per stream per tick; keyword heuristics when AI is off.
++2. **Name detection** (`server/arena/names.js`) — every roster name (username, display name, fighter name, plus the persona's AI-written `spoken_as` nicknames/mishearings) is expanded into the forms a transcriber produces: camelCase/snake/kebab split (`JapaneseOldGuy` → "japanese old guy"), digits and `_tv`/`xX_` decorations dropped (`lofi_dan99` → "lofi dan"), leet undone, glued forms. Lines are matched exact → **fuzzy** (edit distance scaled by length, same word count only: "Matticus", "japanese old gai") → **phonetic** (compact metaphone key: "goose lee", "Goosley", "mattie cuss", "pixel queen"). Single common words can never be aliases, prefixes don't count, 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 together with what was already said about them, and the judge decides `about_target` (still on them via "he", "her chat", "that guy", the same story) and `aimed_at_target`. Every hit extends the lock 3 min (hard cap 20 min without a fresh name-drop); neutral talk keeps it a bit; two chunks about something else drop it. A different name-drop switches targets (the pending chunk is judged first). The console (`/arena/live/<user>`) shows the lock, its context and whether the last hit was a continuation.
++4. Lines matching a board subject's keywords become on-mic moments (and auto-join the fighter). Other lines pool for the **subject judge** against the streamer's active subject — or the one they just brought up (≥ 20 words, ≥ 30 s between calls): `on_topic`, `quality`, `best_line`, `about`.
++5. One judge call per stream per tick; keyword heuristics when AI is off (pronouns + spice count as a continuation).
`/arena/live/<username>` shows exactly what the ears hear for a fighter: hot mic lines, the last judgements, active topic progress, open beefs and any bounty on them (auto-refreshes; a level-up flashes).
@@@ -87,6 +88,6 @@ Every 15 s, for every roster stream that is live **and** has a transcript line i
Background job (`server/arena/arena-job.js`): personas/portraits every 20 min (bounded, budget-aware) · listener every 15 s · clocks + bounty expiries + chat scan every 60 s · discovery ≤ 1 AI call / 5 min and only with new material · lore rewrites only for subjects with ≥ 3 new moments (≤ 3 per minute).
--Personas: the taunts are **ragebait in the person's own voice** — the prompt gets their own chat lines (`things_they_typed_in_chat`), what they said on stream, the chat-AI notes, the streamer overview, the rooms they lurk in and the roster rivals; output includes `taunt`, three more `taunts` and a `typing_style`.
++Personas: the voice comes from their **chat history first** — their own chat lines with the room they typed in, the chat AI's profile of them as a chatter (overview, long-term memory, timeline), then the streaming profile as flavor. Output: `taunt` + three more `taunts` (ragebait in that exact typing style), `typing_style`, `spoken_as` (how people say/mishear the name — fed to name detection) and `custom_stats` (six stats unique to them, e.g. "Alt Accounts 91", drawn as their own radar on the ladder row and profile).
Tests: `node test/arena.test.js` (roster, ratings, filter, quotes) · `node test/arena-beef-board.test.js` (beefs, board, listener tick, chat commands, API) — both on a temp DB, no AI needed.
ddiff --git a/public/css/style.css b/public/css/style.css
iindex 6d3a5d77..edf99b60 100644
---- a/public/css/style.css
++++ b/public/css/style.css
@@@ -14442,3 +14442,18 @@ a.arena-tag:hover { color: var(--accent-light); }
.arena-topic-row-side { flex-direction: row; align-items: center; flex-wrap: wrap; }
.arena-topic-headline-big { font-size: 1.3rem; }
}
++
++/* ── Arena: per-fighter custom stats + taunts ── */
++.arena-radar-custom text { font-size: 9px; }
++.arena-quips-custom .arena-quip b { color: var(--accent-light); }
++.arena-row-taunts { display: flex; flex-direction: column; gap: 3px; margin: 0 0 10px; }
++.arena-row-taunts em { font-size: 0.85rem; color: var(--text-secondary); }
++.arena-row-taunts small { color: var(--text-muted); font-size: 0.72rem; }
++.arena-profile-custom { display: grid; grid-template-columns: 220px 1fr; gap: 18px; align-items: center; padding: 12px 14px; border-radius: 14px; background: var(--bg-tertiary); }
++@media (max-width: 600px) { .arena-profile-custom { grid-template-columns: 1fr; } }
++
++.arena-focus { margin: 0 0 10px; padding: 10px 12px; border-radius: 12px; background: rgba(239, 68, 68, 0.1); border: 1px solid rgba(239, 68, 68, 0.35); font-size: 0.88rem; }
++.arena-focus i { color: #f87171; }
++.arena-focus small { color: var(--text-secondary); margin-left: 6px; }
++.arena-focus-ctx { margin-top: 6px; font-size: 0.8rem; color: var(--text-muted); }
++.arena-focus .arena-note { margin: 6px 0 0; }
ddiff --git a/public/index.html b/public/index.html
iindex 3088b7e7..a86b566d 100644
---- a/public/index.html
++++ b/public/index.html
@@@ -62,7 +62,7 @@
</script>
<!-- Stylesheets -->
-- <link rel="stylesheet" href="/css/style.css?v=147">
++ <link rel="stylesheet" href="/css/style.css?v=148">
<link rel="stylesheet" href="/css/chat-shared.css?v=1">
<link rel="stylesheet" href="/css/broadcast.css?v=11">
<link rel="stylesheet" href="/css/cosmetics.css?v=2">
@@@ -5107,7 +5107,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<script src="/js/staff-console.js?v=1" defer></script>
<script src="/js/dashboard-moderation.js?v=5" defer></script>
<script src="/js/ai-viewers.js?v=2" defer></script>
--<script src="/js/arena.js?v=6" defer></script>
++<script src="/js/arena.js?v=7" defer></script>
<script src="/js/powerchat.js?v=10" defer></script>
<script src="/js/pastes.js?v=15" defer></script>
<script src="/js/draggable-fab.js?v=2" defer></script>
ddiff --git a/public/js/arena.js b/public/js/arena.js
iindex 149a0f78..4d5b3e73 100644
---- a/public/js/arena.js
++++ b/public/js/arena.js
@@@ -101,6 +101,29 @@ function _aRadar(r, color, size = 200) {
</svg>`;
}
++/** A fighter's OWN radar: six AI-named stats from their profile (falls back to the objective seven). */
++function _aCustomRadar(f, color, size = 200) {
++ const cs = (f.persona && Array.isArray(f.persona.custom_stats) ? f.persona.custom_stats : []).filter(x => x && x.name && Number.isFinite(Number(x.value))).slice(0, 8);
++ if (cs.length < 3) return _aRadar(f.ratings, color, size);
++ const c = size / 2, R = size / 2 - 30;
++ const N = cs.length;
++ const angle = (i) => -Math.PI / 2 + (i * 2 * Math.PI) / N;
++ const pt = (i, v) => { const rr = R * (Math.max(0, Math.min(99, v)) / 99); return [c + rr * Math.cos(angle(i)), c + rr * Math.sin(angle(i))]; };
++ const ring = (v) => cs.map((_, i) => pt(i, v).map(x => x.toFixed(1)).join(',')).join(' ');
++ const poly = cs.map((x, i) => pt(i, Number(x.value)).map(y => y.toFixed(1)).join(',')).join(' ');
++ const labels = cs.map((x, i) => { const rr = R + 18, lx = c + rr * Math.cos(angle(i)), ly = c + rr * Math.sin(angle(i)); return `<text x="${lx.toFixed(1)}" y="${ly.toFixed(1)}" text-anchor="middle" dominant-baseline="middle"><title>${_aEsc(x.quip || '')}</title>${_aEsc(String(x.name).slice(0, 14))} ${Number(x.value)}</text>`; }).join('');
++ return `<svg class="arena-radar arena-radar-custom" viewBox="0 0 ${size} ${size}" width="${size}" height="${size}" aria-hidden="true">
++ ${[33, 66, 99].map(v => `<polygon points="${ring(v)}" class="arena-radar-ring"></polygon>`).join('')}
++ <polygon points="${poly}" class="arena-radar-fill" style="fill:${_aEsc(color)}33;stroke:${_aEsc(color)}"></polygon>
++ ${labels}
++ </svg>`;
++}
++function _aCustomQuips(f) {
++ const cs = (f.persona && Array.isArray(f.persona.custom_stats) ? f.persona.custom_stats : []);
++ if (!cs.length) return '';
++ return `<div class="arena-quips arena-quips-custom">${cs.map(x => `<div class="arena-quip"><b>${_aEsc(x.name)} ${Number(x.value)}</b><span>${_aEsc(x.quip || '')}</span></div>`).join('')}</div>`;
++}
++
/** Sparkline of a per-stream series. */
function _aSpark(series, color) {
const w = 320, h = 70, pad = 6;
@@@ -501,10 +524,11 @@ function _aRenderList(fighters) {
<span class="arena-record" title="beef record">${f.record.wins}W–${f.record.losses}L</span>
</span>
<div class="arena-row-expand">
-- <div>${_aRadar(f.ratings, f.user.profile_color || '#8b5cf6', 180)}<div class="arena-mini-record">${f.category ? _aEsc(f.category) + ' · ' : ''}last live ${_aEsc(f.last_live_at ? _aDate(f.last_live_at) : '—')}</div></div>
++ <div>${_aCustomRadar(f, f.user.profile_color || '#8b5cf6', 200)}<div class="arena-mini-record">${(f.persona.custom_stats || []).length ? 'their own stats, from their profile · ' : ''}${f.category ? _aEsc(f.category) + ' · ' : ''}last live ${_aEsc(f.last_live_at ? _aDate(f.last_live_at) : '—')}</div></div>
<div>
<p class="arena-row-lore">${_aEsc(f.persona.lore)}</p>
-- <div class="arena-quips">${ARENA_STATS.map(k => `<div class="arena-quip"><b>${_aEsc(ARENA_STAT_LABEL[k])} ${f.ratings[k]}</b><span>${_aEsc((f.persona.stat_quips || {})[k] || '')}</span></div>`).join('')}</div>
++ ${(f.persona.taunts || []).length ? `<div class="arena-row-taunts">${f.persona.taunts.map(t => `<em>“${_aEsc(t)}”</em>`).join('')}${f.persona.typing_style ? `<small><i class="fa-solid fa-keyboard"></i> ${_aEsc(f.persona.typing_style)}</small>` : ''}</div>` : ''}
++ ${(f.persona.custom_stats || []).length ? _aCustomQuips(f) : `<div class="arena-quips">${ARENA_STATS.map(k => `<div class="arena-quip"><b>${_aEsc(ARENA_STAT_LABEL[k])} ${f.ratings[k]}</b><span>${_aEsc((f.persona.stat_quips || {})[k] || '')}</span></div>`).join('')}</div>`}
${f.persona.signature_move ? `<div class="arena-mini-record"><b>Signature:</b> ${_aEsc(f.persona.signature_move.name)} — ${_aEsc(f.persona.signature_move.description)}</div>` : ''}
</div>
<div class="arena-row-expand-actions">
@@@ -685,8 +709,9 @@ async function _aRenderConsole(root, username) {
<div class="arena-console-cols">
<section class="arena-console-main">
<h3><i class="fa-solid fa-microphone"></i> Hot mic <small>last lines the ears heard</small></h3>
++ ${L.focus ? `<div class="arena-focus"><i class="fa-solid fa-crosshairs fa-beat"></i> <b>Locked on ${_aEsc(L.focus.target || 'a fighter')}</b> <small>${_aEsc(L.focus.how)} name-drop · ${L.focus.hits} hit${L.focus.hits === 1 ? '' : 's'} · ${L.focus.pending_words} words waiting for the judge · lock <span class="arena-clock" data-until="${new Date(Date.now() + L.focus.lock_seconds_left * 1000).toISOString()}" data-who="the lock"><b>${_aClock(L.focus.lock_seconds_left)}</b></span></small>${L.focus.context ? `<div class="arena-focus-ctx">so far: ${_aEsc(L.focus.context)}</div>` : ''}<p class="arena-note">Everything said now goes to the beef judge as a continuation — no need to say the name again. Two chunks about something else and the ears let go.</p></div>` : ''}
<div class="arena-mic-feed">${c.hot_mic?.length ? c.hot_mic.map(l => `<div class="arena-mic-line-row"><span class="arena-mic-time">${_aStamp(l.sec)}</span><span>${_aEsc(l.text)}</span>${_aPlay(l.vod_id, l.sec)}</div>`).join('') : '<p class="arena-note">Nothing heard yet.</p>'}</div>
-- ${L.last_beef_judgement ? `<div class="arena-judgement ${L.last_beef_judgement.aimed_at_target ? 'is-hit' : ''}"><b><i class="fa-solid fa-gavel"></i> Beef judge (${_aEsc(_aAgo(L.last_beef_judgement.at))}):</b> ${L.last_beef_judgement.aimed_at_target ? `HIT · ${L.last_beef_judgement.quality}/10 · ${_aEsc(L.last_beef_judgement.about || '')}${L.last_beef_judgement.opened ? ' · <b>beef opened</b>' : ''}${L.last_beef_judgement.bounty ? ' · <b>bounty collected</b>' : ''}` : `no beef — ${_aEsc(L.last_beef_judgement.about || 'name dropped but not aimed at them')}`}${L.last_beef_judgement.flagged ? ' · <span class="arena-tag arena-tag-dim">line not counted (threat/minor/dox)</span>' : ''}</div>` : ''}
++ ${L.last_beef_judgement ? `<div class="arena-judgement ${L.last_beef_judgement.aimed_at_target ? 'is-hit' : ''}"><b><i class="fa-solid fa-gavel"></i> Beef judge (${_aEsc(_aAgo(L.last_beef_judgement.at))}):</b> ${L.last_beef_judgement.aimed_at_target ? `HIT${L.last_beef_judgement.named === false ? ' (continuation, name not said)' : ''} · ${L.last_beef_judgement.quality}/10 · ${_aEsc(L.last_beef_judgement.about || '')}${L.last_beef_judgement.opened ? ' · <b>beef opened</b>' : ''}${L.last_beef_judgement.bounty ? ' · <b>bounty collected</b>' : ''}` : L.last_beef_judgement.about_target ? `still about them, not shit talk — ${_aEsc(L.last_beef_judgement.about || '')}` : `moved on — ${_aEsc(L.last_beef_judgement.about || 'not about them')}`}${L.last_beef_judgement.flagged ? ' · <span class="arena-tag arena-tag-dim">line not counted (threat/minor/dox)</span>' : ''}</div>` : ''}
${L.last_topic_judgement ? `<div class="arena-judgement ${L.last_topic_judgement.applied ? 'is-hit' : ''}"><b><i class="fa-solid fa-gavel"></i> Subject judge (${_aEsc(_aAgo(L.last_topic_judgement.at))}) · ${_aEsc(L.last_topic_judgement.topic || '')}:</b> ${L.last_topic_judgement.applied ? `MOMENT · ${L.last_topic_judgement.quality}/10 · +${L.last_topic_judgement.xp} XP · ${_aEsc(L.last_topic_judgement.about || '')}` : `not about it — ${_aEsc(L.last_topic_judgement.about || 'say the subject')}`}</div>` : ''}
</section>
<aside class="arena-console-aside">
@@@ -702,7 +727,7 @@ async function _aRenderConsole(root, username) {
${c.open_beefs?.length ? c.open_beefs.map(b => `<div class="arena-console-beef">${_aA(_aBeefLink(b), `<b>${_aEsc(b.headline || `${b.a.fighter_name} vs ${b.b.fighter_name}`)}</b>`)}${_aTug(b)}${_aClockTag(b)}</div>`).join('') : `<p class="arena-note">None. ${mine ? 'Say another fighter\'s name while talking shit and one opens.' : ''}</p>`}
</section>
${c.bounty_on_me ? `<section><h3><i class="fa-solid fa-sack-dollar"></i> Bounty on ${_aEsc(f.fighter_name)}</h3>${_aA(_aTopicLink(c.bounty_on_me), _aEsc(c.bounty_on_me.headline || c.bounty_on_me.text))}<p class="arena-note">Everyone gets double XP for talking shit about ${mine ? 'you' : 'them'} until it expires. ${mine ? 'Answer on mic.' : ''}</p></section>` : ''}
-- <section><h3><i class="fa-solid fa-circle-info"></i> How it's judged</h3><ul class="arena-rules-list"><li>Every 15 s the ears read new transcript lines.</li><li>A fighter's name in a line opens a 45 s window; ≥20 words go to the beef judge.</li><li>Saying a board subject's keywords adds a moment on it and auto-joins you; ≥20 words about it go to the subject judge (≥30 s apart).</li><li>Offensive language is fine. Threats, minors, doxxing → line ignored.</li></ul></section>
++ <section><h3><i class="fa-solid fa-circle-info"></i> How it's judged</h3><ul class="arena-rules-list"><li>Every 15 s the ears read new transcript lines.</li><li>A fighter's name (however the transcriber spells it — split, glued, misheard) locks the ears on them; ≥20 words go to the beef judge, and everything after counts as a continuation until they move on.</li><li>Saying a board subject's keywords adds a moment on it and auto-joins you; ≥20 words about it go to the subject judge (≥30 s apart).</li><li>Offensive language is fine. Threats, minors, doxxing → line ignored.</li></ul></section>
</aside>
</div>
</div>`;
@@@ -787,6 +812,7 @@ async function _aRenderFighter(root, username) {
<div class="arena-record arena-record-lg" title="beef record">${f.record.wins}W – ${f.record.losses}L</div>
</div>
${_aLevelCard(f)}
++ ${(p.custom_stats || []).length ? `<div class="arena-profile-custom"><div>${_aCustomRadar(f, color, 220)}<div class="arena-mini-record">their own stats — AI-read from their chat history and streams</div></div>${_aCustomQuips(f)}</div>` : ''}
<div class="arena-profile-stats">
${_aRadar(f.ratings, color)}
<div class="arena-quips">${ARENA_STATS.map(k => `<div class="arena-quip is-clickable" data-stat="${k}" title="Tap for the breakdown"><b>${_aEsc(ARENA_STAT_LABEL[k])} ${f.ratings[k]} <i class="fa-solid fa-magnifying-glass-chart" style="font-size:0.7em;opacity:0.6"></i></b><span>${_aEsc((p.stat_quips || {})[k] || '')}</span></div>`).join('')}</div>
ddiff --git a/server/arena/arena-service.js b/server/arena/arena-service.js
iindex 1ab83510..da9cbfe2 100644
---- a/server/arena/arena-service.js
++++ b/server/arena/arena-service.js
@@@ -264,16 +264,26 @@ function gatherContext(userId) {
const ctx = {};
try { ctx.overview = db.getStreamerOverview(userId)?.overview || null; } catch { /* */ }
try { ctx.memories = db.all('SELECT description FROM stream_memories WHERE user_id = ? ORDER BY captured_at DESC LIMIT 4', [userId]).map(m => m.description).filter(Boolean); } catch { ctx.memories = []; }
++ // Who they are AS A CHATTER — the chat AI's profile of them (overview + long-term memory +
++ // timeline of notable moments). This is the primary source for their voice and personality.
try {
const chatAi = db.getChatAiSummary('user', userId, 'rolling');
-- if (chatAi) { const ov = parseJson(chatAi.overview, null); ctx.chat_notes = ov ? [ov.alltime, ov.today].filter(Boolean).join(' ') : String(chatAi.overview || ''); }
++ if (chatAi) {
++ const ov = parseJson(chatAi.overview, null);
++ ctx.chat_notes = ov ? [ov.alltime, ov.today].filter(Boolean).join(' ') : String(chatAi.overview || '');
++ const mem = parseJson(chatAi.memory_json, null);
++ ctx.chatter_memory = typeof mem === 'string' ? mem.slice(0, 900) : (mem ? JSON.stringify(mem).slice(0, 900) : (chatAi.memory_json ? String(chatAi.memory_json).slice(0, 900) : null));
++ const tl = parseJson(chatAi.timeline_json, []);
++ ctx.chatter_timeline = Array.isArray(tl) ? tl.slice(-10).map(e => (typeof e === 'string' ? e : `${e.label || e.title || ''}${e.detail ? `: ${e.detail}` : ''}`).slice(0, 160)).filter(Boolean) : [];
++ }
} catch { /* */ }
++ try { ctx.chatter_timeline = [...(ctx.chatter_timeline || []), ...db.all(`SELECT label, detail FROM chat_timeline_events WHERE scope = 'user' AND subject_id = ? ORDER BY id DESC LIMIT 8`, [userId]).map(r => `${r.label}${r.detail ? `: ${r.detail}` : ''}`.slice(0, 160))].slice(0, 14); } catch { /* */ }
try { ctx.vods = db.all(`SELECT v.title, va.ai_overview_short AS overview FROM vods v LEFT JOIN vod_ai_state va ON va.vod_id = v.id WHERE v.user_id = ? AND v.is_public = 1 ORDER BY v.created_at DESC LIMIT 5`, [userId]).map(v => ({ title: v.title, overview: v.overview || null })); }
catch { try { ctx.vods = db.all('SELECT title FROM vods WHERE user_id = ? ORDER BY created_at DESC LIMIT 5', [userId]).map(v => ({ title: v.title })); } catch { ctx.vods = []; } }
try { ctx.titles = db.all('SELECT DISTINCT title FROM streams WHERE user_id = ? AND duration_seconds > 0 ORDER BY started_at DESC LIMIT 8', [userId]).map(r => r.title).filter(Boolean); } catch { ctx.titles = []; }
try { ctx.said = db.all(`SELECT text FROM stream_timeline_events WHERE user_id = ? AND kind = 'speech' AND LENGTH(text) BETWEEN 30 AND 140 ORDER BY created_at DESC LIMIT 24`, [userId]).map(r => r.text).filter(t => !isBannedText(t)).slice(0, 12); } catch { ctx.said = []; }
// How they type when they are a chatter themselves (their own chat lines, newest first) — the taunts copy this voice.
-- try { ctx.typed = db.all(`SELECT message FROM chat_messages WHERE user_id = ? AND COALESCE(is_deleted, 0) = 0 AND message NOT LIKE '!%' AND LENGTH(message) BETWEEN 6 AND 200 ORDER BY id DESC LIMIT 40`, [userId]).map(r => r.message).filter(t => !isBannedText(t)).slice(0, 20); } catch { ctx.typed = []; }
++ try { ctx.typed = db.all(`SELECT c.message, s.user_id AS room_owner FROM chat_messages c LEFT JOIN streams s ON s.id = c.stream_id WHERE c.user_id = ? AND COALESCE(c.is_deleted, 0) = 0 AND c.message NOT LIKE '!%' AND LENGTH(c.message) BETWEEN 4 AND 240 ORDER BY c.id DESC LIMIT 80`, [userId]).filter(r => !isBannedText(r.message)).slice(0, 40).map(r => { const owner = r.room_owner && r.room_owner !== userId ? db.getUserById(r.room_owner)?.username : null; return owner ? `[in ${owner}'s chat] ${r.message}` : (r.room_owner ? `[own chat] ${r.message}` : `[global] ${r.message}`); }); } catch { ctx.typed = []; }
try { ctx.chat_rooms = db.all(`SELECT u.username, COUNT(*) AS n FROM chat_messages c JOIN streams s ON s.id = c.stream_id JOIN users u ON u.id = s.user_id WHERE c.user_id = ? AND s.user_id != ? GROUP BY u.username ORDER BY n DESC LIMIT 4`, [userId, userId]).map(r => `${r.username} (${r.n} msgs)`); } catch { ctx.chat_rooms = []; }
return ctx;
}
@@@ -282,7 +292,7 @@ const PERSONA_SCHEMA = {
name: 'arena_persona',
schema: {
type: 'object', additionalProperties: false,
-- required: ['fighter_name', 'title', 'class', 'element', 'signature_move', 'special', 'weakness', 'taunt', 'taunts', 'typing_style', 'lore', 'catchphrase', 'entrance_music', 'stat_quips'],
++ required: ['fighter_name', 'title', 'class', 'element', 'signature_move', 'special', 'weakness', 'taunt', 'taunts', 'typing_style', 'spoken_as', 'custom_stats', 'lore', 'catchphrase', 'entrance_music', 'stat_quips'],
properties: {
fighter_name: { type: 'string', description: 'Arena ring name, 2–5 words, based on the streamer' },
title: { type: 'string', description: 'Epithet like "The Midnight Menace of Cozy Corner"' },
@@@ -293,13 +303,16 @@ const PERSONA_SCHEMA = {
weakness: { type: 'string' }, taunt: { type: 'string', description: 'Their signature ragebait line: one sentence, written EXACTLY the way this person types/talks (their punctuation, caps, slang, emoji habits, typos), aimed at rivals or their chat, designed to make people reply' },
taunts: { type: 'array', minItems: 3, maxItems: 3, items: { type: 'string' }, description: 'three more ragebait/troll lines in their own voice: one at a specific rival from the roster, one at their own chat, one about a topic they will not shut up about' },
typing_style: { type: 'string', description: '≤ 12 words describing how they type/talk (e.g. "all lowercase, no punctuation, calls everyone bud")' },
++ custom_stats: { type: 'array', minItems: 6, maxItems: 6, items: { type: 'object', additionalProperties: false, required: ['name', 'value', 'quip'], properties: { name: { type: 'string', description: '≤ 14 chars, a stat that only makes sense for THIS person — named after their actual bits, habits, subjects, gear, rivals, chat culture (e.g. "Alt Accounts", "Tent Smell", "Baby Voice", "Reads Chat", "Cope")' }, value: { type: 'integer', minimum: 1, maximum: 99 }, quip: { type: 'string', description: '≤ 8 words, in their own typing voice' } } }, description: 'six stats unique to this streamer for their own radar chart — funny, specific, ragebait-ish; values spread across the range (at least one under 30, at least one over 85)' },
++ spoken_as: { type: 'array', minItems: 2, maxItems: 6, items: { type: 'string' }, description: 'how OTHER people say or mishear this streamer\'s name out loud, as a speech-to-text engine would write it: nicknames, shortenings, phonetic misspellings, the handle split into words (e.g. "japanese old guy", "matticus", "goose lee", "dan") — lowercase' },
lore: { type: 'string', description: '2–3 sentence character-select bio' }, catchphrase: { type: 'string' }, entrance_music: { type: 'string' },
stat_quips: { type: 'object', additionalProperties: false, required: STAT_KEYS, properties: Object.fromEntries(STAT_KEYS.map(k => [k, { type: 'string', description: `≤ 8 words explaining their ${k} rating in character` }])) },
},
},
};
const PERSONA_SYSTEM = `You write fighting-game "character select" bios for a live-streaming site's Arena, where streamers rank each other and talk shit about each other on stream. This is a banter arena: be savage, profane, petty and funny — roast their habits, their chat, their schedule, their takes, their gimmicks, how they talk (transcript lines are provided). Trolling energy is welcome; make it quotable.
--TAUNTS ARE THE MOST IMPORTANT PART. They must be RAGEBAIT: provocative, trolly, specific, the kind of line that makes a rival or a chat reply instantly — never generic gamer fluff like "try to keep up". Write every taunt in THIS PERSON'S OWN VOICE: study \`things_they_typed_in_chat\` (how they type as a chatter: caps, punctuation, slang, emoji, typos, pet names) and \`things_they_said_on_stream\`, plus \`ai_overview\` and \`chat_ai_notes\` (what they are known for, their opinions, their beefs, what they will not shut up about) and \`rooms_they_lurk_in\` (whose chat they hang in). Reference real recurring subjects, rivals from the roster and their obsessions. If there is no chat/transcript data, infer a voice from the overview and category and say so in typing_style.
++VOICE COMES FIRST, AND IT COMES FROM THEIR CHAT HISTORY. The PRIMARY sources are \`things_they_typed_in_chat\` (verbatim lines they typed, newest first, with the room), \`chat_ai_notes\`, \`chatter_memory\` and \`chatter_timeline\` (the chat AI's profile of them as a chatter: what they are known for, their bits, opinions, grudges, who they mess with). Their streaming profile (\`ai_overview\`, \`things_they_said_on_stream\`, numbers) is SECONDARY flavor to mix in. Copy their actual typing: casing (if they type lowercase, every line is lowercase), punctuation or the lack of it, slang, emoji, typos, pet names, sentence length. A taunt must read like a message THEY would actually send in chat — not a movie trailer, not a wrestling promo, no ALL-CAPS unless they type that way, no invented catchphrases, never repeat a word for effect, no "watch me"/"try to keep up" filler.
++TAUNTS ARE THE MOST IMPORTANT PART. They must be RAGEBAIT in that voice: provocative, trolly, specific, petty — the kind of line that makes a rival or a chat reply instantly. Reference their real recurring subjects, rivals from the roster, rooms they lurk in and their obsessions. stat_quips are ALSO in their voice (≤ 8 words each, how THEY would describe that stat). If there is no chat data at all, build the voice from the transcript lines; if neither exists, infer it from the overview and say so in typing_style.
This platform does not censor language — offensive and taboo words are allowed. The only hard line: no direct threats of violence, nothing sexual about minors, no doxxing. Everything is Arena lore, not real-life claims. Output only the JSON.`;
async function generatePersona(userId, { force = false } = {}) {
@@@ -316,7 +329,7 @@ async function generatePersona(userId, { force = false } = {}) {
numbers: { hours_live_90d: entry.raw.hours, peak_viewers_90d: entry.raw.peak_viewers, avg_viewers: entry.raw.avg_viewers, chat_messages_per_hour: entry.raw.messages_per_hour, followers: entry.raw.followers, clips: entry.raw.clips, all_time_hours: entry.raw.all_time_hours, all_time_peak: entry.raw.all_time_peak,
on_mic: entry.raw.voice.has_data ? { talk_share_pct: entry.raw.voice.talk_ratio_pct, words_per_minute: entry.raw.voice.wpm, hype_words_per_hour: entry.raw.voice.hype_per_hour, laughs_per_hour: entry.raw.voice.laughs_per_hour, stream_sounds: entry.raw.voice.top_sounds.map(s => s.label) } : 'no transcript data yet' },
ai_overview: ctx.overview, recent_stream_titles: ctx.titles, what_the_camera_saw_recently: ctx.memories, chat_ai_notes: ctx.chat_notes, recent_vods: ctx.vods, things_they_said_on_stream: ctx.said,
-- things_they_typed_in_chat: ctx.typed, rooms_they_lurk_in: ctx.chat_rooms,
++ things_they_typed_in_chat: ctx.typed, chatter_memory: ctx.chatter_memory || null, chatter_timeline: ctx.chatter_timeline || [], rooms_they_lurk_in: ctx.chat_rooms,
roster_rivals: (() => { try { return loadRoster().order.filter(id => id !== userId).slice(0, 8).map(id => { const p = parseJson(profileRow(id)?.persona_json); return `${loadRoster().byId[id].user.username}${p?.fighter_name ? ` (${p.fighter_name})` : ''}`; }); } catch { return []; } })(),
};
const r = await llm.complete({ role: 'summary', kind: 'arena_persona', source: 'arena', ownerUserId: userId, system: PERSONA_SYSTEM, user: `Write the Arena persona for this fighter. Facts (JSON):\n${JSON.stringify(facts)}`, json: PERSONA_SCHEMA, maxTokens: 1200, temperature: 0.95, timeoutMs: 30000 });
@@@ -338,7 +351,7 @@ function fallbackPersona(entry) {
signature_move: { name: `${STAT_META[best].label} Surge`, description: `Turns ${STAT_META[best].desc} into raw damage.` },
special: { name: 'Go Live', description: 'Hits the button. The arena fills up.' }, weakness: 'Sleep schedules.', taunt: 'Chat, are you seeing this?',
lore: `${entry.user.display_name} shows up, streams, and leaves the leaderboard slightly different than they found it.`,
-- catchphrase: 'Let him cook.', entrance_music: 'Untitled Loop (feat. Notification Sound)', taunts: [], typing_style: null,
++ catchphrase: 'Let him cook.', entrance_music: 'Untitled Loop (feat. Notification Sound)', taunts: [], typing_style: null, spoken_as: [], custom_stats: [],
stat_quips: Object.fromEntries(STAT_KEYS.map(k => [k, STAT_META[k].desc])), _fallback: true,
};
}
@@@ -512,7 +525,7 @@ function listFighters() {
const c = cardFor(id, roster, { includeRaw: false });
return {
user: c.user, rank: c.rank, ratings: c.ratings, record: c.record, live: c.live, image_url: c.image_url, level: { level: c.level.level, xp: c.level.xp },
-- persona: { fighter_name: c.persona.fighter_name, title: c.persona.title, class: c.persona.class, element: c.persona.element, taunt: c.persona.taunt, lore: c.persona.lore, signature_move: c.persona.signature_move, stat_quips: c.persona.stat_quips },
++ persona: { fighter_name: c.persona.fighter_name, title: c.persona.title, class: c.persona.class, element: c.persona.element, taunt: c.persona.taunt, taunts: c.persona.taunts || [], typing_style: c.persona.typing_style || null, lore: c.persona.lore, signature_move: c.persona.signature_move, stat_quips: c.persona.stat_quips, custom_stats: Array.isArray(c.persona.custom_stats) ? c.persona.custom_stats : [] },
persona_is_fallback: c.persona_is_fallback, category: roster.byId[id].raw.category, last_live_at: roster.byId[id].raw.last_live_at,
voice: { has_data: c.voice.has_data, talk_ratio_pct: c.voice.talk_ratio_pct, speech_minutes: c.voice.speech_minutes },
};
ddiff --git a/server/arena/board.js b/server/arena/board.js
iindex ba44c6bb..05146d1a 100644
---- a/server/arena/board.js
++++ b/server/arena/board.js
@@@ -231,7 +231,7 @@ function createTopic({ text, hint = null, createdBy, creatorUserId = null, creat
if (dup) throw new Error('That topic is already on the board');
let kws = normalizeKeywords(keywords);
if (!kws.length) kws = keywordsFromText(clean);
-- if (kind === 'bounty' && targetUserId) { const u = db.getUserById(targetUserId); if (u) kws = normalizeKeywords([...kws, u.username, u.display_name, u.username.replace(/[_.-]+/g, ' ')]); }
++ if (kind === 'bounty' && targetUserId) { const u = db.getUserById(targetUserId); if (u) { const nm = require('./names'); kws = normalizeKeywords([...kws, u.username, u.display_name, ...nm.variants(u.username), ...nm.variants(u.display_name)]); } }
if (kind === 'topic' && kws.length >= 2) {
for (const m of openTopicMatchers()) { if (m.kind === 'topic' && m.keywords.filter(k => kws.includes(k)).length >= 2) throw new Error('That subject is already on the board'); }
}
ddiff --git a/server/arena/listener.js b/server/arena/listener.js
iindex ef76d5e8..0ec29b16 100644
---- a/server/arena/listener.js
++++ b/server/arena/listener.js
@@@ -28,9 +28,7 @@ const llm = require('../ai/llm');
const TICK_MS = 15 * 1000;
const JUDGE_MIN_WORDS = 20;
const JUDGE_MIN_INTERVAL_MS = 30 * 1000;
--const MENTION_TAIL_SEC = 45; // lines within this many seconds after a name-drop stay in the buffer
const BUFFER_MAX_CHARS = 1400;
--const STALE_BUFFER_MS = 3 * 60 * 1000;
const state = new Map(); // streamId → { userId, lastOffset, lastJudgeAt, mention: { [targetId]: { lines, lastAt } }, topic: { lines } }
@@@ -42,7 +40,12 @@ function parseJson(t, f = null) { try { return t ? JSON.parse(t) : f; } catch {
function words(t) { return String(t || '').split(/\s+/).filter(Boolean).length; }
// ── Aliases: who can be called out, by which names ───────────
++// Every predicted spoken form of every roster name (see names.js): camelCase and snake_case
++// split the way a transcriber hears them, digits and decorations dropped, leet undone, plus the
++// persona's AI-written "spoken_as" list (nicknames, misspellings). Matching is exact → fuzzy →
++// phonetic, so "Matticus", "japanese-old-guy" and "goose lee" all resolve.
++const names = require('./names');
let _aliasCache = { at: 0, list: [] };
function aliases(roster) {
if (Date.now() - _aliasCache.at < 60 * 1000) return _aliasCache.list;
@@@ -50,14 +53,8 @@ function aliases(roster) {
for (const id of roster.order) {
const f = roster.byId[id];
const persona = parseJson(db.get('SELECT persona_json FROM arena_profiles WHERE user_id = ?', [id])?.persona_json);
-- const names = new Set([f.user.username, f.user.display_name, persona?.fighter_name].filter(Boolean).map(s => String(s).toLowerCase().trim()));
-- for (const n of names) {
-- if (n.length < 3) continue;
-- // "@name", "name", and names with underscores/dots spoken as spaces.
-- const spoken = n.replace(/[_.-]+/g, ' ');
-- const variants = new Set([n, spoken]);
-- for (const v of variants) list.push({ userId: id, name: v, re: new RegExp(`(?:^|[^a-z0-9])@?${v.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![a-z0-9])`, 'i') });
-- }
++ const spoken = Array.isArray(persona?.spoken_as) ? persona.spoken_as : [];
++ list.push(...names.aliasEntries(id, [f.user.username, f.user.display_name, persona?.fighter_name, ...spoken]));
}
// Longer names first so "goosely" beats "goose" style prefixes.
list.sort((a, b) => b.name.length - a.name.length);
@@@ -65,13 +62,13 @@ function aliases(roster) {
return list;
}
++/** User ids mentioned in a line (never the speaker). */
function mentionsIn(text, speakerId, roster) {
-- const found = new Set();
-- for (const a of aliases(roster)) {
-- if (a.userId === speakerId || found.has(a.userId)) continue;
-- if (a.re.test(text)) found.add(a.userId);
-- }
-- return [...found];
++ return names.findMentions(text, aliases(roster), { excludeUserId: speakerId }).map(m => m.userId);
++}
++/** Same, with how each was matched — for the console. */
++function mentionsDetailed(text, speakerId, roster) {
++ return names.findMentions(text, aliases(roster), { excludeUserId: speakerId });
}
// ── Judges ───────────────────────────────────────────────────
@@@ -80,9 +77,10 @@ const BEEF_SCHEMA = {
name: 'arena_beef_judgement',
schema: {
type: 'object', additionalProperties: false,
-- required: ['aimed_at_target', 'quality', 'best_line', 'about', 'announcer', 'flagged'],
++ required: ['about_target', 'aimed_at_target', 'quality', 'best_line', 'about', 'announcer', 'flagged'],
properties: {
-- aimed_at_target: { type: 'boolean', description: 'true if the speaker is trash-talking, roasting, calling out or bragging AT/ABOUT the named target (in good fun)' },
++ about_target: { type: 'boolean', description: 'true if this speech is (still) about the target — by name OR by reference ("he", "his stream", "that guy", "the beef", continuing the same rant)' },
++ aimed_at_target: { type: 'boolean', description: 'true if the speaker is trash-talking, roasting, calling out, dunking on or bragging over the target (in good fun) — requires about_target' },
announcer: { type: 'string', description: 'one-line ring-announcer call of this moment, ≤ 110 chars, hype and funny (empty if not aimed at the target)' },
quality: { type: 'integer', minimum: 0, maximum: 10, description: 'how good the trash talk is: spice, wit, specificity, quotability' },
best_line: { type: 'string', description: 'the single best line, VERBATIM from the speech (empty if none)' },
@@@ -91,7 +89,7 @@ const BEEF_SCHEMA = {
},
},
};
--const BEEF_SYSTEM = `You judge live streamer-vs-streamer shit talk. You get a target fighter's name(s) and the last ~60 seconds of a streamer's speech-to-text in which the target was named. Decide if the speaker is talking shit AT or ABOUT the target (roast, callout, disrespect, bragging over them, trolling them) versus just mentioning them neutrally (shout-out, thanks, gameplay chatter). Savage, profane, petty and personal is GOOD and scores high — creativity, specificity and quotability score highest; lazy generic insults score low. Pick the best verbatim line, summarise what was said about the target in ≤ 10 words. Forgive transcription noise. Offensive, provocative and taboo language — slurs included — is allowed on this platform and is NOT a reason to flag or to score low; judge the craft. Flag ONLY behavior: direct threats of violence, sexual content about minors, doxxing (addresses, phone numbers, private info). Output only the JSON.`;
++const BEEF_SYSTEM = `You judge live streamer-vs-streamer shit talk. You get a target fighter's name(s), what the speaker ALREADY said about them a moment ago (context), and the newest ~60 seconds of the speaker's speech-to-text. The target may not be named again in the new speech — decide from context whether they are STILL talking about that person ("he", "his chat", "that guy", "the beef", the same story) or have moved on to something else. Then decide if the speech is talking shit AT or ABOUT the target (roast, callout, disrespect, bragging over them, trolling them) versus neutral mention (shout-out, thanks, gameplay chatter). Savage, profane, petty and personal is GOOD and scores high — creativity, specificity and quotability score highest; lazy generic insults score low. Pick the best verbatim line, summarise what was said about the target in ≤ 10 words. Forgive transcription noise and misheard names. Offensive, provocative and taboo language — slurs included — is allowed on this platform and is NOT a reason to flag or to score low; judge the craft. Flag ONLY behavior: direct threats of violence, sexual content about minors, doxxing (addresses, phone numbers, private info). Output only the JSON.`;
const TOPIC_SCHEMA = {
name: 'arena_topic_judgement',
@@@ -109,16 +107,19 @@ const TOPIC_SCHEMA = {
};
const TOPIC_SYSTEM = `You judge a live streamer talking about a SUBJECT the community is on about (a person, a group, a joke, a drama…). You get the subject, its current lore, the keywords people use for it, and the last ~60 seconds of the streamer's speech-to-text. Decide whether the speech is really about the subject (shit talk, a take, a rant, a bit, dunking, defending — all count), score its quality (savage, specific, petty, funny, quotable = high; generic = low), pick the best verbatim line and summarise in ≤ 10 words. Ordinary gameplay chatter or unrelated talk → on_topic false. Forgive transcription noise. Offensive, provocative and taboo language — slurs included — is allowed on this platform and is NOT a reason to flag or to score low; judge the craft. Flag ONLY behavior: direct threats of violence, sexual content about minors, doxxing. Output only the JSON.`;
--function heuristicBeef(text, targetNames) {
++function heuristicBeef(text, targetNames, { named = true } = {}) {
const t = text.toLowerCase();
-- const spicy = /\b(clown|weak|scared|duck|ducking|trash|garbage|mid|washed|bum|ratio|cook|cooked|better than|can't|cannot|never|nobody|beat|fraud|ass|bet|catch (these|this)|come see|fight me|square up|run it|talk (that|your))\b/.test(t);
++ const spicy = /\b(clown|weak|scared|duck|ducking|trash|garbage|mid|washed|bum|ratio|cook|cooked|better than|can't|cannot|never|nobody|beat|fraud|ass|bet|catch (these|this)|come see|fight me|square up|run it|talk (that|your)|cope|seethe|cry|loser|bozo|goofy|clown)\b/.test(t);
++ const pronouns = /\b(he|him|his|she|her|they|them|their|that (guy|dude|man|girl|streamer)|this (guy|dude|man|girl|streamer)|the (guy|dude|beef)|bro's|bros)\b/.test(t);
++ const aboutTarget = named || pronouns;
const excl = (text.match(/!/g) || []).length;
const quality = Math.min(10, (spicy ? 5 : 1) + excl + (words(text) > 30 ? 1 : 0));
const sentences = text.split(/(?<=[.!?])\s+/).filter(Boolean);
-- const named = sentences.filter(l => targetNames.some(n => l.toLowerCase().includes(n)));
-- const pick = (named.length ? named : sentences).sort((a, b) => b.length - a.length)[0] || text;
-- return { aimed_at_target: spicy, quality, best_line: pick.trim().slice(0, 200), about: text.split(/\s+/).slice(0, 8).join(' '), flagged: false, _fallback: true };
++ const namedLines = sentences.filter(l => targetNames.some(n => l.toLowerCase().includes(n)));
++ const pick = (namedLines.length ? namedLines : sentences).sort((a, b) => b.length - a.length)[0] || text;
++ return { about_target: aboutTarget, aimed_at_target: aboutTarget && spicy, quality, best_line: pick.trim().slice(0, 200), about: text.split(/\s+/).slice(0, 8).join(' '), flagged: false, _fallback: true };
}
++
function heuristicTopic(text, topic) {
const kws = (parseJson(topic.keywords_json, []) || []).map(k => String(k).toLowerCase());
const t = text.toLowerCase();
@@@ -131,19 +132,23 @@ function heuristicTopic(text, topic) {
return { on_topic: hits > 0, quality, best_line: ((named.length ? named : sentences).sort((a, b) => b.length - a.length)[0] || text).trim().slice(0, 200), about: text.split(/\s+/).slice(0, 8).join(' '), flagged: false, _fallback: true };
}
--async function judgeBeef(speakerId, targetId, text, roster) {
-- if (arena()._isBannedText(text)) return { aimed_at_target: false, quality: 0, best_line: '', about: 'voided', flagged: true };
++async function judgeBeef(speakerId, targetId, text, roster, { context = null, named = true } = {}) {
++ if (arena()._isBannedText(text)) return { about_target: false, aimed_at_target: false, quality: 0, best_line: '', about: 'voided', flagged: true };
const tf = roster.byId[targetId];
const targetNames = [tf.user.username, tf.user.display_name, (parseJson(db.get('SELECT persona_json FROM arena_profiles WHERE user_id = ?', [targetId])?.persona_json) || {}).fighter_name].filter(Boolean);
++ const spokenForms = [...new Set(targetNames.flatMap(n => names.variants(n)))].slice(0, 8);
let j = null;
if (aiOn()) {
try {
-- const r = await llm.complete({ role: 'chat', kind: 'arena_beef_judge', source: 'arena', ownerUserId: speakerId, system: BEEF_SYSTEM, user: JSON.stringify({ target_names: targetNames, speech: text }), json: BEEF_SCHEMA, maxTokens: 220, temperature: 0.4, timeoutMs: 25000 });
++ const r = await llm.complete({ role: 'chat', kind: 'arena_beef_judge', source: 'arena', ownerUserId: speakerId, system: BEEF_SYSTEM,
++ user: JSON.stringify({ target_names: targetNames, target_as_transcribed: spokenForms, target_named_in_new_speech: named, what_speaker_already_said_about_target: context || null, new_speech: text }),
++ json: BEEF_SCHEMA, maxTokens: 240, temperature: 0.4, timeoutMs: 25000 });
if (r && r.json && typeof r.json.quality === 'number') j = r.json;
} catch (e) { console.warn('[Arena] beef judge:', e.message); }
}
-- if (!j) j = heuristicBeef(text, targetNames.map(n => n.toLowerCase()));
-- return { aimed_at_target: !!j.aimed_at_target && !j.flagged, quality: Math.max(0, Math.min(10, Math.round(Number(j.quality) || 0))), best_line: String(j.best_line || '').slice(0, 220), about: String(j.about || '').slice(0, 80), announcer: String(j.announcer || '').slice(0, 140), flagged: !!j.flagged, fallback: !!j._fallback };
++ if (!j) j = heuristicBeef(text, spokenForms.length ? spokenForms : targetNames.map(n => n.toLowerCase()), { named });
++ const about = (j.about_target !== false) && !j.flagged;
++ return { about_target: about, aimed_at_target: about && !!j.aimed_at_target, quality: Math.max(0, Math.min(10, Math.round(Number(j.quality) || 0))), best_line: String(j.best_line || '').slice(0, 220), about: String(j.about || '').slice(0, 80), announcer: String(j.announcer || '').slice(0, 140), flagged: !!j.flagged, fallback: !!j._fallback };
}
async function judgeTopic(speakerId, topic, text) {
@@@ -178,14 +183,67 @@ function lineRefFor(lines, bestLine) {
return hit ? { vod_id: hit.v || null, sec: Math.max(0, hit.s - 2) } : { vod_id: null, sec: null };
}
++/**
++ * Per-stream focus: after a fighter's name is said, the listener LOCKS ON to that fighter. Every
++ * following line goes to the beef judge with the earlier context, so "…and his chat is 12 alts,
++ * he's scared of the smoke" still counts without the name being repeated. Each judged hit
++ * extends the lock (FOCUS_EXTEND_MS); two chunks in a row that are not about the target, or
++ * FOCUS_MAX_MS since the last time they were actually named, drop it. A different name-drop
++ * switches focus (the pending chunk is judged first if it is big enough).
++ */
++const FOCUS_TAIL_MS = 2 * 60 * 1000; // how long a bare name-drop keeps the ears on the target
++const FOCUS_EXTEND_MS = 3 * 60 * 1000; // every hit while locked extends the lock by this
++const FOCUS_MAX_MS = 20 * 60 * 1000; // hard cap without a fresh name-drop
++const FOCUS_MISSES_TO_DROP = 2;
++
++function newFocus(targetId, now, how) { return { targetId, since: now, namedAt: now, lockUntil: now + FOCUS_TAIL_MS, lines: [], misses: 0, hits: 0, context: null, how }; }
++
++async function judgeFocus(stream, roster, st, events, { reason }) {
++ const f = st.focus;
++ if (!f || !f.lines.length) return false;
++ const text = bufferText(f.lines);
++ const lines = f.lines; f.lines = [];
++ const named = lines.some(l => l.named);
++ st.lastJudgeAt = Date.now();
++ const j = await judgeBeef(stream.user_id, f.targetId, text, roster, { context: f.context, named });
++ const now = Date.now();
++ if (j.aimed_at_target) {
++ const ref = lineRefFor(lines, j.best_line);
++ const res = 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 });
++ f.hits++; f.misses = 0; f.lockUntil = Math.min(now + FOCUS_EXTEND_MS, f.namedAt + FOCUS_MAX_MS);
++ f.context = `${f.context ? f.context + ' | ' : ''}${j.about}${j.best_line ? ` ("${j.best_line.slice(0, 120)}")` : ''}`.slice(-600);
++ st.lastBeefJudgement = { at: new Date().toISOString(), target_id: f.targetId, ...j, opened: res?.opened, bounty: res?.bounty, named, reason };
++ events.push({ kind: 'beef_hit', streamId: stream.id, speakerId: stream.user_id, targetId: f.targetId, opened: res?.opened, quality: j.quality, line: j.best_line, named, continued: !named });
++ return true;
++ }
++ if (j.about_target) {
++ // Still on the subject but not shit talk (a story, a shout-out) — keep listening a little.
++ f.misses = 0; f.lockUntil = Math.min(Math.max(f.lockUntil, now + FOCUS_TAIL_MS / 2), f.namedAt + FOCUS_MAX_MS);
++ f.context = `${f.context ? f.context + ' | ' : ''}(neutral) ${j.about}`.slice(-600);
++ st.lastBeefJudgement = { at: new Date().toISOString(), target_id: f.targetId, ...j, named, reason };
++ events.push({ kind: 'beef_neutral', streamId: stream.id, speakerId: stream.user_id, targetId: f.targetId, about: j.about, named });
++ return true;
++ }
++ f.misses++;
++ st.lastBeefJudgement = { at: new Date().toISOString(), target_id: f.targetId, ...j, named, reason };
++ events.push({ kind: 'beef_miss', streamId: stream.id, speakerId: stream.user_id, targetId: f.targetId, about: j.about, named });
++ if (f.misses >= FOCUS_MISSES_TO_DROP) { events.push({ kind: 'focus_dropped', streamId: stream.id, targetId: f.targetId, hits: f.hits }); st.focus = null; }
++ return true;
++}
++
async function tickStream(stream, roster, events) {
let st = state.get(stream.id);
-- if (!st) { st = { userId: stream.user_id, lastOffset: streamOffsetNow(stream) - 20, lastJudgeAt: 0, mention: {}, topic: { lines: [] } }; state.set(stream.id, st); }
++ if (!st) { st = { userId: stream.user_id, lastOffset: streamOffsetNow(stream) - 20, lastJudgeAt: 0, focus: null, topic: { lines: [] } }; state.set(stream.id, st); }
const rows = db.all(`SELECT text, start_sec, vod_id FROM stream_timeline_events WHERE stream_id = ? AND kind = 'speech' AND start_sec > ? ORDER BY start_sec ASC LIMIT 100`, [stream.id, st.lastOffset]);
if (rows.length) st.lastOffset = rows[rows.length - 1].start_sec;
const now = Date.now();
++ if (st.focus && now > st.focus.lockUntil) {
++ // Lock expired: judge whatever is pending if it is worth a call, then let go.
++ if (st.focus.lines.length && st.focus.lines.reduce((n, l) => n + words(l.t), 0) >= JUDGE_MIN_WORDS && now - st.lastJudgeAt >= JUDGE_MIN_INTERVAL_MS) await judgeFocus(stream, roster, st, events, { reason: 'lock expired' });
++ if (st.focus) { events.push({ kind: 'focus_dropped', streamId: stream.id, targetId: st.focus.targetId, hits: st.focus.hits, why: 'timeout' }); st.focus = null; }
++ }
for (const r of rows) {
-- const line = { t: String(r.text || ''), s: Math.floor(r.start_sec), v: r.vod_id || null };
++ const line = { t: String(r.text || ''), s: Math.floor(r.start_sec), v: r.vod_id || null, named: false };
// Board subjects said on mic → a moment on the topic (raw mention; the judge scores the chunk later).
try {
for (const t of board().matchTopics(line.t)) {
@@@ -194,38 +252,32 @@ async function tickStream(stream, roster, events) {
st.lastTopic = { id: t.id, at: now };
}
} catch (e) { console.warn('[Arena] topic match:', e.message); }
-- const targets = mentionsIn(line.t, stream.user_id, roster);
-- if (targets.length) {
-- for (const tid of targets) { const m = st.mention[tid] || (st.mention[tid] = { lines: [], lastAt: 0, lastSec: 0 }); m.lines.push(line); m.lastAt = now; m.lastSec = line.s; }
++ const mentions = mentionsDetailed(line.t, stream.user_id, roster);
++ if (mentions.length) {
++ const m = mentions[0]; // best match (exact > fuzzy > phonetic)
++ line.named = true;
++ if (st.focus && st.focus.targetId !== m.userId) {
++ // Switching targets: judge the pending chunk on the old target first if it is big enough.
++ if (st.focus.lines.reduce((n, l) => n + words(l.t), 0) >= JUDGE_MIN_WORDS) await judgeFocus(stream, roster, st, events, { reason: 'target switch' });
++ st.focus = null;
++ }
++ if (!st.focus) { st.focus = newFocus(m.userId, now, m.how); events.push({ kind: 'focus', streamId: stream.id, speakerId: stream.user_id, targetId: m.userId, how: m.how, hit: m.hit }); }
++ else { st.focus.namedAt = now; st.focus.lockUntil = Math.max(st.focus.lockUntil, now + FOCUS_TAIL_MS); st.focus.misses = 0; }
++ st.focus.lines.push(line);
++ } else if (st.focus) {
++ st.focus.lines.push(line); // locked on: everything they say goes to the target's judge
} else {
-- // Tail: keep feeding the most recent mention buffer for a bit after the name drop.
-- const recent = Object.entries(st.mention).filter(([, m]) => line.s - m.lastSec <= MENTION_TAIL_SEC && now - m.lastAt < STALE_BUFFER_MS).sort((x, y) => y[1].lastSec - x[1].lastSec)[0];
-- if (recent) recent[1].lines.push(line);
-- else st.topic.lines.push(line);
++ st.topic.lines.push(line);
}
}
-- // Drop stale mention buffers that never reached the judge.
-- for (const [tid, m] of Object.entries(st.mention)) if (now - m.lastAt > STALE_BUFFER_MS) delete st.mention[tid];
++ if (st.focus && st.focus.lines.length > 80) st.focus.lines = st.focus.lines.slice(-80);
if (st.topic.lines.length > 60) st.topic.lines = st.topic.lines.slice(-60);
if (now - st.lastJudgeAt < JUDGE_MIN_INTERVAL_MS) return;
-- // 1) Mention buffers first (a callout is the interesting thing).
-- for (const [tidStr, m] of Object.entries(st.mention)) {
-- const tid = Number(tidStr);
-- if (m.lines.reduce((n, l) => n + words(l.t), 0) < JUDGE_MIN_WORDS) continue;
-- const text = bufferText(m.lines);
-- const lines = m.lines; delete st.mention[tidStr];
-- st.lastJudgeAt = Date.now();
-- const j = await judgeBeef(stream.user_id, tid, text, roster);
-- if (j.aimed_at_target) {
-- const ref = lineRefFor(lines, j.best_line);
-- const res = beef().recordHit(stream.user_id, tid, { quality: j.quality, best_line: j.best_line, about: j.about, announcer: j.announcer, vod_id: ref.vod_id, sec: ref.sec });
-- st.lastBeefJudgement = { at: new Date().toISOString(), target_id: tid, ...j, opened: res?.opened, bounty: res?.bounty };
-- events.push({ kind: 'beef_hit', streamId: stream.id, speakerId: stream.user_id, targetId: tid, opened: res?.opened, quality: j.quality, line: j.best_line });
-- } else {
-- events.push({ kind: 'beef_miss', streamId: stream.id, speakerId: stream.user_id, targetId: tid, about: j.about });
-- }
++ // 1) Focused target first (a callout is the interesting thing).
++ if (st.focus && st.focus.lines.reduce((n, l) => n + words(l.t), 0) >= JUDGE_MIN_WORDS) {
++ await judgeFocus(stream, roster, st, events, { reason: st.focus.lines.some(l => l.named) ? 'name-drop' : 'continuation' });
return; // one judge call per stream per tick
}
@@@ -263,7 +315,13 @@ async function tick() {
function consoleState(userId) {
for (const [streamId, st] of state) if (st.userId === userId) {
-- return { stream_id: streamId, listening: true, mention_buffers: Object.keys(st.mention).length, pending_topic_words: st.topic.lines.reduce((n, l) => n + words(l.t), 0), last_topic_judgement: st.lastTopicJudgement || null, last_beef_judgement: st.lastBeefJudgement || null, last_judge_at: st.lastJudgeAt ? new Date(st.lastJudgeAt).toISOString() : null };
++ const f = st.focus;
++ return {
++ stream_id: streamId, listening: true,
++ focus: f ? { target_id: f.targetId, target: (() => { try { return board().nameOf(f.targetId); } catch { return null; } })(), how: f.how, since: new Date(f.since).toISOString(), lock_seconds_left: Math.max(0, Math.round((f.lockUntil - Date.now()) / 1000)), hits: f.hits, misses: f.misses, pending_words: f.lines.reduce((n, l) => n + words(l.t), 0), context: f.context } : null,
++ pending_topic_words: st.topic.lines.reduce((n, l) => n + words(l.t), 0),
++ last_topic_judgement: st.lastTopicJudgement || null, last_beef_judgement: st.lastBeefJudgement || null, last_judge_at: st.lastJudgeAt ? new Date(st.lastJudgeAt).toISOString() : null,
++ };
}
return { listening: false };
}
@@@ -276,4 +334,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, _mentionsIn: mentionsIn, _aliases: aliases, _heuristicBeef: heuristicBeef, _heuristicTopic: heuristicTopic, _state: state };
++module.exports = { start, stop, tick, consoleState, TICK_MS, JUDGE_MIN_WORDS, JUDGE_MIN_INTERVAL_MS, FOCUS_TAIL_MS, FOCUS_EXTEND_MS, FOCUS_MAX_MS, _mentionsIn: mentionsIn, _mentionsDetailed: mentionsDetailed, _aliases: aliases, _heuristicBeef: heuristicBeef, _heuristicTopic: heuristicTopic, _state: state };
ddiff --git a/server/arena/names.js b/server/arena/names.js
nnew file mode 100644
iindex 00000000..f9622e3f
---- /dev/null
++++ b/server/arena/names.js
@@@ -0,0 +1,160 @@
++/**
++ * names.js — how a streamer's name comes out of a speech-to-text engine, and how to find it.
++ *
++ * "JapaneseOldGuy" is never transcribed as one token: whisper hears "Japanese old guy",
++ * "japanese-old-guy", "Japanese Old Guy's stream". "lofi_dan99" → "lofi dan". "Maticus" →
++ * "Matticus" / "Mattie cuss". "x_Goosely_TV" → "Goosely". This module turns a name into every
++ * spoken form we can predict, then matches transcript lines against them with:
++ *
++ * 1. exact/loose-separator matches ("japanese old guy", "japanese-old-guy", "japaneseoldguy")
++ * 2. fuzzy matches on n-grams of the line (edit distance scaled by length)
++ * 3. phonetic matches (a compact metaphone-ish key: "goosely" ≈ "goose lee" ≈ "goosley")
++ *
++ * Guard rails: single-word aliases must be ≥ 4 letters and not a common English word, so a
++ * fighter called "Guy" or "Old" cannot be "mentioned" by every sentence; multi-word aliases only
++ * match as the whole phrase; the speaker never matches themselves; longest alias wins.
++ */
++'use strict';
++
++const COMMON = new Set(('about after again also always another anyone anything around because before being between both bring build built came cant cannot chat check come could does doing done down each else even ever every first from game games getting give going gone good great group guy guys have having here home into just keep kind know last later least life like little live look made make many maybe mean might more most much must name need never next nice night nobody nothing okay only other over people place play player point pretty probably really right same says school seen shit should show since some something sorry stay still stop stream streamer stuff sure take talk tell than thank that their them then there these they thing think this those three time today told took trying under until very want watch week well went were what when where which while whole will with without work would year years young your live love hate lord king queen dude bro bruh fam yeah yes okay old new big small guy girl boy man men women woman baby dan dave mike john tom jack ben sam max alex chris'
++).split(/\s+/));
++
++const SUFFIXES = /(?:_|-)?(?:tv|ttv|live|official|yt|twitch|gaming|stream|streams|vods|irl|hd|4k)$/i;
++
++const LEET = { '0': 'o', '1': 'i', '3': 'e', '4': 'a', '5': 's', '7': 't', '8': 'b', '@': 'a', '$': 's', '!': 'i', '|': 'l' };
++
++function clean(s) { return String(s || '').trim(); }
++function words(s) { return String(s || '').toLowerCase().split(/[^a-z0-9]+/).filter(Boolean); }
++
++/** Split a handle the way it is SAID: camelCase, snake_case, kebab, letters|digits. */
++function splitHandle(name) {
++ let s = clean(name);
++ if (!s) return [];
++ s = s.replace(/[_.\-+]+/g, ' ')
++ .replace(/([a-z])([A-Z])/g, '$1 $2') // JapaneseOldGuy → Japanese Old Guy
++ .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2') // XXGooselyXX → XX Goosely XX
++ .replace(/([a-zA-Z])(\d)/g, '$1 $2') // dan99 → dan 99
++ .replace(/(\d)([a-zA-Z])/g, '$1 $2');
++ return s.toLowerCase().split(/\s+/).filter(Boolean);
++}
++
++function deleet(w) { return String(w).replace(/[0134578@$!|]/g, c => LEET[c] || c); }
++
++/**
++ * All predicted spoken forms of one name (lowercase word arrays, deduped, most specific first).
++ * JapaneseOldGuy → ["japanese old guy"], ["japaneseoldguy"]
++ * lofi_dan99 → ["lofi dan 99"], ["lofi dan"], ["lofidan"]
++ * x_Goosely_TV → ["x goosely tv"], ["goosely"], ["x goosely"]
++ * M4ticus → ["m4ticus"], ["maticus"]
++ */
++function variants(name) {
++ const out = new Set();
++ const add = (ws) => { const v = ws.filter(Boolean).join(' ').trim(); if (v.length >= 3) out.add(v); };
++ const raw = clean(name);
++ if (!raw) return [];
++ const base = splitHandle(raw);
++ add(base);
++ add(base.map(deleet));
++ add(base.filter(w => !/^\d+$/.test(w))); // drop pure numbers: "lofi dan 99" → "lofi dan"
++ add(base.map(deleet).filter(w => !/^\d+$/.test(w)));
++ const noSuffix = raw.replace(SUFFIXES, '');
++ if (noSuffix !== raw) { const b2 = splitHandle(noSuffix); add(b2); add(b2.filter(w => !/^\d+$/.test(w))); }
++ // Decorations people put around handles: xX_name_Xx, __name__, TheName → name
++ const stripped = raw.replace(/^(?:xx+|x_|_+|the)/i, '').replace(/(?:xx+|_x|_+)$/i, '');
++ if (stripped !== raw && stripped.length >= 3) { const b3 = splitHandle(stripped); add(b3); add(b3.filter(w => !/^\d+$/.test(w))); }
++ // Glued form for names that ARE a phrase (a transcriber may glue or split them).
++ for (const v of [...out]) { const ws = v.split(' '); if (ws.length > 1 && ws.every(w => /^[a-z]+$/.test(w))) out.add(ws.join('')); }
++ // Drop unsafe single common words (would match every sentence).
++ return [...out].filter(v => v.split(' ').length > 1 || (v.length >= 4 && !COMMON.has(v) && !/^\d+$/.test(v))).sort((a, b) => b.length - a.length);
++}
++
++// ── Fuzzy + phonetic ────────────────────────────────────────
++
++function levenshtein(a, b) {
++ if (a === b) return 0;
++ const m = a.length, n = b.length;
++ if (!m) return n; if (!n) return m;
++ let prev = Array.from({ length: n + 1 }, (_, i) => i);
++ for (let i = 1; i <= m; i++) {
++ const cur = [i];
++ for (let j = 1; j <= n; j++) cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
++ prev = cur;
++ }
++ return prev[n];
++}
++
++/** Compact phonetic key: "goosely" → "gsl", "goose lee" → "gsl", "matticus" → "mtks", "japanese old guy" → "jpns ld g". */
++function phonetic(s) {
++ let t = String(s || '').toLowerCase().replace(/[^a-z ]/g, '');
++ t = t.replace(/ph/g, 'f').replace(/ck/g, 'k').replace(/qu/g, 'kw').replace(/x/g, 'ks').replace(/z/g, 's').replace(/wh/g, 'w')
++ .replace(/c(?=[eiy])/g, 's').replace(/c/g, 'k').replace(/g(?=[eiy])/g, 'j').replace(/dg/g, 'j').replace(/tio/g, 'sho').replace(/y/g, 'i');
++ return t.split(' ').map(w => {
++ if (!w) return '';
++ const first = w[0];
++ let rest = w.slice(1).replace(/[aeiouhw]/g, '');
++ rest = rest.replace(/(.)\1+/g, '$1');
++ return (first + rest).replace(/(.)\1+/g, '$1');
++ }).filter(Boolean).join(' ');
++}
++function ratioThreshold(len) { return len <= 4 ? 0 : len <= 7 ? 1 : len <= 11 ? 2 : 3; }
++
++// ── Matching ────────────────────────────────────────────────
++
++/** Regex for one variant: words joined by any separator, optional 's / plural, no letters glued around. */
++function variantRegex(v) {
++ const parts = v.split(' ').map(w => w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
++ return new RegExp(`(?:^|[^a-z0-9])@?${parts.join('[\\s\\-_.]*')}(?:'?s)?(?![a-z0-9])`, 'i');
++}
++
++/** Build an alias entry: { userId, name, words, re, key } for each variant of each name. */
++function aliasEntries(userId, names) {
++ const seen = new Set();
++ const out = [];
++ for (const n of names.filter(Boolean)) {
++ 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, '') });
++ }
++ }
++ return out;
++}
++
++/**
++ * Which alias entries occur in `text`. Returns [{ userId, name, how: 'exact'|'fuzzy'|'phonetic', hit }].
++ * `excludeUserId` = the speaker (never mentions themselves).
++ */
++function findMentions(text, entries, { excludeUserId = null, fuzzy = true } = {}) {
++ const found = new Map(); // userId → best hit
++ const raw = String(text || '');
++ if (raw.length < 3) return [];
++ const consider = (e, how, hit) => { if (e.userId === excludeUserId) return; const cur = found.get(e.userId); if (!cur || cur.rank < (how === 'exact' ? 3 : how === 'fuzzy' ? 2 : 1)) found.set(e.userId, { userId: e.userId, name: e.name, how, hit, rank: how === 'exact' ? 3 : how === 'fuzzy' ? 2 : 1 }); };
++ // 1) exact / loose separators
++ for (const e of entries) { const m = raw.match(e.re); if (m) consider(e, 'exact', m[0].trim()); }
++ if (!fuzzy) return [...found.values()].map(({ rank, ...r }) => r);
++ // 2) fuzzy + phonetic on n-grams (1..4 words) of the line
++ const toks = words(raw).map(deleet);
++ if (!toks.length) return [...found.values()].map(({ rank, ...r }) => r);
++ for (const e of entries) {
++ if (found.has(e.userId) && found.get(e.userId).rank === 3) continue;
++ const n = e.words.length;
++ const target = e.letters;
++ if (target.length < 5) continue; // too short to fuzzy-match safely
++ const maxD = ratioThreshold(target.length);
++ // n-grams of n words, and of n±1 words (a name can be split or glued by the transcriber)
++ for (const span of [n, n + 1, n - 1].filter(x => x >= 1 && x <= 4)) {
++ for (let i = 0; i + span <= toks.length; i++) {
++ const gram = toks.slice(i, i + span);
++ const glued = gram.join('');
++ if (Math.abs(glued.length - target.length) > maxD + 4) continue;
++ // Fuzzy (typo-level) only on the same number of words: "matticus" ~ "maticus", "japanese old gai" ~
++ // "japanese old guy" — but never "japanese guy" ~ "japanese old guy" (a different phrase).
++ if (span === n && maxD > 0 && Math.abs(glued.length - target.length) <= maxD + 1 && levenshtein(glued, target) <= maxD) { consider(e, 'fuzzy', gram.join(' ')); break; }
++ // "mattie cuss" → mtks == maticus → mtks; "goose lee" → gsl == goosely → gsl
++ if (glued.length >= 5 && !gram.every(w => COMMON.has(w)) && phonetic(gram.join(' ')).replace(/ /g, '') === e.key.replace(/ /g, '')) { consider(e, 'phonetic', gram.join(' ')); break; }
++ }
++ }
++ }
++ return [...found.values()].sort((a, b) => b.rank - a.rank).map(({ rank, ...r }) => r);
++}
++
++module.exports = { variants, splitHandle, deleet, phonetic, levenshtein, aliasEntries, findMentions, variantRegex, COMMON };
ddiff --git a/test/arena-beef-board.test.js b/test/arena-beef-board.test.js
iindex 0ba33160..e328ec98 100644
---- a/test/arena-beef-board.test.js
++++ b/test/arena-beef-board.test.js
@@@ -215,6 +215,30 @@ board.ensureTables(); beef.ensureTables();
assert.deepStrictEqual(listener._mentionsIn('shoutout @pixelqueen for the raid', u1, fresh2), [u3]);
assert.deepStrictEqual(listener._mentionsIn('nova is the best, nova nova', u1, fresh2), [], 'a speaker never mentions themselves');
assert.deepStrictEqual(listener._mentionsIn('the supernova exploded', u2, fresh2), [], 'no match inside other words');
++ assert.deepStrictEqual(listener._mentionsIn('grizzlybear is a fraud', u1, fresh2), [u2], 'glued form of a split name');
++ assert.deepStrictEqual(listener._mentionsIn('grizly bear ducked me', u1, fresh2), [u2], 'typo-level fuzzy match');
++ assert.deepStrictEqual(listener._mentionsIn('pixel queen is scared', u1, fresh2), [u3], 'a one-word handle the transcriber split in two');
++ assert.strictEqual(listener._mentionsDetailed('pixel queen is scared', u1, fresh2)[0].how, 'phonetic');
++ // names.js on the handles that break naive matching
++ const names = require('../server/arena/names');
++ assert.deepStrictEqual(names.variants('JapaneseOldGuy'), ['japanese old guy', 'japaneseoldguy']);
++ assert.deepStrictEqual(names.variants('lofi_dan99'), ['lofi dan 99', 'lofi dan', 'lofidan']);
++ assert.ok(names.variants('x_Goosely_TV').includes('goosely tv') && names.variants('M4ticus').includes('maticus'));
++ assert.deepStrictEqual(names.variants('Guy'), [], 'a common word alone can never be an alias');
++ const E = [...names.aliasEntries(1, ['JapaneseOldGuy']), ...names.aliasEntries(2, ['Maticus']), ...names.aliasEntries(3, ['Goosely'])];
++ const hit = (t) => names.findMentions(t, E).map(m => `${m.userId}:${m.how}`);
++ assert.deepStrictEqual(hit('Japanese old guy is washed'), ['1:exact']);
++ assert.deepStrictEqual(hit('japanese-old-guy again'), ['1:exact']);
++ assert.deepStrictEqual(hit("JapaneseOldGuy's chat is dead"), ['1:exact']);
++ assert.deepStrictEqual(hit('japanese old gai lol'), ['1:fuzzy']);
++ assert.deepStrictEqual(hit('a japanese guy walked in'), [], 'a different phrase is not a fuzzy hit');
++ assert.deepStrictEqual(hit('the old guy said nothing'), []);
++ assert.deepStrictEqual(hit('Matticus is a fraud'), ['2:fuzzy']);
++ assert.deepStrictEqual(hit('mattie cuss is scared'), ['2:phonetic']);
++ assert.deepStrictEqual(hit('goose lee ducked'), ['3:phonetic']);
++ assert.deepStrictEqual(hit('Goosley never answered'), ['3:phonetic']);
++ assert.deepStrictEqual(hit('that goose flew'), [], 'a prefix alone is not the name');
++ console.log('✅ name matching: camelCase/snake/digits/leet/suffixes, fuzzy + phonetic, guarded');
const hb1 = listener._heuristicBeef('Grizzly is trash and washed, he could never beat me!', ['grizzly']);
assert.strictEqual(hb1.aimed_at_target, true); assert.ok(hb1.quality >= 6);
assert.strictEqual(listener._heuristicBeef('thanks grizzly for the raid, love you', ['grizzly']).aimed_at_target, false);
@@@ -240,7 +264,31 @@ board.ensureTables(); beef.ensureTables();
assert.strictEqual(opened.length, 1); assert.strictEqual(opened[0].b_user_id, u3);
assert.strictEqual(opened[0].on_clock, 'b');
assert.strictEqual(arena.liveFighters()[0].open_beefs, 1);
-- console.log('✅ listener: name-drop on a live stream opens the beef');
++ const cs = listener.consoleState(u1);
++ assert.ok(cs.focus && cs.focus.target_id === u3 && cs.focus.hits === 1 && cs.focus.lock_seconds_left > 60, `locked on the target after the hit: ${JSON.stringify(cs.focus)}`);
++ console.log('✅ listener: name-drop on a live stream opens the beef and locks on');
++
++ // Locked on: the next lines never say the name, but "she", "her chat" keep the rant going → another hit.
++ const hitsBefore = beef.get(opened[0].id).a.hits;
++ db.run(`UPDATE streams SET started_at = datetime('now', '-700 seconds') WHERE id = ?`, [liveId]);
++ db.addTimelineEvents([
++ { stream_id: liveId, user_id: u1, vod_id: null, kind: 'speech', start_sec: 686, end_sec: 692, text: 'And her chat? Her chat is twelve alts and a bot, she is cooked, she could never run it back with me.', label: null, confidence: 0.9 },
++ { stream_id: liveId, user_id: u1, vod_id: null, kind: 'speech', start_sec: 693, end_sec: 698, text: 'She is trash at this and everyone knows it, bet, she is scared to even answer.', label: null, confidence: 0.9 },
++ ]);
++ listener._state.get(liveId).lastJudgeAt = 0; // skip the 30 s judge spacing for the test
++ const ev2 = await listener.tick();
++ assert.ok(ev2.some(e => e.kind === 'beef_hit' && e.targetId === u3 && e.continued), `continuation counted without the name: ${JSON.stringify(ev2)}`);
++ assert.strictEqual(beef.get(opened[0].id).a.hits, hitsBefore + 1);
++ assert.ok(listener.consoleState(u1).focus.hits === 2 && listener.consoleState(u1).focus.context.includes('|'), 'lock extended with context');
++ // Then they move on to gameplay: two off-target chunks drop the lock.
++ for (let k = 0; k < 2; k++) {
++ db.run(`UPDATE streams SET started_at = datetime('now', ?) WHERE id = ?`, [`-${800 + k * 100} seconds`, liveId]);
++ db.addTimelineEvents([{ stream_id: liveId, user_id: u1, vod_id: null, kind: 'speech', start_sec: 786 + k * 100, end_sec: 792 + k * 100, text: 'Okay chat back to the game, we need to farm this boss and then do the quest line for the sword upgrade, let me check the map real quick.', label: null, confidence: 0.9 }]);
++ listener._state.get(liveId).lastJudgeAt = 0;
++ await listener.tick();
++ }
++ assert.strictEqual(listener.consoleState(u1).focus, null, 'moved on → lock dropped');
++ console.log('✅ listener: stays locked on the target, counts continuations, lets go when they move on');
// ── Chat commands ──
const sent = [], room = [];
@@@ -279,7 +327,7 @@ board.ensureTables(); beef.ensureTables();
assert.strictEqual(bd.status, 200); assert.ok(Array.isArray(bd.body.open) && bd.body.levels.length >= 1 && bd.body.yappers.length >= 1 && bd.body.archive.length >= 1);
const bf = await get('/beefs'); assert.strictEqual(bf.body.open.length, 2, 'nova→pixelqueen (listener) + grizzly→pixelqueen (bounty)'); assert.strictEqual(bf.body.resolved.length, 3);
const one = await get(`/beefs/${opened[0].id}`); assert.strictEqual(one.body.a.user.username, 'nova'); assert.ok(one.body.rules.response_live_min === 15);
-- const con = await get('/console/nova'); assert.strictEqual(con.body.listener.listening, true); assert.strictEqual(con.body.open_beefs.length, 1); assert.ok(con.body.hot_mic.length === 2);
++ const con = await get('/console/nova'); assert.strictEqual(con.body.listener.listening, true); assert.strictEqual(con.body.open_beefs.length, 1); assert.ok(con.body.hot_mic.length >= 2 && con.body.listener.focus === null);
const lv2 = await get('/live'); assert.strictEqual(lv2.body.live[0].open_beefs, 1);
const tp = await get(`/board/topics/${t1.id}`); assert.ok(tp.body.lore && tp.body.moments.length >= 5 && tp.body.keywords.includes('baby voice'));
assert.strictEqual((await get('/beefs/999')).status, 404);