openvibers/openvibe.live Commits

Arena: single AI-characteristics radar (correct geometry), objective stats as bars; backfill takes an explicit window

OP OpenVibers committed 30 Aug 2026 · 07afe30155 · 5 files, +55 −27

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

Changes

ddiff --git a/public/css/style.css b/public/css/style.css
iindex 90778891..fb6916e5 100644
---- a/public/css/style.css
++++ b/public/css/style.css
@@@ -14556,3 +14556,17 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg
.arena-row-expand-actions .btn { padding: 6px 10px; font-size: 0.82rem; }
.arena-topic-headline-big { font-size: 1.2rem; }
}
++
++/* ── Arena radar v2 ── */
++.arena-radar-v2 { width: 100%; height: auto; max-width: 300px; }
++.arena-radar-v2 .arena-radar-ring { fill: rgba(255, 255, 255, 0.02); stroke: var(--border-light); stroke-width: 1; }
++.arena-radar-axis { stroke: var(--border); stroke-width: 1; }
++.arena-radar-v2 .arena-radar-fill { stroke-width: 2.2; stroke-linejoin: round; }
++.arena-radar-dot { stroke: var(--bg-card); stroke-width: 1.5; }
++.arena-radar-label text { fill: var(--text-secondary); font-size: 10.5px; font-weight: 700; }
++.arena-radar-label .arena-radar-val { font-size: 11px; font-weight: 900; }
++.arena-profile-custom { grid-template-columns: 300px 1fr; }
++.arena-profile-stats.is-bars { grid-template-columns: 1fr; gap: 8px; }
++.arena-bars-head { font-size: 0.85rem; } .arena-bars-head small { color: var(--text-muted); }
++.arena-bar.is-clickable { cursor: pointer; }
++@media (max-width: 600px) { .arena-profile-custom { grid-template-columns: 1fr; } .arena-radar-v2 { max-width: 260px; margin: 0 auto; } }
ddiff --git a/public/index.html b/public/index.html
iindex 21bfee49..61647ceb 100644
---- a/public/index.html
++++ b/public/index.html
@@@ -62,7 +62,7 @@
</script>
<!-- Stylesheets -->
-- <link rel="stylesheet" href="/css/style.css?v=150">
++ <link rel="stylesheet" href="/css/style.css?v=151">
<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=11" defer></script>
++<script src="/js/arena.js?v=12" 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 8868f8af..0d957edf 100644
---- a/public/js/arena.js
++++ b/public/js/arena.js
@@@ -115,21 +115,28 @@ 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) {
++/**
++ * A fighter's radar — their AI characteristics (six stats named after their own bits, read from
++ * their chat history + channel), drawn correctly: value v ∈ [0, 99] sits at radius R·v/99 on its
++ * axis, rings at 33/66/99, axis lines, a value pill at each vertex. Falls back to the objective
++ * seven only when the persona has no custom stats yet.
++ */
++function _aCustomRadar(f, color, size = 240) {
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}
++ const axes = cs.length >= 3 ? cs.map(x => ({ name: String(x.name).slice(0, 16), value: Math.max(0, Math.min(99, Number(x.value))), quip: x.quip || '' })) : ARENA_STATS.map(k => ({ name: ARENA_STAT_LABEL[k], value: Math.max(0, Math.min(99, Number(f.ratings?.[k]) || 0)), quip: (f.persona?.stat_quips || {})[k] || '' }));
++ const N = axes.length, c = size / 2, R = size / 2 - 44;
++ const ang = (i) => -Math.PI / 2 + (i * 2 * Math.PI) / N;
++ const pt = (i, v) => [c + (R * v / 99) * Math.cos(ang(i)), c + (R * v / 99) * Math.sin(ang(i))];
++ const poly = (v) => axes.map((_, i) => pt(i, v).map(n => n.toFixed(1)).join(',')).join(' ');
++ const shape = axes.map((a, i) => pt(i, a.value).map(n => n.toFixed(1)).join(',')).join(' ');
++ const id = `rg${Math.floor(Math.random() * 1e6)}`;
++ return `<svg class="arena-radar arena-radar-v2" viewBox="0 0 ${size} ${size}" width="${size}" height="${size}" role="img" aria-label="stats radar">
++ <defs><radialGradient id="${id}" cx="50%" cy="50%" r="60%"><stop offset="0%" stop-color="${_aEsc(color)}" stop-opacity="0.55"/><stop offset="100%" stop-color="${_aEsc(color)}" stop-opacity="0.12"/></radialGradient></defs>
++ ${[33, 66, 99].map(v => `<polygon points="${poly(v)}" class="arena-radar-ring"></polygon>`).join('')}
++ ${axes.map((_, i) => { const [x, y] = pt(i, 99); return `<line x1="${c}" y1="${c}" x2="${x.toFixed(1)}" y2="${y.toFixed(1)}" class="arena-radar-axis"></line>`; }).join('')}
++ <polygon points="${shape}" class="arena-radar-fill" style="fill:url(#${id});stroke:${_aEsc(color)}"></polygon>
++ ${axes.map((a, i) => { const [x, y] = pt(i, a.value); return `<circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="3.5" class="arena-radar-dot" style="fill:${_aEsc(color)}"><title>${_aEsc(a.name)} ${a.value}${a.quip ? ` — ${_aEsc(a.quip)}` : ''}</title></circle>`; }).join('')}
++ ${axes.map((a, i) => { const [x, y] = pt(i, 99); const lx = c + (R + 26) * Math.cos(ang(i)), ly = c + (R + 26) * Math.sin(ang(i)); const anchor = Math.abs(Math.cos(ang(i))) < 0.2 ? 'middle' : (Math.cos(ang(i)) > 0 ? 'start' : 'end'); return `<g class="arena-radar-label"><text x="${lx.toFixed(1)}" y="${(ly - 4).toFixed(1)}" text-anchor="${anchor}">${_aEsc(a.name)}</text><text x="${lx.toFixed(1)}" y="${(ly + 9).toFixed(1)}" text-anchor="${anchor}" class="arena-radar-val" style="fill:${_aEsc(color)}">${a.value}</text><title>${_aEsc(a.quip)}</title></g>`; }).join('')}
</svg>`;
}
function _aCustomQuips(f) {
@@@ -567,7 +574,7 @@ 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>${_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>${_aCustomRadar(f, f.user.profile_color || '#8b5cf6', 220)}<div class="arena-mini-record">${(f.persona.custom_stats || []).length ? 'AI characteristics · ' : ''}${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 is-clamp">${_aEsc(f.persona.lore)}</p>
${(f.persona.taunts || []).length ? `<div class="arena-row-taunts">${f.persona.taunts.slice(0, 2).map(t => `<em>“${_aEsc(t)}” ${_aSpeakBtn(t, '', f.user.username)}</em>`).join('')}${f.persona.typing_style ? `<small><i class="fa-solid fa-keyboard"></i> ${_aEsc(f.persona.typing_style)}</small>` : ''}</div>` : ''}
@@@ -844,10 +851,13 @@ 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>
++ <div class="arena-profile-custom">
++ <div>${_aCustomRadar(f, color, 260)}<div class="arena-mini-record">${(p.custom_stats || []).length ? 'their characteristics — AI-read from their chat history + channel' : 'objective stats (the AI characteristics appear once the persona is generated)'}</div></div>
++ ${(p.custom_stats || []).length ? _aCustomQuips(f) : ''}
++ </div>
++ <div class="arena-profile-stats is-bars">
++ <div class="arena-bars-head"><b>The numbers</b> <small>percentile across the roster · these make POWER · tap one for the breakdown</small></div>
++ <div class="arena-bars">${ARENA_STATS.map(k => `<div class="arena-bar is-clickable arena-quip" data-stat="${k}" title="${_aEsc((p.stat_quips || {})[k] || ARENA_STAT_LABEL[k])}"><span class="arena-bar-label">${_aEsc(ARENA_STAT_LABEL[k])}</span><span class="arena-bar-track"><span class="arena-bar-fill" style="width:${Math.max(0, Math.min(100, f.ratings[k] || 0))}%;background:${_aEsc(color)}"></span></span><span class="arena-bar-val">${f.ratings[k] ?? '–'}</span></div>`).join('')}</div>
<div id="arena-stat-detail"></div>
</div>
<div class="arena-lore">
ddiff --git a/server/arena/arena-service.js b/server/arena/arena-service.js
iindex 75eec5bc..0259dade 100644
---- a/server/arena/arena-service.js
++++ b/server/arena/arena-service.js
@@@ -303,7 +303,7 @@ 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)' },
++ 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 CHARACTERISTICS unique to this streamer for their radar — read from their chat history (things_they_typed_in_chat, chat_ai_notes, chatter_memory, chatter_timeline) and their channel (ai_overview, what they said on stream, what the camera saw): the bits they run, what they will not shut up about, how they treat chat, their rivals, their gear obsession, their schedule. Names must be specific to THEM (never generic like "Hype" or "Skill"), funny, ragebait-ish; values honestly spread (at least one under 30, at least one over 85) and the quip explains the number in their voice.' },
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` }])) },
ddiff --git a/server/arena/board.js b/server/arena/board.js
iindex 484ccbd3..e3a65c6a 100644
---- a/server/arena/board.js
++++ b/server/arena/board.js
@@@ -551,22 +551,26 @@ async function discoverTopics({ force = false } = {}) {
created.push(createTopic({ text: `Bounty: ${nameOf(u.id)}`, hint: clip(b.why, 90), createdBy: 'community', creatorName: 'the community', kind: 'bounty', targetUserId: u.id, headline: b.headline, tagline: b.why, sourceNote: 'community' })); made++;
} catch { /* dup */ }
}
-- if (created.length) backfillMoments(created);
++ if (created.length) backfillMoments(created, { windowMin: scanWindowMin() * 4 });
if (made) console.log(`[Arena] discovered ${made} subject(s) from the last ${scanWindowMin()} min`);
return { made, pulse: _pulse.text };
}
/** Seed a fresh topic with the lines from the window that match it, so it never starts empty. */
--function backfillMoments(topics) {
-- const chat = db.all(`SELECT id, stream_id, user_id, username, message, source_platform, anon_id FROM chat_messages WHERE COALESCE(is_deleted, 0) = 0 AND COALESCE(message_type, 'chat') = 'chat' AND COALESCE(source_platform, '') != 'ai' AND timestamp >= datetime('now', ?) AND LENGTH(message) BETWEEN 6 AND 300 ORDER BY id ASC LIMIT 400`, [`-${scanWindowMin() * 4} minutes`]);
-- const speech = db.all(`SELECT e.stream_id, e.user_id, e.vod_id, e.start_sec, e.text, u.username FROM stream_timeline_events e LEFT JOIN users u ON u.id = e.user_id WHERE e.kind = 'speech' AND e.created_at >= datetime('now', ?) ORDER BY e.id ASC LIMIT 300`, [`-${scanWindowMin() * 4} minutes`]);
++function backfillMoments(topics, { windowMin = null } = {}) {
++ const win = Math.max(30, Number(windowMin) || scanWindowMin() * 4);
++ const chat = db.all(`SELECT id, stream_id, user_id, username, message, source_platform, anon_id FROM chat_messages WHERE COALESCE(is_deleted, 0) = 0 AND COALESCE(message_type, 'chat') = 'chat' AND COALESCE(source_platform, '') != 'ai' AND timestamp >= datetime('now', ?) AND LENGTH(message) BETWEEN 6 AND 300 ORDER BY id DESC LIMIT 600`, [`-${win} minutes`]).reverse();
++ const speech = db.all(`SELECT e.stream_id, e.user_id, e.vod_id, e.start_sec, e.text, u.username FROM stream_timeline_events e LEFT JOIN users u ON u.id = e.user_id WHERE e.kind = 'speech' AND e.created_at >= datetime('now', ?) ORDER BY e.id DESC LIMIT 400`, [`-${win} minutes`]).reverse();
++ let seeded = 0;
for (const t of topics) {
const kws = normalizeKeywords(parseJson(t.keywords_json, [])).map(keywordRegex);
let n = 0;
for (const c of chat) if (!/^!/.test(c.message) && kws.some(re => re.test(c.message))) { if (addMoment(t.id, { kind: 'chat', source: 'seed', userId: c.user_id, username: c.username, streamId: c.stream_id, text: c.message, platform: c.source_platform || null, anonId: c.anon_id || null })) n++; }
for (const s of speech) if (kws.some(re => re.test(s.text || ''))) { if (addMoment(t.id, { kind: 'speech', source: 'seed', userId: s.user_id, username: s.username, streamId: s.stream_id, vodId: s.vod_id, sec: Math.max(0, Math.floor(s.start_sec) - 2), text: s.text })) n++; }
-- if (n) console.log(`[Arena] topic #${t.id} seeded with ${n} moment(s)`);
++ if (n) console.log(`[Arena] topic #${t.id} seeded with ${n} moment(s) from the last ${win} min`);
++ seeded += n;
}
++ return seeded;
}
function pulse() { const p = loadPulse(); return { text: p.text, at: p.at ? new Date(p.at).toISOString() : null, sources: p.sources || [] }; }