openvibers/openvibe.live Commits

Arena growth: tiers, achievements, history feed, weekly crowns; fighter page rebuilt (rap sheet, ragebait bubbles, numbers grid); radar labels

OP OpenVibers committed 30 Aug 2026 · 783b7f818f · 12 files, +376 −46

- server/arena/progress.js: tiers Bronze→Mythic from all-time XP (paid once,
  coins for accounts), 24 achievements checked from existing data on every XP
  change (XP + coins + bell + history), arena_events history per key, weekly XP
  ladders. Hooked into board/chatters XP, beefs (open/over), subjects, quotes.
- Fighter page: progress panel (tier track, achievements grid, history), single
  characteristics radar with un-clipped labels, rap sheet, chat-bubble taunts
  read in their voice, numbers as a 2-col bar grid. Chatter page + Your Arena
  strip get the same progress panel; ladder rows show tier badges.

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 e8d7b8e1..7ab6a8f6 100644
---- a/docs/arena.md
++++ b/docs/arena.md
@@@ -124,3 +124,13 @@ Where it shows: the Yappers block on `/arena` (podium with level rings, streak f
- Arena events go to the site notification bell (`server/arena/notify.js` → `utils/notify.pushNotification`, deduped 10 min per user/type/key; users without a linked network account are skipped like everywhere else): a beef opened on you (with the clock), answers/hits while a beef is open, beef over (win/loss/draw), yap level-ups (+coins), getting quoted in lore, a bounty on you, your subject going HOT, your yap card being written.
- Bots are never chatters: AI viewers (`source_platform='ai'`), channel AI bots (`channel_ai_bots`) and the admin list `arena_bot_usernames` (comma-separated) are excluded from moments, discovery input, voice samples and yapper XP.
- Moments store `said_at` (the chat timestamp / stream start + offset) and every view uses it — never the time the line was filed.
++
++## Growth: tiers, achievements, history (`server/arena/progress.js`)
++
++- **Tiers** from all-time XP (never go down): Bronze 0 · Silver 150 · Gold 400 · Platinum 900 · Diamond 1800 · Mythic 3500. Fighters use their Trash Level XP, chatters their yap XP (an account's `user:<id>` key is the same for both, so one bar builds). Tier-ups are paid once (`arena_tier_paid`): accounts get `tier × 50` OpenCoins, a history entry and a notification.
++- **Achievements** (`ACHIEVEMENTS`, checked from existing data after every XP change — no AI): fighters — First Blood, Not Ducking, W, Problem (5 wins), Untouchable (3 streak), Giant Killer (upset), Run It Back (rematch win), On The Board, Loudmouth (25 judged moments), Bounty Hunter, Main Character (heard on a subject that went HOT — `arena_topics.peak_heat`), Crowd Favorite (25 hypes); chatters — Said Something, Yapping (10), Cannot Be Stopped (50), Everywhere (3 subjects), Instigator (started one), In The Lore, Historian's Nightmare (quoted 5×), Hot Take, Three Days, Regular (7 days), Instigator Rank (yap 5), Menace Rank (yap 10). Each pays XP; accounts also get OpenCoins (idempotent `live:arena_ach:<user>:<id>`), a bell notification and a history entry.
++- **History** (`arena_events`): beefs started/answered/over, level-ups, tier-ups, achievements, subjects started, being quoted — the timeline on every fighter/chatter page.
++- **Weekly**: XP gained in 7 days per key; `fighters_week` / `yappers_week` crowns on `/arena`.
++- API: `GET /api/arena/progress/:key`; `progress` is embedded in fighter cards, chatter profiles and `/api/arena/me`.
++
++Fighter page order: hero (portrait, POWER, tier, record, taunt) → progress panel (tier bar, achievements grid, history) → Trash Level card → characteristics radar + quips → rap sheet (lore, signature/special/weakness, walk-out, catchphrase, typing style) → ragebait bubbles (🔊 in their chat voice) → on the mic (quotes) → the numbers (bars) → rivalries → beefs.
ddiff --git a/public/css/style.css b/public/css/style.css
iindex 2d96b5b5..652aedad 100644
---- a/public/css/style.css
++++ b/public/css/style.css
@@@ -14588,3 +14588,65 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg
.arena-profile-stats.is-bars .arena-bar-fill { display: block; height: 100%; border-radius: 4px; }
.arena-profile-stats.is-bars .arena-bar-val { text-align: right; font-weight: 800; font-variant-numeric: tabular-nums; }
@media (max-width: 700px) { .arena-me-inner { grid-template-columns: 1fr; } .arena-profile-stats.is-bars .arena-bars { grid-template-columns: 1fr; } }
++
++/* ── Arena growth: tiers, achievements, history, rap sheet, bubbles, numbers ── */
++.arena-radar-v2 { overflow: visible; }
++.arena-tier { display: inline-flex; align-items: center; gap: 5px; padding: 2px 8px; border-radius: 999px; font-size: 0.68rem; font-weight: 900; letter-spacing: 0.04em; text-transform: uppercase; color: var(--tc, #cd7f32); background: color-mix(in srgb, var(--tc, #cd7f32) 16%, transparent); border: 1px solid color-mix(in srgb, var(--tc, #cd7f32) 55%, transparent); }
++.arena-tier-big { font-size: 0.82rem; padding: 5px 12px; box-shadow: 0 0 14px color-mix(in srgb, var(--tc) 35%, transparent); }
++.arena-progress-panel { display: flex; flex-direction: column; gap: 12px; padding: 14px; border-radius: 16px; background: var(--bg-card); border: 1px solid var(--border); }
++.arena-progress-head { display: grid; grid-template-columns: auto 1fr; gap: 14px; align-items: center; }
++.arena-progress-bar { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
++.arena-tier-track { display: grid; grid-template-columns: repeat(6, 1fr); gap: 4px; }
++.arena-tier-step { height: 8px; border-radius: 4px; background: var(--bg-tertiary); }
++.arena-tier-step.is-done { background: var(--tc); }
++.arena-tier-step.is-now { box-shadow: 0 0 10px color-mix(in srgb, var(--tc) 60%, transparent); animation: arena-glow 1.6s ease-in-out infinite alternate; }
++@keyframes arena-glow { from { filter: brightness(0.9); } to { filter: brightness(1.3); } }
++.arena-ach-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 8px; }
++.arena-ach { display: flex; flex-direction: column; align-items: center; text-align: center; gap: 3px; padding: 10px 8px; border-radius: 12px; background: var(--bg-tertiary); border: 1px solid var(--border); min-width: 0; }
++.arena-ach-icon { font-size: 1.5rem; line-height: 1; }
++.arena-ach-name { font-size: 0.78rem; font-weight: 800; }
++.arena-ach-hint { font-size: 0.68rem; color: var(--text-muted); line-height: 1.25; }
++.arena-ach.is-earned { border-color: rgba(245, 197, 66, 0.55); background: linear-gradient(180deg, rgba(245, 197, 66, 0.12), var(--bg-tertiary)); }
++.arena-ach.is-locked { opacity: 0.55; filter: grayscale(0.6); }
++.arena-ach.is-locked .arena-ach-icon { filter: grayscale(1); }
++.arena-ach.is-mini { flex-direction: row; text-align: left; padding: 6px 10px; gap: 8px; }
++.arena-ach.is-mini .arena-ach-icon { font-size: 1.1rem; } .arena-ach.is-mini .arena-ach-hint { display: none; }
++.arena-me-ach { gap: 6px; }
++.arena-history summary { font-weight: 700; }
++.arena-history-list { display: flex; flex-direction: column; gap: 2px; }
++.arena-history-row { display: grid; grid-template-columns: 28px 1fr auto; gap: 8px; align-items: center; padding: 6px 0; border-top: 1px solid var(--border); font-size: 0.84rem; }
++.arena-history-kind { display: inline-grid; place-items: center; width: 24px; height: 24px; border-radius: 8px; background: var(--bg-tertiary); color: var(--text-secondary); font-size: 0.72rem; }
++.arena-history-kind.achievement { color: #f5c542; } .arena-history-kind.beef_over, .arena-history-kind.beef { color: #f87171; } .arena-history-kind.tier { color: #8ab4ff; } .arena-history-kind.level { color: #fb923c; }
++.arena-history-body { display: flex; flex-direction: column; min-width: 0; }
++.arena-history-body a { color: var(--text-primary); text-decoration: none; }
++.arena-history-body small { color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
++.arena-history-when { font-size: 0.72rem; color: var(--text-muted); white-space: nowrap; }
++.arena-sheet, .arena-taunts { padding: 14px; border-radius: 16px; background: var(--bg-card); border: 1px solid var(--border); display: flex; flex-direction: column; gap: 10px; }
++.arena-sheet h3, .arena-taunts h3 { margin: 0; font-size: 0.98rem; display: flex; gap: 8px; align-items: center; }
++.arena-taunts h3 small { color: var(--text-muted); font-weight: 400; font-size: 0.74rem; }
++.arena-sheet-lore { margin: 0; font-size: 0.95rem; line-height: 1.5; }
++.arena-sheet-rows { display: flex; flex-direction: column; gap: 6px; }
++.arena-sheet-row { display: grid; grid-template-columns: 92px 1fr; gap: 10px; font-size: 0.86rem; align-items: baseline; }
++.arena-sheet-k { font-size: 0.66rem; font-weight: 800; letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-muted); }
++.arena-sheet-row.is-weak .arena-sheet-k { color: #f87171; }
++.arena-bubbles { display: flex; flex-direction: column; gap: 8px; }
++.arena-bubble { display: grid; grid-template-columns: 1fr auto; gap: 10px; align-items: center; padding: 10px 12px; border-radius: 14px 14px 14px 4px; background: var(--bg-tertiary); border: 1px solid var(--border); }
++.arena-bubble-who { display: block; font-size: 0.7rem; font-weight: 800; color: var(--accent-light); margin-bottom: 2px; }
++.arena-bubble-text { font-size: 0.95rem; line-height: 1.4; }
++.arena-bubble-speak { width: 34px; height: 34px; border-radius: 10px; border: 1px solid var(--border); background: var(--bg-card); color: var(--text-secondary); cursor: pointer; }
++.arena-bubble-speak.is-speaking, .arena-bubble-speak:hover { color: var(--accent-light); border-color: var(--accent); }
++.arena-numgrid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px 18px; }
++.arena-num { display: grid; grid-template-columns: 60px 1fr 30px; align-items: center; gap: 8px; cursor: pointer; font-size: 0.84rem; }
++.arena-num-label { color: var(--text-secondary); }
++.arena-num-track { display: block; height: 8px; border-radius: 4px; background: var(--bg-tertiary); overflow: hidden; }
++.arena-num-fill { display: block; height: 100%; border-radius: 4px; transition: width 0.6s cubic-bezier(0.2, 0.8, 0.2, 1); }
++.arena-num-val { text-align: right; font-weight: 800; font-variant-numeric: tabular-nums; }
++.arena-numbers-compact { grid-template-columns: repeat(3, 1fr); }
++@media (max-width: 600px) {
++ .arena-progress-head { grid-template-columns: 1fr; }
++ .arena-ach-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; } .arena-ach { padding: 8px 4px; } .arena-ach-hint { display: none; }
++ .arena-sheet-row { grid-template-columns: 1fr; gap: 2px; }
++ .arena-numgrid { grid-template-columns: 1fr; }
++ .arena-numbers-compact { grid-template-columns: repeat(3, 1fr); }
++ .arena-profile-custom { grid-template-columns: 1fr; }
++}
ddiff --git a/public/index.html b/public/index.html
iindex 02153893..1b02a95d 100644
---- a/public/index.html
++++ b/public/index.html
@@@ -62,7 +62,7 @@
</script>
<!-- Stylesheets -->
-- <link rel="stylesheet" href="/css/style.css?v=152">
++ <link rel="stylesheet" href="/css/style.css?v=153">
<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=13" defer></script>
++<script src="/js/arena.js?v=14" 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 81626566..ea6c9467 100644
---- a/public/js/arena.js
++++ b/public/js/arena.js
@@@ -124,7 +124,7 @@ function _aRadar(r, color, size = 200) {
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);
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 N = axes.length, c = size / 2, R = size / 2 - 62;
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(' ');
@@@ -136,7 +136,7 @@ function _aCustomRadar(f, color, size = 240) {
${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('')}
++ ${axes.map((a, i) => { const cos = Math.cos(ang(i)), sin = Math.sin(ang(i)); const lx = c + (R + 22) * cos, ly = c + (R + 22) * sin; const anchor = Math.abs(cos) < 0.25 ? 'middle' : (cos > 0 ? 'start' : 'end'); const name = a.name.length > 13 ? a.name.slice(0, 12) + '…' : a.name; return `<g class="arena-radar-label"><text x="${lx.toFixed(1)}" y="${(ly + (sin < -0.3 ? -6 : sin > 0.3 ? 2 : -4)).toFixed(1)}" text-anchor="${anchor}">${_aEsc(name)}</text><text x="${lx.toFixed(1)}" y="${(ly + (sin < -0.3 ? 7 : sin > 0.3 ? 15 : 9)).toFixed(1)}" text-anchor="${anchor}" class="arena-radar-val" style="fill:${_aEsc(color)}">${a.value}</text><title>${_aEsc(a.name)} ${a.value}${a.quip ? ` — ${_aEsc(a.quip)}` : ''}</title></g>`; }).join('')}
</svg>`;
}
function _aCustomQuips(f) {
@@@ -221,6 +221,7 @@ async function _aRenderHome(root) {
<div class="arena-ladder">
<h2><i class="fa-solid fa-fire"></i> Trash Level ladder <small>XP from beef hits, judged moments on subjects, chat hype</small></h2>
${_aLevelsList(boardData.levels || [])}
++ ${(boardData.fighters_week || []).length ? `<div class="arena-yap-week"><span class="arena-note"><i class="fa-solid fa-crown" style="color:#f5c542"></i> this week:</span> ${boardData.fighters_week.map((f, i) => _aA(_aFighterLink(f.user), `${i === 0 ? '👑 ' : ''}${_aEsc(f.fighter_name)} <b>+${f.gained}</b>`, 'arena-tag')).join(' ')}</div>` : ''}
</div>
<div class="arena-ladder arena-ladder-yap" id="arena-yappers">
${_aYappersSection(boardData)}
@@@ -274,14 +275,15 @@ async function _aRenderMe() {
if (!me.checked_in) { try { const r = await api('/arena/checkin', { method: 'POST' }); if (!r.already) { _aToast(`+${r.gained} XP — daily check-in${r.streak >= 2 ? ` · ${r.streak}-day streak` : ''}${r.leveled_up ? ` · LEVEL ${r.level}!` : ''}`, 'success'); if (r.leveled_up) _aLevelUp(r.level); me = await api('/arena/me'); } } catch { /* */ } }
const c = me.chatter, pct = c.xp_for_next ? Math.round((c.xp_into_level / c.xp_for_next) * 100) : 100;
el.innerHTML = `<div class="arena-me-inner">
-- <div class="arena-me-left">${_aYapRing(c, 56)}<div><div class="arena-me-name"><b>${_aEsc(c.name)}</b> <span class="arena-lvl">YAP ${c.level} · ${_aEsc((c.card && c.card.title) || c.title)}</span>${c.streak >= 2 ? `<span class="arena-tag arena-tag-hot arena-streak"><i class="fa-solid fa-fire"></i> ${c.streak}-day streak</span>` : ''}</div>
++ <div class="arena-me-left">${_aYapRing(c, 56)}<div><div class="arena-me-name"><b>${_aEsc(c.name)}</b> ${me.progress ? _aTierBadge(me.progress.tier) : ''} <span class="arena-lvl">YAP ${c.level} · ${_aEsc((c.card && c.card.title) || c.title)}</span>${c.streak >= 2 ? `<span class="arena-tag arena-tag-hot arena-streak"><i class="fa-solid fa-fire"></i> ${c.streak}-day streak</span>` : ''}</div>
<span class="arena-xp-track"><span class="arena-xp-fill" style="width:${pct}%"></span></span>
-- <small class="arena-note">${c.xp_into_level}/${c.xp_for_next} to level ${c.level + 1} · <b>+${me.xp_today} XP today</b> · ${me.coins_from_arena} OpenCoins banked from levels</small></div></div>
++ <small class="arena-note">${c.xp_into_level}/${c.xp_for_next} to level ${c.level + 1} · <b>+${me.xp_today} XP today</b>${me.progress?.tier?.next ? ` · ${me.progress.tier.next.xp - me.progress.xp} to ${_aEsc(me.progress.tier.next.name)}` : ''} · ${me.progress ? `${me.progress.earned}/${me.progress.total} achievements` : ''} · ${me.coins_from_arena} OpenCoins from levels</small></div></div>
<div class="arena-me-right">
${me.on_clock.length ? me.on_clock.map(b => `<div class="arena-me-alert">${_aA(_aBeefLink(b), `<i class="fa-solid fa-stopwatch"></i> <b>${_aEsc((b.a.user.id === c.user?.id ? b.b : b.a).fighter_name)}</b> has words for you — answer on stream`)} ${_aClockTag(b)}</div>`).join('') : ''}
${me.fighter ? `<div class="arena-me-row"><i class="fa-solid fa-hand-fist"></i> Fighter #${me.fighter.rank} · PWR ${me.fighter.power} · TL ${me.fighter.level.level} · ${me.fighter.record.wins}W–${me.fighter.record.losses}L ${me.fighter.live ? _aA(_aConsoleLink(me.fighter.user), '<i class="fa-solid fa-ear-listen"></i> your ears', 'arena-tag arena-tag-hot') : ''}</div>` : ''}
${me.subjects.length ? `<div class="arena-me-row"><i class="fa-solid fa-comments"></i> You're in: ${me.subjects.map(s => _aA(`/arena/topic/${s.id}`, `${_aEsc(s.headline || s.text)} <b>${s.moments}</b>`, 'arena-thread')).join(' ')}</div>` : `<div class="arena-me-row arena-note"><i class="fa-solid fa-keyboard"></i> Type about ${me.hot_now.length ? me.hot_now.map(h => _aA(`/arena/topic/${h.id}`, _aEsc(h.headline), 'arena-thread')).join(' ') : 'any subject on the board'} in chat — every line that lands is XP.</div>`}
-- ${_aA(_aChatterLink(c.key), 'your yap page <i class="fa-solid fa-arrow-right"></i>', 'arena-subject-open')}
++ ${me.progress ? `<div class="arena-me-row arena-me-ach">${me.progress.achievements.filter(a => !a.earned_at).slice(0, 3).map(a => `<span class="arena-ach is-locked is-mini" title="${_aEsc(a.desc)}"><span class="arena-ach-icon">${a.icon}</span><span class="arena-ach-name">${_aEsc(a.name)}</span><span class="arena-ach-hint">${_aEsc(a.desc)} · +${a.xp} XP${a.coins ? ` · +${a.coins} coins` : ''}</span></span>`).join('')}</div>` : ''}
++ ${_aA(_aChatterLink(c.key), 'your page <i class="fa-solid fa-arrow-right"></i>', 'arena-subject-open')}
</div>
</div>`;
_aEvery(1000, () => _aTickClocks(el));
@@@ -566,6 +568,7 @@ async function _aRenderChatter(root, key) {
<div class="arena-yap-stats"><span><b>${y.moments}</b> moments</span><span><b>${y.subjects}</b> subjects</span><span><b>${y.subjects_started}</b> started</span><span><b>${y.quoted}</b> quoted in lore</span><span><b>${y.hypes}</b> hypes</span><span><b>${y.streak}</b> day streak <small>(best ${y.best_streak})</small></span></div>
</div>
</div>
++ ${_aProgressPanel(y.progress)}
${card.blurb ? `<section class="arena-yap-cardbox"><h3><i class="fa-solid fa-id-badge"></i> Yap card <small>AI-written from their chat history${y.card_at ? ` · ${_aEsc(_aAgo(y.card_at))}` : ''}</small></h3><p>${_aEsc(card.blurb)}</p>${card.catchphrase ? `<q class="arena-yap-line">${_aEsc(card.catchphrase)}</q>` : ''}${card.known_for?.length ? `<div class="arena-keywords"><span class="arena-note">known for:</span>${card.known_for.map(k => `<span>${_aEsc(k)}</span>`).join('')}</div>` : ''}</section>` : `<p class="arena-note">The AI writes a yap card at level ${3}+ from their chat history — keep yapping.</p>`}
${y.chat_ai?.overview ? `<section class="arena-yap-cardbox"><h3><i class="fa-solid fa-brain"></i> What the chat AI has on them</h3><p>${_aEsc(y.chat_ai.overview)}</p></section>` : ''}
<div class="arena-topic-cols">
@@@ -588,7 +591,7 @@ function _aRenderList(fighters) {
<span class="arena-rank ${f.rank <= 3 ? `arena-rank-${f.rank}` : ''}">${f.rank}</span>
${_aPortrait(f, 'sm')}
<span class="arena-row-main">
-- <strong>${_aEsc(f.persona.fighter_name)} ${f.live ? '<span class="arena-live-pill">LIVE</span>' : ''} ${_aLevelPill(f.level?.level)}<i class="fa-solid fa-chevron-right arena-chevron"></i></strong>
++ <strong>${_aEsc(f.persona.fighter_name)} ${f.live ? '<span class="arena-live-pill">LIVE</span>' : ''} ${_aTierBadge(f.tier)} ${_aLevelPill(f.level?.level)}<i class="fa-solid fa-chevron-right arena-chevron"></i></strong>
<span class="arena-row-sub">${_aEsc(f.user.display_name)} · ${_aEsc(f.persona.class)} · ${_aEsc(f.persona.element)}${f.voice?.has_data ? ` · <i class="fa-solid fa-microphone" title="has transcript data"></i> Mic ${f.ratings.mic}` : ''}</span>
<em class="arena-row-taunt">“${_aEsc(f.persona.taunt)}”</em>
</span>
@@@ -833,6 +836,22 @@ function _aVoiceCard(f) {
</div>`;
}
++function _aTierBadge(t, cls = '') { return t ? `<span class="arena-tier ${cls}" style="--tc:${_aEsc(t.color)}" title="${_aEsc(t.name)} tier${t.next ? ` · ${t.progress}% to ${_aEsc(t.next.name)}` : ''}"><i class="fa-solid fa-shield-halved"></i> ${_aEsc(t.name)}</span>` : ''; }
++function _aProgressPanel(pr, { compact = false } = {}) {
++ if (!pr) return '';
++ const t = pr.tier;
++ const earned = pr.achievements.filter(a => a.earned_at), locked = pr.achievements.filter(a => !a.earned_at);
++ return `<section class="arena-progress-panel">
++ <div class="arena-progress-head">
++ ${_aTierBadge(t, 'arena-tier-big')}
++ <div class="arena-progress-bar"><div class="arena-tier-track">${pr.tiers.map((x, i) => `<span class="arena-tier-step ${i <= t.index ? 'is-done' : ''} ${i === t.index ? 'is-now' : ''}" style="--tc:${_aEsc(x.color)}" title="${_aEsc(x.name)} · ${x.min} XP"></span>`).join('')}</div>
++ <small class="arena-note"><b>${pr.xp} XP</b> all-time · ${t.next ? `${t.next.xp - pr.xp} more to <b style="color:${_aEsc(t.next.color)}">${_aEsc(t.next.name)}</b>` : 'top tier'} · <b>+${pr.week_xp}</b> this week · ${pr.earned}/${pr.total} unlocked</small></div>
++ </div>
++ <div class="arena-ach-grid">${[...earned, ...locked].slice(0, compact ? 8 : 40).map(a => `<div class="arena-ach ${a.earned_at ? 'is-earned' : 'is-locked'}" title="${_aEsc(a.desc)} · +${a.xp} XP${a.coins ? ` · +${a.coins} OpenCoins` : ''}${a.earned_at ? ` · ${_aEsc(_aAgo(a.earned_at))}` : ''}"><span class="arena-ach-icon">${a.icon}</span><span class="arena-ach-name">${_aEsc(a.name)}</span>${a.earned_at ? '' : `<span class="arena-ach-hint">${_aEsc(a.desc)}</span>`}</div>`).join('')}</div>
++ ${pr.history.length ? `<details class="arena-done arena-history" ${compact ? '' : 'open'}><summary><i class="fa-solid fa-scroll"></i> History <small>${pr.history.length}</small></summary><div class="arena-history-list">${pr.history.slice(0, compact ? 6 : 30).map(e => `<div class="arena-history-row"><span class="arena-history-kind ${_aEsc(e.kind)}"><i class="fa-solid ${({ beef: 'fa-fire-flame-curved', beef_over: 'fa-flag-checkered', level: 'fa-arrow-up', tier: 'fa-shield-halved', achievement: 'fa-medal', subject: 'fa-comment-dots', quoted: 'fa-quote-left' })[e.kind] || 'fa-circle'}"></i></span><span class="arena-history-body">${e.url ? _aA(e.url, `<b>${_aEsc(e.title)}</b>`) : `<b>${_aEsc(e.title)}</b>`}${e.detail ? `<small>${_aEsc(e.detail)}</small>` : ''}</span><span class="arena-history-when">${_aEsc(_aAgo(e.created_at))}</span></div>`).join('')}</div></details>` : ''}
++ </section>`;
++}
++
function _aLevelCard(f) {
const l = f.level || {};
const pct = l.xp_per_level ? Math.round((l.xp_into_level / l.xp_per_level) * 100) : 0;
@@@ -870,38 +889,41 @@ async function _aRenderFighter(root, username) {
</div>
<div class="arena-profile-power">
<div class="arena-power arena-power-lg"><b>${f.ratings.power}</b><small>POWER</small></div>
++ ${f.progress ? _aTierBadge(f.progress.tier, 'arena-tier-big') : ''}
${f.ratings.talk_bonus ? `<div class="arena-talk-bonus" title="Mouth bonus — recent Trash Level XP and beef wins, decays over a week"><i class="fa-solid fa-microphone-lines"></i> +${f.ratings.talk_bonus} mouth</div>` : ''}
<div class="arena-record arena-record-lg" title="beef record">${f.record.wins}W – ${f.record.losses}L</div>
</div>
++ ${_aProgressPanel(f.progress)}
${_aLevelCard(f)}
<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>
++ <div>${_aCustomRadar(f, color, 280)}<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>
++ <section class="arena-sheet">
++ <h3><i class="fa-solid fa-file-invoice"></i> Rap sheet</h3>
++ <p class="arena-sheet-lore">${_aEsc(p.lore)}</p>
++ <div class="arena-sheet-rows">
++ <div class="arena-sheet-row"><span class="arena-sheet-k">Signature</span><span><b>${_aEsc(p.signature_move?.name)}</b> — ${_aEsc(p.signature_move?.description)}</span></div>
++ <div class="arena-sheet-row"><span class="arena-sheet-k">Special</span><span><b>${_aEsc(p.special?.name)}</b> — ${_aEsc(p.special?.description)}</span></div>
++ <div class="arena-sheet-row is-weak"><span class="arena-sheet-k">Weakness</span><span>${_aEsc(p.weakness)}</span></div>
++ <div class="arena-sheet-row"><span class="arena-sheet-k">Walk-out</span><span><i class="fa-solid fa-music"></i> ${_aEsc(p.entrance_music)}</span></div>
++ <div class="arena-sheet-row"><span class="arena-sheet-k">Catchphrase</span><span>“${_aEsc(p.catchphrase)}”</span></div>
++ ${p.typing_style ? `<div class="arena-sheet-row"><span class="arena-sheet-k">Types like</span><span>${_aEsc(p.typing_style)}</span></div>` : ''}
++ </div>
++ ${f.persona_is_fallback ? '<p class="arena-note">Stats-only profile — the AI writes the rest once it has data.</p>' : ''}
++ </section>
++ <section class="arena-taunts">
++ <h3><i class="fa-solid fa-comment-dots"></i> Ragebait <small>in their own typing voice · 🔊 reads it in their chat voice</small></h3>
++ <div class="arena-bubbles">${[p.taunt, ...(p.taunts || [])].filter(Boolean).map(x => `<div class="arena-bubble"><span class="arena-bubble-who">${_aEsc(f.user.display_name || f.user.username)}</span><span class="arena-bubble-text">${_aEsc(x)}</span>${_aSpeakBtn(x, 'arena-bubble-speak', f.user.username)}</div>`).join('')}</div>
++ </section>
++ ${_aVoiceCard(f)}
<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" 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 class="arena-bars-head"><b>The numbers</b> <small>percentile across the roster · these make POWER · tap one</small></div>
++ <div class="arena-numgrid">${ARENA_STATS.map(k => `<div class="arena-num is-clickable" data-stat="${k}" title="${_aEsc((p.stat_quips || {})[k] || ARENA_STAT_LABEL[k])}"><span class="arena-num-label">${_aEsc(ARENA_STAT_LABEL[k])}</span><span class="arena-num-track"><span class="arena-num-fill" style="width:${Math.max(0, Math.min(100, f.ratings[k] || 0))}%;background:${_aEsc(color)}"></span></span><span class="arena-num-val">${f.ratings[k] ?? '–'}</span></div>`).join('')}</div>
<div id="arena-stat-detail"></div>
</div>
-- <div class="arena-lore">
-- <p class="arena-lore-text">${_aEsc(p.lore)}</p>
-- <div class="arena-moves">
-- <div class="arena-move"><span class="arena-move-kind">Signature</span><b>${_aEsc(p.signature_move?.name)}</b><span>${_aEsc(p.signature_move?.description)}</span></div>
-- <div class="arena-move"><span class="arena-move-kind">Special</span><b>${_aEsc(p.special?.name)}</b><span>${_aEsc(p.special?.description)}</span></div>
-- <div class="arena-move arena-move-weak"><span class="arena-move-kind">Weakness</span><b>${_aEsc(p.weakness)}</b></div>
-- </div>
-- <div class="arena-flavor">
-- <span><i class="fa-solid fa-comment"></i> “${_aEsc(p.taunt)}” ${_aSpeakBtn(p.taunt, 'arena-speak', f.user.username)}</span>
-- ${(p.taunts || []).map(x => `<span><i class="fa-solid fa-fire"></i> “${_aEsc(x)}” ${_aSpeakBtn(x, 'arena-speak', f.user.username)}</span>`).join('')}
-- ${p.typing_style ? `<span class="arena-note"><i class="fa-solid fa-keyboard"></i> ${_aEsc(p.typing_style)}</span>` : ''}
-- <span><i class="fa-solid fa-music"></i> ${_aEsc(p.entrance_music)}</span>
-- <span><i class="fa-solid fa-quote-left"></i> ${_aEsc(p.catchphrase)}</span>
-- </div>
-- ${f.persona_is_fallback ? '<p class="arena-note">Stats-only profile — AI lore appears once the AI is enabled.</p>' : ''}
-- </div>
-- ${_aVoiceCard(f)}
-- <div class="arena-numbers">
-- ${[['Hours live (90d)', f.raw.hours], ['Peak viewers', f.raw.peak_viewers], ['Avg viewers', f.raw.avg_viewers], ['Chat msgs / hr', f.raw.messages_per_hour], ['Followers', f.raw.followers], ['Clips', f.raw.clips], ['All-time hours', f.raw.all_time_hours], ['All-time peak', f.raw.all_time_peak]]
++ <div class="arena-numbers arena-numbers-compact">
++ ${[['Hours (90d)', f.raw.hours], ['Peak', f.raw.peak_viewers], ['Avg', f.raw.avg_viewers], ['Msgs/hr', f.raw.messages_per_hour], ['Followers', f.raw.followers], ['Clips', f.raw.clips]]
.map(([l, v]) => `<div class="arena-number"><b>${_aEsc(_aNum(v))}</b><span>${_aEsc(l)}</span></div>`).join('')}
</div>
</div>
@@@ -912,7 +934,7 @@ async function _aRenderFighter(root, username) {
_aBindHome(root);
_aEvery(1000, () => _aTickClocks(root));
-- root.querySelectorAll('.arena-bar.is-clickable, .arena-quip.is-clickable').forEach(el => el.addEventListener('click', async () => {
++ root.querySelectorAll('.arena-num.is-clickable, .arena-quip.is-clickable').forEach(el => el.addEventListener('click', async () => {
const stat = el.dataset.stat;
const box = document.getElementById('arena-stat-detail');
if (box.dataset.stat === stat) { box.innerHTML = ''; box.dataset.stat = ''; return; }
ddiff --git a/server/arena/arena-service.js b/server/arena/arena-service.js
iindex 0259dade..0f19ce40 100644
---- a/server/arena/arena-service.js
++++ b/server/arena/arena-service.js
@@@ -570,7 +570,7 @@ function listFighters() {
return roster.order.map(id => {
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 },
++ 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 }, tier: (() => { try { return require('./progress').tierFor(c.level.xp); } catch { return null; } })(),
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/beef.js b/server/arena/beef.js
iindex 038dd97c..eb3ad640 100644
---- a/server/arena/beef.js
++++ b/server/arena/beef.js
@@@ -141,6 +141,7 @@ function recordHit(speakerId, targetId, hit) {
opened = true;
board().addXp(speakerId, XP_BEEF_OPEN, 'beef_open', beef.id);
console.log(`[Arena] beef #${beef.id} opened: user ${speakerId} → user ${targetId}${history.fights ? ' (rematch)' : ''}${bounty ? ' (bounty!)' : ''}`);
++ try { const pr = require('./progress'); pr.event(`user:${speakerId}`, 'beef', `Started a beef with ${nameOf(targetId)}${history.fights ? ' (rematch)' : ''}`, { detail: hit.best_line, url: `/arena/beef/${beef.id}` }); pr.event(`user:${targetId}`, 'beef', `${nameOf(speakerId)} came for you${history.fights ? ' again' : ''}`, { detail: hit.best_line, url: `/arena/beef/${beef.id}` }); } catch { /* */ }
try { require('./notify').arenaNotify(targetId, { type: 'beef_open', title: `${nameOf(speakerId)} has words for you${history.fights ? ' — rematch' : ''}`, message: `${hit.best_line ? `“${String(hit.best_line).slice(0, 120)}” — ` : ''}answer on your own stream within ${isLive(targetId) ? `${RESPONSE_LIVE_MIN} min` : `${RESPONSE_OFFLINE_HOURS} h`} or forfeit.`, icon: '🥊', url: `/arena/beef/${beef.id}`, key: `open:${beef.id}`, senderId: speakerId, senderName: nameOf(speakerId) }); } catch { /* */ }
const id = beef.id;
headlineFor('open', { a: nameOf(speakerId), b: nameOf(targetId), opening_line: hit.best_line, about: hit.about, rematch: history.fights > 0, record_between: history, bounty: !!bounty }).then(h => { if (h) db.run('UPDATE arena_beefs SET headline = ? WHERE id = ?', [h, id]); }).catch(() => {});
@@@ -190,6 +191,7 @@ function resolve(beef, resolution, winnerId) {
db.run(`UPDATE arena_beefs SET status = 'resolved', resolution = ?, winner_user_id = ?, resolved_at = CURRENT_TIMESTAMP, on_clock = NULL, clock_until = NULL, feed_json = ?, upset = ?, result_headline = ? WHERE id = ? AND status = 'open'`, [resolution, winnerId, feed, upset, templateHeadline(resolution === 'forfeit' ? 'forfeit' : 'score', ctx), beef.id]);
if (winnerId) board().addXp(winnerId, XP_BEEF_WIN + (upset ? 20 : 0), upset ? 'beef_upset_win' : 'beef_win', beef.id);
console.log(`[Arena] beef #${beef.id} resolved: ${resolution}${winnerId ? ` winner user ${winnerId}` : ' draw'}${upset ? ' UPSET' : ''}`);
++ try { const pr = require('./progress'); for (const uid of [beef.a_user_id, beef.b_user_id]) { const won = winnerId === uid; pr.event(`user:${uid}`, 'beef_over', winnerId == null ? `Draw with ${nameOf(uid === beef.a_user_id ? beef.b_user_id : beef.a_user_id)}` : won ? `Beat ${nameOf(loserId)}${upset ? ' — UPSET' : ''}${resolution === 'forfeit' ? ' (they ducked)' : ''}` : `Lost to ${nameOf(winnerId)}${resolution === 'forfeit' ? ' (ran out the clock)' : ''}`, { detail: `${t.a}–${t.b}`, url: `/arena/beef/${beef.id}` }); pr.check(`user:${uid}`); } } catch { /* */ }
try { const n = require('./notify'); for (const uid of [beef.a_user_id, beef.b_user_id]) { const won = winnerId === uid, draw = winnerId == null; n.arenaNotify(uid, { type: 'beef_over', title: draw ? `Draw with ${nameOf(uid === beef.a_user_id ? beef.b_user_id : beef.a_user_id)}` : won ? `You won the beef with ${nameOf(loserId)}${upset ? ' — UPSET' : ''}` : `${nameOf(winnerId)} won the beef${resolution === 'forfeit' ? ' — you ran out the clock' : ' on points'}`, message: won ? `+${XP_BEEF_WIN + (upset ? 20 : 0)} XP. ${t.a}–${t.b}.` : `${t.a}–${t.b}. Say their name on mic for a rematch.`, icon: won ? '🏆' : draw ? '🤝' : '💀', url: `/arena/beef/${beef.id}`, key: `over:${beef.id}` }); } } catch { /* */ }
const id = beef.id;
headlineFor(resolution, ctx).then(h => { if (h) db.run('UPDATE arena_beefs SET result_headline = ? WHERE id = ?', [h, id]); }).catch(() => {});
ddiff --git a/server/arena/board.js b/server/arena/board.js
iindex 72f73403..994fde69 100644
---- a/server/arena/board.js
++++ b/server/arena/board.js
@@@ -133,7 +133,7 @@ function ensureTables() {
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
for (const col of ["kind TEXT NOT NULL DEFAULT 'topic'", 'target_user_id INTEGER', 'headline TEXT', 'heat REAL DEFAULT 0', 'source_note TEXT', 'expires_at DATETIME', 'resolved_json TEXT',
-- 'keywords_json TEXT', 'lore TEXT', 'tagline TEXT', 'lore_updated_at DATETIME', 'lore_moment_count INTEGER DEFAULT 0', 'chat_mentions INTEGER DEFAULT 0', 'mic_mentions INTEGER DEFAULT 0', 'last_mention_at DATETIME', 'origin_json TEXT', 'creator_ip_hash TEXT', 'submitted_text TEXT']) {
++ 'keywords_json TEXT', 'lore TEXT', 'tagline TEXT', 'lore_updated_at DATETIME', 'lore_moment_count INTEGER DEFAULT 0', 'chat_mentions INTEGER DEFAULT 0', 'mic_mentions INTEGER DEFAULT 0', 'last_mention_at DATETIME', 'origin_json TEXT', 'creator_ip_hash TEXT', 'submitted_text TEXT', 'peak_heat REAL DEFAULT 0']) {
try { db.run(`ALTER TABLE arena_topics ADD COLUMN ${col}`); } catch { /* exists */ }
}
for (const col of ['topic_moments INTEGER DEFAULT 0', 'topics_joined INTEGER DEFAULT 0']) { try { db.run(`ALTER TABLE arena_trash_levels ADD COLUMN ${col}`); } catch { /* exists */ } }
@@@ -173,8 +173,9 @@ function addXp(userId, amount, reason, refId = null, extra = {}) {
db.run('UPDATE arena_trash_levels SET best_line = ?, best_line_vod_id = ?, best_line_sec = ?, best_line_score = ? WHERE user_id = ?', [String(extra.line).slice(0, 220), extra.lineVodId || null, extra.lineSec ?? null, extra.lineScore, userId]);
}
const after = levelRow(userId);
-- if (after.level > before.level) console.log(`[Arena] user ${userId} → Trash Level ${after.level}`);
++ if (after.level > before.level) { console.log(`[Arena] user ${userId} → Trash Level ${after.level}`); try { require('./progress').event(`user:${userId}`, 'level', `Trash Level ${after.level}`, { detail: `${after.xp} XP` }); } catch { /* */ } }
try { arena().loadRoster(true); } catch { /* */ }
++ if (!reason.startsWith('ach_')) { try { require('./progress').check(`user:${userId}`); } catch (e) { console.warn('[Arena] progress:', e.message); } }
return { ...after, leveled_up: after.level > before.level, gained: amount };
}
@@@ -306,7 +307,7 @@ async function submitTopic({ text, userId, ip, creatorName, onRoster = false })
}
const t = createTopic({ text: refined?.subject || raw, hint: refined?.hint || null, createdBy: onRoster ? 'streamer' : 'viewer', creatorUserId: userId, creatorName, creatorIp: ip, headline: refined?.headline || null, tagline: refined?.tagline || `Put up by ${creatorName}`, sourceNote: creatorName, keywords: refined?.keywords || null, threads: refined?.threads || null, submittedText: raw });
backfillMoments([t]);
-- try { require('./chatters').onSubjectStarted(`user:${userId}`, t.id, { display: creatorName }); } catch { /* */ }
++ try { require('./chatters').onSubjectStarted(`user:${userId}`, t.id, { display: creatorName }); require('./progress').event(`user:${userId}`, 'subject', `Started “${t.headline || t.text}”`, { url: `/arena/topic/${t.id}` }); } catch { /* */ }
return t;
}
@@@ -415,7 +416,7 @@ function computeHeat(topicId) {
const talking = db.get(`SELECT COUNT(*) AS n FROM arena_topic_members WHERE topic_id = ? AND active = 1`, [topicId])?.n || 0;
const heat = (m.mic || 0) * 3 + (m.chat || 0) + hype + talking * 4;
const prev = db.get('SELECT heat, creator_user_id, headline, text FROM arena_topics WHERE id = ?', [topicId]);
-- db.run('UPDATE arena_topics SET heat = ? WHERE id = ?', [heat, topicId]);
++ db.run('UPDATE arena_topics SET heat = ?, peak_heat = MAX(COALESCE(peak_heat, 0), ?) WHERE id = ?', [heat, heat, topicId]);
if (prev && (prev.heat || 0) < HOT_THRESHOLD && heat >= HOT_THRESHOLD) {
try { const n = require('./notify'); const who = new Set([prev.creator_user_id, ...db.all('SELECT user_id FROM arena_topic_members WHERE topic_id = ?', [topicId]).map(r => r.user_id), ...db.all(`SELECT user_id FROM arena_topic_moments WHERE topic_id = ? AND user_id IS NOT NULL GROUP BY user_id`, [topicId]).map(r => r.user_id)].filter(Boolean)); for (const uid of who) n.arenaNotify(uid, { type: 'hot', title: `“${String(prev.headline || prev.text).slice(0, 60)}” is HOT`, message: `Your subject is the loudest thing on the board right now (${heat} heat).`, icon: '🔥', url: `/arena/topic/${topicId}`, key: `hot:${topicId}` }); } catch { /* */ }
}
ddiff --git a/server/arena/chatters.js b/server/arena/chatters.js
iindex 1eed507b..2f2b3896 100644
---- a/server/arena/chatters.js
++++ b/server/arena/chatters.js
@@@ -147,6 +147,7 @@ function addXp(key, amount, reason, refId = null, { display = null, streamId = n
db.run('INSERT INTO chatter_xp_log (key, amount, reason, ref_id) VALUES (?, ?, ?, ?)', [key, amount, reason, refId]);
const leveled = level > (before.level || 1);
if (leveled) onLevelUp(key, before.level || 1, level, { streamId });
++ if (!String(reason).startsWith('ach_')) { try { require('./progress').check(key, { streamId }); } catch (e) { console.warn('[Arena] progress:', e.message); } }
return { leveled_up: leveled, level, xp, gained: amount, title: titleFor(level) };
}
@@@ -154,6 +155,7 @@ function onLevelUp(key, from, to, { streamId = null } = {}) {
const p = parseKey(key);
const name = displayFor(key);
console.log(`[Arena] yapper ${name} (${key}) → level ${to} ${titleFor(to)}`);
++ try { require('./progress').event(key, 'level', `Yap level ${to} — ${titleFor(to)}`); } catch { /* */ }
// Site-wide OpenCoins for OpenVibe accounts — one payout per level, idempotent in the wallet.
if (p && p.kind === 'user') {
try {
@@@ -208,7 +210,7 @@ function onMoment(moment, topic, { hot = false } = {}) {
xp += streakBonus(key);
db.run(`UPDATE chatter_profiles SET moments = moments + 1, subjects = (SELECT COUNT(*) FROM chatter_subjects s WHERE s.key = chatter_profiles.key), best_line = CASE WHEN best_line IS NULL OR LENGTH(?) > LENGTH(best_line) THEN ? ELSE best_line END, best_line_topic_id = CASE WHEN best_line IS NULL OR LENGTH(?) > LENGTH(best_line) THEN ? ELSE best_line_topic_id END WHERE key = ?`,
[moment.text, clip(moment.text, 200), moment.text, topic.id, key]);
-- return addXp(key, xp, first ? 'moment_first' : 'moment', topic.id, { display: moment.username, streamId: moment.stream_id || null });
++ return addXp(key, xp, hot ? 'moment_hot' : (first ? 'moment_first' : 'moment'), topic.id, { display: moment.username, streamId: moment.stream_id || null });
}
/** Daily streak: the first moment of a day extends the streak (or restarts it) and pays a bonus. */
@@@ -245,7 +247,7 @@ function onLore(topic, loreText, moments) {
const key = m.chatter_key || keyFor(m);
if (!key || seen.has(key)) continue;
const name = String(m.username || '').replace(/^\[[^\]]+\]\s+/, '').toLowerCase();
-- if (name.length >= 3 && text.includes(name)) { seen.add(key); db.run('UPDATE chatter_profiles SET quoted = quoted + 1 WHERE key = ?', [key]); addXp(key, XP_QUOTED_IN_LORE, 'quoted_in_lore', topic.id, { display: m.username }); n++; const p = parseKey(key); if (p?.kind === 'user') { try { require('./notify').arenaNotify(p.user_id, { type: 'quoted', title: `You made the lore of “${String(topic.headline || topic.text).slice(0, 60)}”`, message: `+${XP_QUOTED_IN_LORE} XP for getting quoted.`, icon: '📜', url: `/arena/topic/${topic.id}`, key: `quoted:${topic.id}` }); } catch { /* */ } } }
++ if (name.length >= 3 && text.includes(name)) { seen.add(key); db.run('UPDATE chatter_profiles SET quoted = quoted + 1 WHERE key = ?', [key]); addXp(key, XP_QUOTED_IN_LORE, 'quoted_in_lore', topic.id, { display: m.username }); n++; try { require('./progress').event(key, 'quoted', `Quoted in “${topic.headline || topic.text}”`, { url: `/arena/topic/${topic.id}` }); } catch { /* */ } const p = parseKey(key); if (p?.kind === 'user') { try { require('./notify').arenaNotify(p.user_id, { type: 'quoted', title: `You made the lore of “${String(topic.headline || topic.text).slice(0, 60)}”`, message: `+${XP_QUOTED_IN_LORE} XP for getting quoted.`, icon: '📜', url: `/arena/topic/${topic.id}`, key: `quoted:${topic.id}` }); } catch { /* */ } } }
}
return n;
}
ddiff --git a/server/arena/progress.js b/server/arena/progress.js
nnew file mode 100644
iindex 00000000..6349cfc8
---- /dev/null
++++ b/server/arena/progress.js
@@@ -0,0 +1,175 @@
++/**
++ * progress.js — the part that BUILDS over time: tiers, achievements, a history feed, weekly ladders.
++ *
++ * tiers → Bronze → Silver → Gold → Platinum → Diamond → Mythic, from all-time XP (fighters:
++ * arena_trash_levels.xp; chatters: chatter_profiles.xp). Never goes down.
++ * achievements → milestones checked from the data already there (no AI): first blood, streaks,
++ * upsets, rematch wins, subjects started, HOT subjects, being quoted, hype received,
++ * bounties, days in a row… Each pays XP; accounts also get OpenCoins (idempotent).
++ * history → arena_events: everything notable that happened to a key, newest first — beefs,
++ * level-ups, tier-ups, achievements, subjects, quotes. The profile timeline.
++ * weekly → XP gained in the last 7 days per key (fighters + chatters) for "this week" crowns.
++ *
++ * Keys: fighters are `user:<id>` (same as their chatter key) — one history per person; chatters of
++ * other kinds are `anon:<n>` / `relay:<platform>:<name>`.
++ */
++'use strict';
++
++const db = require('../db/database');
++
++const TIERS = [[0, 'Bronze', '#cd7f32'], [150, 'Silver', '#c0c0c0'], [400, 'Gold', '#f5c542'], [900, 'Platinum', '#7fe7e0'], [1800, 'Diamond', '#8ab4ff'], [3500, 'Mythic', '#ff5fd2']];
++
++// { id, name, desc, icon, xp, coins, for: 'fighter'|'chatter'|'both', check(ctx) → bool, hint }
++const ACHIEVEMENTS = [
++ // fighters (on-mic)
++ { id: 'first_blood', for: 'fighter', name: 'First Blood', icon: '🩸', desc: 'Land your first judged hit in a beef.', xp: 20, coins: 20, check: c => c.f.beef_hits >= 1 },
++ { id: 'answered', for: 'fighter', name: 'Not Ducking', icon: '🎙️', desc: 'Answer a beef on your own stream before the clock ran out.', xp: 25, coins: 25, check: c => c.f.answered >= 1 },
++ { id: 'first_win', for: 'fighter', name: 'W', icon: '🏆', desc: 'Win a beef.', xp: 30, coins: 30, check: c => c.f.wins >= 1 },
++ { id: 'five_wins', for: 'fighter', name: 'Problem', icon: '🥊', desc: 'Win five beefs.', xp: 80, coins: 100, check: c => c.f.wins >= 5 },
++ { id: 'streak3', for: 'fighter', name: 'Untouchable', icon: '🔥', desc: 'Three beef wins in a row.', xp: 60, coins: 60, check: c => c.f.streak >= 3 },
++ { id: 'upset', for: 'fighter', name: 'Giant Killer', icon: '⚡', desc: 'Beat someone ranked 4+ places above you.', xp: 60, coins: 80, check: c => c.f.upsets >= 1 },
++ { id: 'rematch_win', for: 'fighter', name: 'Run It Back', icon: '🔁', desc: 'Win a rematch.', xp: 40, coins: 40, check: c => c.f.rematch_wins >= 1 },
++ { id: 'on_the_board', for: 'fighter', name: 'On The Board', icon: '📌', desc: 'Get a judged on-mic moment on a subject.', xp: 15, coins: 10, check: c => c.f.topic_moments >= 1 },
++ { id: 'loudmouth', for: 'fighter', name: 'Loudmouth', icon: '📢', desc: 'Twenty-five judged on-mic moments.', xp: 60, coins: 60, check: c => c.f.topic_moments >= 25 },
++ { id: 'bounty_hunter', for: 'fighter', name: 'Bounty Hunter', icon: '💰', desc: 'Collect a bounty.', xp: 40, coins: 60, check: c => c.f.bounties >= 1 },
++ { id: 'main_event', for: 'fighter', name: 'Main Character', icon: '🎯', desc: 'Be heard on a subject that went HOT.', xp: 30, coins: 30, check: c => c.f.hot_subjects >= 1 },
++ { id: 'crowd', for: 'fighter', name: 'Crowd Favorite', icon: '👑', desc: 'Get hyped 25 times.', xp: 40, coins: 50, check: c => c.f.hypes_received >= 25 },
++ // chatters (from chat)
++ { id: 'first_moment', for: 'chatter', name: 'Said Something', icon: '💬', desc: 'Land your first line on a subject.', xp: 5, coins: 5, check: c => c.c.moments >= 1 },
++ { id: 'ten_moments', for: 'chatter', name: 'Yapping', icon: '🗣️', desc: 'Ten lines on the board.', xp: 15, coins: 10, check: c => c.c.moments >= 10 },
++ { id: 'fifty_moments', for: 'chatter', name: 'Cannot Be Stopped', icon: '🌪️', desc: 'Fifty lines on the board.', xp: 50, coins: 50, check: c => c.c.moments >= 50 },
++ { id: 'three_subjects', for: 'chatter', name: 'Everywhere', icon: '🧭', desc: 'Pile onto three different subjects.', xp: 15, coins: 10, check: c => c.c.subjects >= 3 },
++ { id: 'starter', for: 'chatter', name: 'Instigator', icon: '🧨', desc: 'Start a subject.', xp: 20, coins: 20, check: c => c.c.subjects_started >= 1 },
++ { id: 'quoted', for: 'chatter', name: 'In The Lore', icon: '📜', desc: 'Get quoted in a subject\'s story.', xp: 15, coins: 15, check: c => c.c.quoted >= 1 },
++ { id: 'quoted5', for: 'chatter', name: 'Historian\'s Nightmare', icon: '📚', desc: 'Quoted five times.', xp: 40, coins: 40, check: c => c.c.quoted >= 5 },
++ { id: 'hot_take', for: 'chatter', name: 'Hot Take', icon: '🌶️', desc: 'Land a line on a subject while it\'s HOT.', xp: 10, coins: 10, check: c => c.c.hot_lines >= 1 },
++ { id: 'streak3', for: 'chatter', name: 'Three Days', icon: '📅', desc: 'Show up three days in a row.', xp: 15, coins: 15, check: c => c.c.best_streak >= 3 },
++ { id: 'streak7', for: 'chatter', name: 'Regular', icon: '🗓️', desc: 'Seven days in a row.', xp: 50, coins: 60, check: c => c.c.best_streak >= 7 },
++ { id: 'level5', for: 'chatter', name: 'Instigator Rank', icon: '⬆️', desc: 'Reach yap level 5.', xp: 25, coins: 25, check: c => c.c.level >= 5 },
++ { id: 'level10', for: 'chatter', name: 'Menace Rank', icon: '😈', desc: 'Reach yap level 10.', xp: 80, coins: 100, check: c => c.c.level >= 10 },
++];
++
++let _ready = false;
++function ensureTables() {
++ if (_ready) return;
++ db.run(`CREATE TABLE IF NOT EXISTS arena_achievements (
++ key TEXT NOT NULL,
++ achievement_id TEXT NOT NULL,
++ earned_at DATETIME DEFAULT CURRENT_TIMESTAMP,
++ PRIMARY KEY (key, achievement_id)
++ )`);
++ db.run(`CREATE TABLE IF NOT EXISTS arena_events (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ key TEXT NOT NULL,
++ kind TEXT NOT NULL,
++ title TEXT NOT NULL,
++ detail TEXT,
++ url TEXT,
++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
++ )`);
++ db.run('CREATE INDEX IF NOT EXISTS idx_arena_events_key ON arena_events (key, id)');
++ db.run(`CREATE TABLE IF NOT EXISTS arena_tier_paid (key TEXT NOT NULL, tier INTEGER NOT NULL, PRIMARY KEY (key, tier))`);
++ try { db.run('ALTER TABLE arena_topics ADD COLUMN peak_heat REAL DEFAULT 0'); } catch { /* exists */ }
++ _ready = true;
++}
++
++function tierFor(xp) { let t = 0; for (let i = 0; i < TIERS.length; i++) if ((xp || 0) >= TIERS[i][0]) t = i; const [min, name, color] = TIERS[t]; const next = TIERS[t + 1] || null; return { index: t, name, color, min, next: next ? { name: next[1], xp: next[0], color: next[2] } : null, progress: next ? Math.round(((xp - min) / (next[0] - min)) * 100) : 100 }; }
++
++/** History feed entry (also mirrored to the notification bell by callers when it matters). */
++function event(key, kind, title, { detail = null, url = null } = {}) {
++ ensureTables();
++ if (!key) return;
++ db.run('INSERT INTO arena_events (key, kind, title, detail, url) VALUES (?, ?, ?, ?, ?)', [key, kind, String(title).slice(0, 160), detail ? String(detail).slice(0, 240) : null, url]);
++}
++function events(key, limit = 30) { ensureTables(); return db.all('SELECT * FROM arena_events WHERE key = ? ORDER BY id DESC LIMIT ?', [key, limit]); }
++
++// ── Context for achievement checks (cheap aggregate queries) ──
++function fighterCtx(userId) {
++ const lv = db.get('SELECT * FROM arena_trash_levels WHERE user_id = ?', [userId]) || {};
++ const beefs = db.get(`SELECT SUM(winner_user_id = ?) AS wins, SUM(status = 'resolved' AND winner_user_id = ? AND upset = 1) AS upsets, SUM(status = 'resolved' AND winner_user_id = ? AND rematch = 1) AS rematch_wins, SUM(b_user_id = ? AND responded = 1) AS answered FROM arena_beefs WHERE a_user_id = ? OR b_user_id = ?`, [userId, userId, userId, userId, userId, userId]) || {};
++ let streak = 0; try { streak = require('./beef').streakFor(userId); } catch { /* */ }
++ const bounties = db.get(`SELECT COUNT(*) AS n FROM arena_xp_log WHERE user_id = ? AND reason = 'bounty_claimed'`, [userId])?.n || 0;
++ const hot = db.get(`SELECT COUNT(*) AS n FROM arena_topic_members m JOIN arena_topics t ON t.id = m.topic_id WHERE m.user_id = ? AND t.peak_heat >= 12`, [userId])?.n || 0;
++ const hypes = (db.get('SELECT COUNT(*) AS n FROM arena_topic_hype WHERE user_id = ?', [userId])?.n || 0) + (db.get(`SELECT COUNT(*) AS n FROM arena_beef_hype h JOIN arena_beefs b ON b.id = h.beef_id WHERE (h.side = 'a' AND b.a_user_id = ?) OR (h.side = 'b' AND b.b_user_id = ?)`, [userId, userId])?.n || 0);
++ return { xp: lv.xp || 0, beef_hits: lv.beef_hits || 0, topic_moments: lv.topic_moments || 0, wins: beefs.wins || 0, upsets: beefs.upsets || 0, rematch_wins: beefs.rematch_wins || 0, answered: beefs.answered || 0, streak, bounties, hot_subjects: hot, hypes_received: hypes };
++}
++function chatterCtx(key) {
++ const r = db.get('SELECT * FROM chatter_profiles WHERE key = ?', [key]) || {};
++ const hot = db.get(`SELECT COUNT(*) AS n FROM chatter_xp_log WHERE key = ? AND reason = 'moment_hot'`, [key])?.n || 0;
++ return { xp: r.xp || 0, level: r.level || 1, moments: r.moments || 0, subjects: r.subjects || 0, subjects_started: r.subjects_started || 0, quoted: r.quoted || 0, best_streak: r.best_streak || 0, hot_lines: hot };
++}
++
++/** Run every applicable achievement for a key; awards XP/coins/events/notifications for new ones. Returns the new ids. */
++function check(key, { streamId = null } = {}) {
++ ensureTables();
++ const p = String(key || '').split(':');
++ const isUser = p[0] === 'user';
++ const userId = isUser ? Number(p[1]) : null;
++ const onRoster = isUser && (() => { try { return !!require('./arena-service').loadRoster().byId[userId]; } catch { return false; } })();
++ const ctx = { f: onRoster ? fighterCtx(userId) : null, c: chatterCtx(key) };
++ const earned = new Set(db.all('SELECT achievement_id FROM arena_achievements WHERE key = ?', [key]).map(r => r.achievement_id));
++ const fresh = [];
++ for (const a of ACHIEVEMENTS) {
++ if (earned.has(a.id)) continue;
++ if (a.for === 'fighter' && !ctx.f) continue;
++ let ok = false; try { ok = !!a.check(ctx); } catch { ok = false; }
++ if (!ok) continue;
++ db.run('INSERT OR IGNORE INTO arena_achievements (key, achievement_id) VALUES (?, ?)', [key, a.id]);
++ fresh.push(a);
++ event(key, 'achievement', `${a.icon} ${a.name}`, { detail: a.desc, url: isUser && onRoster ? `/arena/${encodeURIComponent(db.getUserById(userId)?.username || '')}` : `/arena/chatter/${encodeURIComponent(key)}` });
++ // XP: fighters into Trash Level, everyone into their chatter profile (same key for accounts → one bar builds).
++ try { if (a.for === 'fighter') require('./board').addXp(userId, a.xp, `ach_${a.id}`); else require('./chatters').addXp(key, a.xp, `ach_${a.id}`, null, { streamId }); } catch { /* */ }
++ if (isUser && a.coins) { try { require('../monetization/opencoins').credit(userId, a.coins, `arena_ach_${a.id}`, `live:arena_ach:${userId}:${a.id}`, { achievement: a.id }); } catch { /* */ } }
++ if (isUser) { try { require('./notify').arenaNotify(userId, { type: 'achievement', title: `${a.icon} ${a.name}`, message: `${a.desc} +${a.xp} XP${a.coins ? ` · +${a.coins} OpenCoins` : ''}`, icon: a.icon, url: onRoster ? `/arena/${encodeURIComponent(db.getUserById(userId)?.username || '')}` : `/arena/chatter/${encodeURIComponent(key)}`, key: `ach:${a.id}` }); } catch { /* */ } }
++ console.log(`[Arena] ${key} unlocked ${a.name}`);
++ }
++ checkTier(key, ctx);
++ return fresh.map(a => a.id);
++}
++
++/** Tier-ups: pay once per tier (accounts: coins = tier index × 50), log + notify. */
++function checkTier(key, ctx) {
++ const xp = Math.max(ctx.f ? ctx.f.xp : 0, ctx.c ? ctx.c.xp : 0);
++ const t = tierFor(xp);
++ if (t.index === 0) return;
++ const paid = db.all('SELECT tier FROM arena_tier_paid WHERE key = ?', [key]).map(r => r.tier);
++ for (let i = 1; i <= t.index; i++) {
++ if (paid.includes(i)) continue;
++ db.run('INSERT OR IGNORE INTO arena_tier_paid (key, tier) VALUES (?, ?)', [key, i]);
++ const name = TIERS[i][1];
++ event(key, 'tier', `Reached ${name}`, { detail: `${TIERS[i][0]}+ XP all-time` });
++ const p = String(key).split(':');
++ if (p[0] === 'user') {
++ const userId = Number(p[1]);
++ try { require('../monetization/opencoins').credit(userId, i * 50, `arena_tier_${name.toLowerCase()}`, `live:arena_tier:${userId}:${i}`, { tier: name }); } catch { /* */ }
++ try { require('./notify').arenaNotify(userId, { type: 'tier', title: `${name} tier`, message: `Your arena rep passed ${TIERS[i][0]} XP. +${i * 50} OpenCoins.`, icon: '🏅', url: '/arena', key: `tier:${i}` }); } catch { /* */ }
++ }
++ }
++}
++
++/** Everything the profile needs: tier, achievements (earned + locked with hints), history, weekly XP. */
++function view(key) {
++ ensureTables();
++ const p = String(key || '').split(':');
++ const isUser = p[0] === 'user';
++ const userId = isUser ? Number(p[1]) : null;
++ const onRoster = isUser && (() => { try { return !!require('./arena-service').loadRoster().byId[userId]; } catch { return false; } })();
++ const fx = onRoster ? (db.get('SELECT xp FROM arena_trash_levels WHERE user_id = ?', [userId])?.xp || 0) : 0;
++ const cx = db.get('SELECT xp FROM chatter_profiles WHERE key = ?', [key])?.xp || 0;
++ const xp = Math.max(fx, cx);
++ const earned = new Map(db.all('SELECT achievement_id, earned_at FROM arena_achievements WHERE key = ?', [key]).map(r => [r.achievement_id, r.earned_at]));
++ const list = ACHIEVEMENTS.filter(a => a.for === 'chatter' || onRoster).map(a => ({ id: a.id, for: a.for, name: a.name, icon: a.icon, desc: a.desc, xp: a.xp, coins: isUser ? a.coins : 0, earned_at: earned.get(a.id) || null }));
++ const weekF = onRoster ? (db.get(`SELECT COALESCE(SUM(amount), 0) AS n FROM arena_xp_log WHERE user_id = ? AND created_at >= datetime('now', '-7 days')`, [userId])?.n || 0) : 0;
++ const weekC = db.get(`SELECT COALESCE(SUM(amount), 0) AS n FROM chatter_xp_log WHERE key = ? AND created_at >= datetime('now', '-7 days')`, [key])?.n || 0;
++ return { key, xp, tier: tierFor(xp), tiers: TIERS.map(([min, name, color]) => ({ min, name, color })), achievements: list, earned: list.filter(a => a.earned_at).length, total: list.length, week_xp: weekF + weekC, history: events(key, 30) };
++}
++
++/** Weekly fighters ladder (XP gained in 7 days). */
++function weeklyFighters(limit = 5) {
++ ensureTables();
++ const roster = require('./arena-service').loadRoster();
++ const board = require('./board');
++ return db.all(`SELECT user_id, SUM(amount) AS gained FROM arena_xp_log WHERE created_at >= datetime('now', '-7 days') GROUP BY user_id ORDER BY gained DESC LIMIT ?`, [limit]).filter(r => roster.byId[r.user_id]).map(r => ({ ...board.fighterBrief(r.user_id, roster), gained: r.gained, tier: tierFor(db.get('SELECT xp FROM arena_trash_levels WHERE user_id = ?', [r.user_id])?.xp || 0) }));
++}
++
++module.exports = { ensureTables, TIERS, ACHIEVEMENTS, tierFor, event, events, check, view, weeklyFighters, fighterCtx, chatterCtx };
ddiff --git a/server/arena/routes.js b/server/arena/routes.js
iindex 6d779e37..46dad345 100644
---- a/server/arena/routes.js
++++ b/server/arena/routes.js
@@@ -51,7 +51,7 @@ router.get('/fighters/:user', async (req, res) => {
try {
const card = await arena.getFighter(req.params.user, { generate: req.query.generate !== '0' });
if (!card) return res.status(404).json({ error: 'No such fighter' });
-- if (!card.not_on_roster) { try { card.rivalries = beef.rivalriesFor(card.user.id); } catch { card.rivalries = []; } }
++ if (!card.not_on_roster) { try { card.rivalries = beef.rivalriesFor(card.user.id); } catch { card.rivalries = []; } try { card.progress = require('./progress').view(`user:${card.user.id}`); } catch { card.progress = null; } }
res.set('Cache-Control', 'no-store');
res.json(card);
} catch (err) { fail(res, err, 'Failed to load fighter'); }
@@@ -99,7 +99,7 @@ router.get('/board', (req, res) => {
try {
const v = board.boardView();
res.set('Cache-Control', 'no-store');
-- const ch = require('./chatters'); res.json({ ...v, levels: board.levelsLeaderboard(8), yappers: ch.leaderboard(8), yappers_week: ch.leaderboard(5, { days: 7 }), yappers_total: ch.count(), ai: arena.aiOn() });
++ const ch = require('./chatters'); let fightersWeek = []; try { fightersWeek = require('./progress').weeklyFighters(5); } catch { /* */ } res.json({ ...v, levels: board.levelsLeaderboard(8), fighters_week: fightersWeek, yappers: ch.leaderboard(8), yappers_week: ch.leaderboard(5, { days: 7 }), yappers_total: ch.count(), ai: arena.aiOn() });
} catch (err) { fail(res, err, 'Failed to load the board'); }
});
router.post('/board/topics', requireAuth, async (req, res) => {
@@@ -183,18 +183,20 @@ router.get('/me', requireAuth, (req, res) => {
const subjects = db.all(`SELECT t.id, t.headline, t.text, t.heat, s.moments FROM chatter_subjects s JOIN arena_topics t ON t.id = s.topic_id WHERE s.key = ? AND t.status = 'open' ORDER BY t.heat DESC LIMIT 5`, [key]);
const onClock = beefs.filter(b => (b.on_clock === 'a' ? b.a.user.id : b.b.user.id) === req.user.id);
res.set('Cache-Control', 'no-store');
-- res.json({ chatter, xp_today: xpToday, checked_in: checkedIn, coins_from_arena: coins, fighter, beefs, on_clock: onClock, subjects, hot_now: board.boardView().open.filter(t => t.hot).slice(0, 3).map(t => ({ id: t.id, headline: t.headline || t.text })) });
++ let progress = null; try { progress = require('./progress').view(key); } catch { /* */ }
++ res.json({ chatter, progress, xp_today: xpToday, checked_in: checkedIn, coins_from_arena: coins, fighter, beefs, on_clock: onClock, subjects, hot_now: board.boardView().open.filter(t => t.hot).slice(0, 3).map(t => ({ id: t.id, headline: t.headline || t.text })) });
} catch (err) { fail(res, err, 'Failed to load your arena'); }
});
router.post('/checkin', requireAuth, (req, res) => {
try { res.json(require('./chatters').checkin(req.user.id, { display: req.user.display_name || req.user.username })); } catch (err) { fail(res, err, 'Check-in failed'); }
});
++router.get('/progress/:key', (req, res) => { try { res.set('Cache-Control', 'no-store'); res.json(require('./progress').view(String(req.params.key || ''))); } catch (err) { fail(res, err, 'Failed'); } });
router.get('/levels', (req, res) => { try { res.json({ levels: board.levelsLeaderboard(20) }); } catch (err) { fail(res, err, 'Failed'); } });
router.get('/yappers', (req, res) => { try { const ch = require('./chatters'); res.json({ yappers: ch.leaderboard(20), week: ch.leaderboard(10, { days: 7 }), total: ch.count() }); } catch (err) { fail(res, err, 'Failed'); } });
// Chatter (yapper) profile by key: user:<id> · anon:<n> · relay:<platform>:<name> — anyone who chats has one.
router.get('/chatter/:key', (req, res) => {
-- try { const p = require('./chatters').profile(String(req.params.key || '')); if (!p) return res.status(404).json({ error: 'No such chatter yet — say something about a subject first' }); res.set('Cache-Control', 'no-store'); res.json(p); }
++ try { const p = require('./chatters').profile(String(req.params.key || '')); if (!p) return res.status(404).json({ error: 'No such chatter yet — say something about a subject first' }); try { p.progress = require('./progress').view(p.key); } catch { p.progress = null; } res.set('Cache-Control', 'no-store'); res.json(p); }
catch (err) { fail(res, err, 'Failed to load chatter'); }
});
router.get('/chatter/by-user/:username', (req, res) => {
ddiff --git a/test/arena-chatters.test.js b/test/arena-chatters.test.js
iindex 83d4f1cf..270c145c 100644
---- a/test/arena-chatters.test.js
++++ b/test/arena-chatters.test.js
@@@ -26,18 +26,18 @@ const t = board.createTopic({ text: 'The baby voice', createdBy: 'community', cr
const say = (uid2, uname, msg, extra = {}) => board.addMoment(t.id, { kind: 'chat', source: 'chat', userId: uid2, username: uname, text: msg, ...extra });
say(uid, 'viewer', 'the baby voice is a war crime');
let p = ch.profile('user:viewer'.replace('viewer', uid));
--assert.strictEqual(p.xp, ch.XP_MOMENT + ch.XP_FIRST_ON_SUBJECT + 5, 'moment + first-on-subject + day-1 streak');
++assert.strictEqual(p.xp, ch.XP_MOMENT + ch.XP_FIRST_ON_SUBJECT + 5 + 5, 'moment + first-on-subject + day-1 streak + the Said Something achievement');
assert.strictEqual(p.streak, 1); assert.strictEqual(p.subjects, 1); assert.strictEqual(p.moments, 1);
say(uid, 'viewer', 'baby voice again');
p = ch.profile(`user:${uid}`);
--assert.strictEqual(p.xp, 12 + ch.XP_MOMENT, 'second line: just the moment (streak already paid today)');
++assert.strictEqual(p.xp, 17 + ch.XP_MOMENT, 'second line: just the moment (streak already paid today)');
say(null, 'anon4412', 'baby voice enjoyers rise', { anonId: 'anon4412' });
say(null, '[Twitch] BobLol', 'the baby voice guy again', { platform: 'twitch' });
assert.strictEqual(ch.profile('anon:4412').kind, 'anon'); assert.strictEqual(ch.profile('relay:twitch:boblol').name, '[Twitch] BobLol');
assert.strictEqual(ch.profile('anon:4412').coins, false, 'anon: level + title, no coins');
db.run(`UPDATE arena_topics SET heat = 50 WHERE id = ?`, [t.id]);
say(uid, 'viewer', 'HOT take on the baby voice');
--assert.strictEqual(ch.profile(`user:${uid}`).xp, 15 + ch.XP_MOMENT_HOT, 'hot subjects pay double');
++assert.strictEqual(ch.profile(`user:${uid}`).xp, 20 + ch.XP_MOMENT_HOT + 10, 'hot subjects pay double (+ the Hot Take achievement)');
assert.strictEqual(db.get('SELECT chatter_key FROM arena_topic_moments ORDER BY id DESC LIMIT 1').chatter_key, `user:${uid}`);
console.log('✅ moments → XP for accounts, anon and relay chatters');
@@@ -45,7 +45,7 @@ console.log('✅ moments → XP for accounts, anon and relay chatters');
const before = ch.profile(`user:${uid}`).xp;
const lore = await board.buildLore(t.id, { force: true });
assert.ok(/viewer/.test(lore.lore), 'template lore names the chatter');
-- assert.strictEqual(ch.profile(`user:${uid}`).xp, before + ch.XP_QUOTED_IN_LORE, 'quoted in the lore → XP');
++ assert.strictEqual(ch.profile(`user:${uid}`).xp, before + ch.XP_QUOTED_IN_LORE + 15, 'quoted in the lore → XP (+ the In The Lore achievement)');
assert.strictEqual(ch.profile(`user:${uid}`).quoted, 1);
ch.onHype(`user:${uid}`, t.id); assert.strictEqual(ch.profile(`user:${uid}`).hypes, 1);
ch.onSubjectStarted(`user:${uid}`, t.id, { display: 'Viewer' }); assert.strictEqual(ch.profile(`user:${uid}`).subjects_started, 1);
ddiff --git a/test/arena-progress.test.js b/test/arena-progress.test.js
nnew file mode 100644
iindex 00000000..00f668d8
---- /dev/null
++++ b/test/arena-progress.test.js
@@@ -0,0 +1,54 @@
++'use strict';
++// Growth: tiers from all-time XP, achievements from existing data (XP + coins + history), weekly XP.
++const assert = require('assert');
++const fs = require('fs'); const os = require('os'); const path = require('path');
++const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-progress-'));
++process.env.DB_PATH = path.join(tmp, 'live.db');
++const db = require('../server/db/database'); db.initDb();
++const arena = require('../server/arena/arena-service'); const board = require('../server/arena/board'); const beef = require('../server/arena/beef'); const ch = require('../server/arena/chatters'); const pr = require('../server/arena/progress');
++arena.ensureTables(); board.ensureTables(); beef.ensureTables(); ch.ensureTables(); pr.ensureTables();
++
++assert.deepStrictEqual([0, 149, 150, 400, 3500, 9000].map(x => pr.tierFor(x).name), ['Bronze', 'Bronze', 'Silver', 'Gold', 'Mythic', 'Mythic']);
++assert.strictEqual(pr.tierFor(275).progress, 50, 'halfway from Silver (150) to Gold (400)');
++assert.strictEqual(pr.tierFor(9000).next, null);
++console.log('✅ tiers');
++
++const mk = (u) => Number(db.createUser({ username: u, email: `${u}@x`, password_hash: 'x', display_name: u, stream_key: u.padEnd(32, '0') }).lastInsertRowid);
++const u1 = mk('nova'), u2 = mk('grizzly'), viewer = mk('viewer');
++for (const uid of [u1, u2, viewer]) db.ensureChannel(uid);
++const stream = (uid, peak, hours, daysAgo) => { const id = Number(db.createStream({ user_id: uid, title: 't', category: 'irl', protocol: 'rtmp' }).lastInsertRowid); db.run(`UPDATE streams SET is_live = 0, started_at = datetime('now', ?), ended_at = datetime('now', ?), duration_seconds = ?, peak_viewers = ? WHERE id = ?`, [`-${daysAgo} days`, `-${daysAgo} days`, hours * 3600, peak, id]); db.run(`INSERT INTO stream_analytics (stream_id, avg_viewers, peak_viewers, unique_chatters, total_messages, total_watch_minutes, new_followers, clips_created, coins_earned) VALUES (?, ?, ?, ?, ?, ?, 0, 0, 0)`, [id, peak / 2, peak, peak, peak * 20, peak * 60]); };
++stream(u1, 100, 5, 1); stream(u2, 40, 5, 1);
++arena.loadRoster(true);
++
++// A chatter earns "Said Something" from their first moment; XP + history + coins attempt.
++const t = board.createTopic({ text: 'The baby voice', createdBy: 'community', creatorName: 'chat', keywords: ['baby voice'] });
++board.addMoment(t.id, { kind: 'chat', source: 'chat', userId: viewer, username: 'viewer', text: 'baby voice is a war crime' });
++let v = pr.view(`user:${viewer}`);
++assert.ok(v.achievements.find(a => a.id === 'first_moment').earned_at, 'first moment achievement');
++assert.ok(v.history.some(e => e.kind === 'achievement' && /Said Something/.test(e.title)), 'history records it');
++assert.ok(!v.achievements.some(a => a.for === 'fighter'), 'a viewer is not shown fighter achievements');
++assert.ok(ch.profile(`user:${viewer}`).xp >= 12 + 5, 'achievement XP lands on the same bar');
++console.log('✅ chatter achievements from moments');
++
++// A fighter: first blood → win → the beef events + achievements + tier.
++beef.recordHit(u1, u2, { quality: 8, best_line: 'grizzly streams to alts', about: 'alts' });
++let f = pr.view(`user:${u1}`);
++assert.ok(f.achievements.find(a => a.id === 'first_blood').earned_at, 'first blood');
++assert.ok(f.history.some(e => e.kind === 'beef'), 'beef in history');
++const b = beef.openBeefsFor(u1)[0];
++db.run(`UPDATE arena_beefs SET clock_until = datetime('now', '-1 minute') WHERE id = ?`, [b.id]); beef.tick();
++f = pr.view(`user:${u1}`);
++assert.ok(f.achievements.find(a => a.id === 'first_win').earned_at, 'first win');
++assert.ok(f.history.some(e => e.kind === 'beef_over' && /Beat grizzly/.test(e.title)));
++assert.ok(f.xp >= 40 + 20 + 30, `xp accrued: ${f.xp}`);
++assert.strictEqual(pr.view(`user:${u2}`).history.some(e => /Lost to/.test(e.title)), true);
++board.addXp(u1, 200, 'test');
++f = pr.view(`user:${u1}`);
++assert.strictEqual(f.tier.name, 'Silver'); assert.ok(f.history.some(e => e.kind === 'tier' && /Silver/.test(e.title)), 'tier-up recorded once');
++board.addXp(u1, 1, 'test');
++assert.strictEqual(pr.view(`user:${u1}`).history.filter(e => e.kind === 'tier').length, 1, 'not re-paid');
++assert.ok(f.week_xp >= f.xp - 1, 'weekly XP counts recent gains');
++const wk = pr.weeklyFighters(3); assert.strictEqual(wk[0].user.id, u1); assert.strictEqual(wk[0].tier.name, 'Silver');
++console.log('✅ fighter achievements, beef history, tiers (paid once), weekly ladder');
++console.log('\n✅ All Arena progress tests passed');
++process.exit(0);