openvibers/openvibe.live Commits
Arena growth: tiers, achievements, history feed, weekly crowns; fighter page rebuilt (rap sheet, ragebait bubbles, numbers grid); radar labels
Changes
@@@ -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.@@@ -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; }++}@@@ -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>@@@ -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; }@@@ -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 },@@@ -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(() => {});@@@ -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 { /* */ }}@@@ -147,6 +147,7 @@ function addXp(key, amount, reason, refId = null, { display = null, streamId = ndb.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;}nnew file mode 100644@@@ -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 };@@@ -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) => {@@@ -26,18 +26,18 @@ const t = board.createTopic({ text: 'The baby voice', createdBy: 'community', crconst 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);nnew file mode 100644@@@ -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);
@@ -124,3 +124,13 @@ Where it shows: the Yappers block on `/arena` (podium with level rings, streak f
@@ -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.
- 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.
- 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.
- 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.
@@ -14588,3 +14588,65 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg
@@ -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-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; }
.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; } }
@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; }
+}
@@ -62,7 +62,7 @@
@@ -62,7 +62,7 @@
</script>
</script>
<!-- Stylesheets -->
<!-- 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/chat-shared.css?v=1">
<link rel="stylesheet" href="/css/broadcast.css?v=11">
<link rel="stylesheet" href="/css/broadcast.css?v=11">
<link rel="stylesheet" href="/css/cosmetics.css?v=2">
<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
@@ -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/staff-console.js?v=1" defer></script>
<script src="/js/dashboard-moderation.js?v=5" 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/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/powerchat.js?v=10" defer></script>
<script src="/js/pastes.js?v=15" defer></script>
<script src="/js/pastes.js?v=15" defer></script>
<script src="/js/draggable-fab.js?v=2" defer></script>
<script src="/js/draggable-fab.js?v=2" defer></script>
@@ -124,7 +124,7 @@ function _aRadar(r, color, size = 200) {
@@ -124,7 +124,7 @@ function _aRadar(r, color, size = 200) {
function _aCustomRadar(f, color, size = 240) {
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 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 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 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 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 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) {
@@ -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('')}
${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>
<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, 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>`;
</svg>`;
}
}
function _aCustomQuips(f) {
function _aCustomQuips(f) {
@@ -221,6 +221,7 @@ async function _aRenderHome(root) {
@@ -221,6 +221,7 @@ async function _aRenderHome(root) {
<div class="arena-ladder">
<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>
<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 || [])}
${_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>
<div class="arena-ladder arena-ladder-yap" id="arena-yappers">
<div class="arena-ladder arena-ladder-yap" id="arena-yappers">
${_aYappersSection(boardData)}
${_aYappersSection(boardData)}
@@ -274,14 +275,15 @@ async function _aRenderMe() {
@@ -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 { /* */ } }
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;
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">
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>
<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">
<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.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.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>`}
${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>
</div>`;
</div>`;
_aEvery(1000, () => _aTickClocks(el));
_aEvery(1000, () => _aTickClocks(el));
@@ -566,6 +568,7 @@ async function _aRenderChatter(root, key) {
@@ -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 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>
</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>`}
${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>` : ''}
${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">
<div class="arena-topic-cols">
@@ -588,7 +591,7 @@ function _aRenderList(fighters) {
@@ -588,7 +591,7 @@ function _aRenderList(fighters) {
<span class="arena-rank ${f.rank <= 3 ? `arena-rank-${f.rank}` : ''}">${f.rank}</span>
<span class="arena-rank ${f.rank <= 3 ? `arena-rank-${f.rank}` : ''}">${f.rank}</span>
${_aPortrait(f, 'sm')}
${_aPortrait(f, 'sm')}
<span class="arena-row-main">
<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>
<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>
<em class="arena-row-taunt">“${_aEsc(f.persona.taunt)}”</em>
</span>
</span>
@@ -833,6 +836,22 @@ function _aVoiceCard(f) {
@@ -833,6 +836,22 @@ function _aVoiceCard(f) {
</div>`;
</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) {
function _aLevelCard(f) {
const l = f.level || {};
const l = f.level || {};
const pct = l.xp_per_level ? Math.round((l.xp_into_level / l.xp_per_level) * 100) : 0;
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) {
@@ -870,38 +889,41 @@ async function _aRenderFighter(root, username) {
</div>
</div>
<div class="arena-profile-power">
<div class="arena-profile-power">
<div class="arena-power arena-power-lg"><b>${f.ratings.power}</b><small>POWER</small></div>
<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>` : ''}
${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 class="arena-record arena-record-lg" title="beef record">${f.record.wins}W – ${f.record.losses}L</div>
</div>
</div>
+ ${_aProgressPanel(f.progress)}
${_aLevelCard(f)}
${_aLevelCard(f)}
<div class="arena-profile-custom">
<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) : ''}
${(p.custom_stats || []).length ? _aCustomQuips(f) : ''}
</div>
</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-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 id="arena-stat-detail"></div>
</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('')}
.map(([l, v]) => `<div class="arena-number"><b>${_aEsc(_aNum(v))}</b><span>${_aEsc(l)}</span></div>`).join('')}
</div>
</div>
</div>
</div>
@@ -912,7 +934,7 @@ async function _aRenderFighter(root, username) {
@@ -912,7 +934,7 @@ async function _aRenderFighter(root, username) {
_aBindHome(root);
_aBindHome(root);
_aEvery(1000, () => _aTickClocks(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 stat = el.dataset.stat;
const box = document.getElementById('arena-stat-detail');
const box = document.getElementById('arena-stat-detail');
if (box.dataset.stat === stat) { box.innerHTML = ''; box.dataset.stat = ''; return; }
if (box.dataset.stat === stat) { box.innerHTML = ''; box.dataset.stat = ''; return; }
@@ -570,7 +570,7 @@ function listFighters() {
@@ -570,7 +570,7 @@ function listFighters() {
return roster.order.map(id => {
return roster.order.map(id => {
const c = cardFor(id, roster, { includeRaw: false });
const c = cardFor(id, roster, { includeRaw: false });
return {
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: { 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,
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 },
voice: { has_data: c.voice.has_data, talk_ratio_pct: c.voice.talk_ratio_pct, speech_minutes: c.voice.speech_minutes },
@@ -141,6 +141,7 @@ function recordHit(speakerId, targetId, hit) {
@@ -141,6 +141,7 @@ function recordHit(speakerId, targetId, hit) {
opened = true;
opened = true;
board().addXp(speakerId, XP_BEEF_OPEN, 'beef_open', beef.id);
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!)' : ''}`);
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 { /* */ }
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;
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(() => {});
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) {
@@ -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]);
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);
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' : ''}`);
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 { /* */ }
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;
const id = beef.id;
headlineFor(resolution, ctx).then(h => { if (h) db.run('UPDATE arena_beefs SET result_headline = ? WHERE id = ?', [h, id]); }).catch(() => {});
headlineFor(resolution, ctx).then(h => { if (h) db.run('UPDATE arena_beefs SET result_headline = ? WHERE id = ?', [h, id]); }).catch(() => {});
@@ -133,7 +133,7 @@ function ensureTables() {
@@ -133,7 +133,7 @@ function ensureTables() {
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
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',
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 */ }
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 */ } }
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 = {}) {
@@ -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]);
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);
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 { /* */ }
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 };
return { ...after, leveled_up: after.level > before.level, gained: amount };
}
}
@@ -306,7 +307,7 @@ async function submitTopic({ text, userId, ip, creatorName, onRoster = false })
@@ -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 });
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]);
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;
return t;
}
}
@@ -415,7 +416,7 @@ function computeHeat(topicId) {
@@ -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 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 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]);
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) {
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 { /* */ }
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 { /* */ }
}
}
@@ -147,6 +147,7 @@ function addXp(key, amount, reason, refId = null, { display = null, streamId = n
@@ -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]);
db.run('INSERT INTO chatter_xp_log (key, amount, reason, ref_id) VALUES (?, ?, ?, ?)', [key, amount, reason, refId]);
const leveled = level > (before.level || 1);
const leveled = level > (before.level || 1);
if (leveled) onLevelUp(key, before.level || 1, level, { streamId });
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) };
return { leveled_up: leveled, level, xp, gained: amount, title: titleFor(level) };
}
}
@@ -154,6 +155,7 @@ function onLevelUp(key, from, to, { streamId = null } = {}) {
@@ -154,6 +155,7 @@ function onLevelUp(key, from, to, { streamId = null } = {}) {
const p = parseKey(key);
const p = parseKey(key);
const name = displayFor(key);
const name = displayFor(key);
console.log(`[Arena] yapper ${name} (${key}) → level ${to} ${titleFor(to)}`);
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.
// Site-wide OpenCoins for OpenVibe accounts — one payout per level, idempotent in the wallet.
if (p && p.kind === 'user') {
if (p && p.kind === 'user') {
try {
try {
@@ -208,7 +210,7 @@ function onMoment(moment, topic, { hot = false } = {}) {
@@ -208,7 +210,7 @@ function onMoment(moment, topic, { hot = false } = {}) {
xp += streakBonus(key);
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 = ?`,
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]);
[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. */
/** 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) {
@@ -245,7 +247,7 @@ function onLore(topic, loreText, moments) {
const key = m.chatter_key || keyFor(m);
const key = m.chatter_key || keyFor(m);
if (!key || seen.has(key)) continue;
if (!key || seen.has(key)) continue;
const name = String(m.username || '').replace(/^\[[^\]]+\]\s+/, '').toLowerCase();
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;
return n;
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,175 @@
@@ -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 };
@@ -51,7 +51,7 @@ router.get('/fighters/:user', async (req, res) => {
@@ -51,7 +51,7 @@ router.get('/fighters/:user', async (req, res) => {
try {
try {
const card = await arena.getFighter(req.params.user, { generate: req.query.generate !== '0' });
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) 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.set('Cache-Control', 'no-store');
res.json(card);
res.json(card);
} catch (err) { fail(res, err, 'Failed to load fighter'); }
} catch (err) { fail(res, err, 'Failed to load fighter'); }
@@ -99,7 +99,7 @@ router.get('/board', (req, res) => {
@@ -99,7 +99,7 @@ router.get('/board', (req, res) => {
try {
try {
const v = board.boardView();
const v = board.boardView();
res.set('Cache-Control', 'no-store');
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'); }
} catch (err) { fail(res, err, 'Failed to load the board'); }
});
});
router.post('/board/topics', requireAuth, async (req, res) => {
router.post('/board/topics', requireAuth, async (req, res) => {
@@ -183,18 +183,20 @@ router.get('/me', requireAuth, (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 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);
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.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'); }
} catch (err) { fail(res, err, 'Failed to load your arena'); }
});
});
router.post('/checkin', requireAuth, (req, res) => {
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'); }
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('/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'); } });
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.
// Chatter (yapper) profile by key: user:<id> · anon:<n> · relay:<platform>:<name> — anyone who chats has one.
router.get('/chatter/:key', (req, res) => {
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'); }
catch (err) { fail(res, err, 'Failed to load chatter'); }
});
});
router.get('/chatter/by-user/:username', (req, res) => {
router.get('/chatter/by-user/:username', (req, res) => {
@@ -26,18 +26,18 @@ const t = board.createTopic({ text: 'The baby voice', createdBy: 'community', cr
@@ -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 });
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');
say(uid, 'viewer', 'the baby voice is a war crime');
let p = ch.profile('user:viewer'.replace('viewer', uid));
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);
assert.strictEqual(p.streak, 1); assert.strictEqual(p.subjects, 1); assert.strictEqual(p.moments, 1);
say(uid, 'viewer', 'baby voice again');
say(uid, 'viewer', 'baby voice again');
p = ch.profile(`user:${uid}`);
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, 'anon4412', 'baby voice enjoyers rise', { anonId: 'anon4412' });
say(null, '[Twitch] BobLol', 'the baby voice guy again', { platform: 'twitch' });
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').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');
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]);
db.run(`UPDATE arena_topics SET heat = 50 WHERE id = ?`, [t.id]);
say(uid, 'viewer', 'HOT take on the baby voice');
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}`);
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');
console.log('✅ moments → XP for accounts, anon and relay chatters');
@@ -45,7 +45,7 @@ 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 before = ch.profile(`user:${uid}`).xp;
const lore = await board.buildLore(t.id, { force: true });
const lore = await board.buildLore(t.id, { force: true });
assert.ok(/viewer/.test(lore.lore), 'template lore names the chatter');
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);
assert.strictEqual(ch.profile(`user:${uid}`).quoted, 1);
ch.onHype(`user:${uid}`, t.id); assert.strictEqual(ch.profile(`user:${uid}`).hypes, 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);
ch.onSubjectStarted(`user:${uid}`, t.id, { display: 'Viewer' }); assert.strictEqual(ch.profile(`user:${uid}`).subjects_started, 1);
new file mode 100644
new file mode 100644
@@ -0,0 +1,54 @@
@@ -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);