openvibers/openvibe.live Commits
Arena: judge past speech (backfill), no personas/portraits; promo: desperation copy, restream tour, VIP recruiter, crypto payouts
Changes
@@@ -11,6 +11,8 @@ ADMIN_PASSWORD=changeme123# RS_PROMO_AMOUNT=50 # top of the $25–$50 range# RS_PROMO_AMOUNT_MIN=25# RS_PROMO_REFERRAL=10 # paid to an existing user who brings a RobotStreamer streamer over++# RS_PROMO_VIP_USER=patrick # the VIP recruiter spotlighted on the home page (username)++# RS_PROMO_REFERRAL_VIP=20 # the VIP recruiter's per-conversion rate# RS_PROMO_GITHUB=https://github.com/OpenVibers/OpenVibe.Live# RS_PROMO_OWNER=alex # in-site username claimants DM (defaults to ADMIN_USERNAME)# RS_PROMO_DISCORD=https://discord.gg/M6MuRUaeJj@@@ -6,7 +6,11 @@ The **Arena** tab (`/arena`) is streamer-vs-streamer shit talk with exactly one- **The feed** — everything else that is actually shit talk (at chat, the mods, other platforms, the game, the world) is judged, scored 0–10, VOD-linked and lands in the live feed. It pays Trash Level XP. Gameplay narration and small talk score nothing.- **The ladder** — every fighter is rated on seven mic-only stats, as percentiles across the roster, plus an AI persona and portrait written from their transcripts.--With AI off everything still works: keyword judges, template headlines, fallback personas.++**No personas, no portraits, no AI ring names** (removed 2026-09-11): fighters are the streamers, under their own names and avatars. The only AI on this page is the judge.++++**Backfill** (`server/arena/backfill.js`): the listener only hears live cams, so past speech (last 7 days, ended streams) is judged by the job every 20 min (and 90 s after boot): chunks of ≥ 20 words, only the ones that look spicy or name a fighter go to a model (≤ 160 calls per run), name-drops become `callout` moments with the target attached (no clocks for old speech), the rest `trash` moments — dated when they were *said*, VOD-linked. A per-fighter cursor keeps it incremental. Admin: `POST /api/arena/backfill`.++++With AI off everything still works: keyword judges, template headlines.## Speech policy@@@ -207,6 +207,12 @@ body.rs-ticker-on .rs-modal, body.rs-ticker-on .rs-takeover { --rs-ticker-h: 0px.rs-chip-zelle { border-color: rgba(109,40,217,0.8); background: linear-gradient(135deg, rgba(109,40,217,0.5), rgba(139,92,246,0.25)); }.rs-chip-paypal { border-color: rgba(0,112,186,0.9); background: linear-gradient(135deg, rgba(0,48,135,0.6), rgba(0,156,222,0.3)); }.rs-chip-paypal i { color: #7cc4ff; }++.rs-chip-crypto { border-color: rgba(247,147,26,0.9); background: linear-gradient(135deg, rgba(247,147,26,0.45), rgba(251,191,36,0.2)); }++.rs-chip-crypto i { color: #ffd28a; }++.rs-toggle-3 { grid-template-columns: 1fr 1fr 1fr; }++.rs-toggle-3 .rs-toggle-thumb { width: calc(33.333% - 4px); }++.rs-toggle-3[data-method="paypal"] .rs-toggle-thumb { transform: translateX(100%); }++.rs-toggle-3[data-method="crypto"] .rs-toggle-thumb { transform: translateX(200%); background: linear-gradient(135deg, #b45309, #f7931a); box-shadow: 0 6px 18px rgba(247,147,26,0.5); }.rs-chip-cash { border-color: rgba(34,197,94,0.8); background: linear-gradient(135deg, rgba(21,128,61,0.55), rgba(34,197,94,0.25)); }.rs-chip-build { border-color: rgba(251,191,36,0.8); background: linear-gradient(135deg, rgba(180,83,9,0.5), rgba(251,191,36,0.22)); }.rs-chip-build i { color: #fde68a; }@@@ -374,6 +380,94 @@ body.rs-ticker-on .rs-modal, body.rs-ticker-on .rs-takeover { --rs-ticker-h: 0px.rs-card-replay { background: none; border: none; color: #94a3b8; font-size: 0.72rem; cursor: pointer; text-decoration: underline dotted; }.rs-card-replay:hover { color: #fff; }++/* ═══════════════════════════════════════════════════════════════++ VIP RECRUITER — the starfish bounty board under the hero card++ ═══════════════════════════════════════════════════════════════ */++.rs-vip {++ position: relative; z-index: 1; margin-top: 14px; padding: 16px 20px; border-radius: 20px; overflow: hidden; text-align: left;++ display: grid; grid-template-columns: 140px 1fr; gap: 18px; align-items: center; color: #fff;++ background: linear-gradient(120deg, #0b3a5c, #0e5c7a 55%, #1a2f6b);++ border: 2px solid rgba(255,143,190,0.6); box-shadow: 0 20px 50px rgba(0,0,0,0.45), 0 0 30px rgba(255,95,162,0.25);++ animation: rsCardIn 0.9s cubic-bezier(.2,1.3,.3,1) 0.5s both;++}++.rs-vip::before { content: ''; position: absolute; inset: 0; background: repeating-linear-gradient(0deg, transparent 0 22px, rgba(255,255,255,0.03) 22px 23px); pointer-events: none; }++.rs-vip-bubbles { position: absolute; inset: 0; pointer-events: none; }++.rs-vip-bubbles span { position: absolute; bottom: -20px; width: 10px; height: 10px; border-radius: 50%; border: 2px solid rgba(255,255,255,0.55); background: rgba(255,255,255,0.08); animation: rsBubble 6s ease-in infinite; }++.rs-vip-bubbles span:nth-child(1) { left: 8%; animation-delay: 0s; } .rs-vip-bubbles span:nth-child(2) { left: 22%; width: 6px; height: 6px; animation-delay: -2.2s; }++.rs-vip-bubbles span:nth-child(3) { left: 47%; animation-delay: -4s; } .rs-vip-bubbles span:nth-child(4) { left: 63%; width: 14px; height: 14px; animation-delay: -1.3s; }++.rs-vip-bubbles span:nth-child(5) { left: 80%; animation-delay: -3.1s; } .rs-vip-bubbles span:nth-child(6) { left: 93%; width: 7px; height: 7px; animation-delay: -5s; }++@keyframes rsBubble { 0% { transform: translate(0, 0) scale(0.6); opacity: 0; } 10% { opacity: 0.9; } 100% { transform: translate(-14px, -170px) scale(1.2); opacity: 0; } }++.rs-vip-star { position: relative; width: 120px; height: 120px; margin: 0 auto; transform-origin: 50% 80%; animation: rsWobble 2.6s ease-in-out infinite; filter: drop-shadow(0 8px 14px rgba(0,0,0,0.45)); }++.rs-vip-star svg { width: 100%; height: 100%; display: block; }++.rs-vip-pupil { animation: rsGoogly 3.2s ease-in-out infinite; transform-origin: center; }++@keyframes rsWobble { 0%,100% { transform: rotate(-7deg) scale(1, 1); } 25% { transform: rotate(6deg) scale(1.04, 0.94); } 50% { transform: rotate(-4deg) scale(0.96, 1.06); } 75% { transform: rotate(7deg) scale(1.03, 0.97); } }++@keyframes rsGoogly { 0%,100% { transform: translate(0, 0); } 30% { transform: translate(2px, 1px); } 60% { transform: translate(-2px, 2px); } 80% { transform: translate(1px, -1px); } }++.rs-vip-sparkle { position: absolute; color: #fde68a; font-size: 1.1rem; animation: rsTwinkle 1.6s ease-in-out infinite; text-shadow: 0 0 10px #fbbf24; }++.rs-vip-sparkle:nth-child(2) { left: -6px; top: 10px; } .rs-vip-sparkle:nth-child(3) { right: -8px; top: 24px; animation-delay: -0.5s; } .rs-vip-sparkle:nth-child(4) { left: 50%; bottom: -8px; animation-delay: -1s; }++@keyframes rsTwinkle { 0%,100% { transform: scale(0.6) rotate(0); opacity: 0.4; } 50% { transform: scale(1.3) rotate(45deg); opacity: 1; } }++.rs-vip-kicker { display: inline-flex; align-items: center; gap: 6px; color: #ffb3d1; font-weight: 800; font-size: 0.72rem; letter-spacing: 0.12em; text-transform: uppercase; margin-bottom: 4px; }++.rs-vip-title { margin: 0 0 6px; font-size: clamp(1.05rem, 2.2vw, 1.4rem); font-weight: 900; line-height: 1.2; }++.rs-vip-title .rs-money { font-size: 1.4em; vertical-align: -0.08em; }++.rs-vip-sub { margin: 0 0 10px; color: #dbeafe; font-size: 0.86rem; line-height: 1.45; }++.rs-vip-chips { display: flex; flex-wrap: wrap; gap: 8px; }++.rs-vip-chips .rs-chip { padding: 4px 10px; font-size: 0.78rem; animation: none; }++@media (max-width: 720px) { .rs-vip { grid-template-columns: 1fr; text-align: center; } .rs-vip-chips { justify-content: center; } }++++/* ═══════════════════════════════════════════════════════════════++ RESTREAM TOUR — cam/robot → OpenVibe hub → every platform, with packets flowing++ ═══════════════════════════════════════════════════════════════ */++.rs-flow2 { display: grid; gap: 6px; justify-items: center; margin: 12px 0 4px; }++.rs-flow2-src, .rs-flow2-out { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; }++.rs-flow2-node {++ display: inline-flex; align-items: center; gap: 6px; padding: 5px 12px; border-radius: 999px; font-weight: 700; font-size: 0.8rem;++ color: #fff; background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.22); backdrop-filter: blur(6px);++ animation: rsChipIn 0.6s cubic-bezier(.2,1.4,.3,1) both;++}++.rs-flow2-out .rs-flow2-node:nth-child(1) { animation-delay: 0.05s; } .rs-flow2-out .rs-flow2-node:nth-child(2) { animation-delay: 0.12s; }++.rs-flow2-out .rs-flow2-node:nth-child(3) { animation-delay: 0.19s; } .rs-flow2-out .rs-flow2-node:nth-child(4) { animation-delay: 0.26s; } .rs-flow2-out .rs-flow2-node:nth-child(5) { animation-delay: 0.33s; }++.rs-flow2-node.rs-twitch { border-color: #9146ff; background: rgba(145,70,255,0.25); }++.rs-flow2-node.rs-yt { border-color: #ff0000; background: rgba(255,0,0,0.2); }++.rs-flow2-node.rs-kick { border-color: #53fc18; background: rgba(83,252,24,0.16); color: #d9ffcc; }++.rs-flow2-node.rs-rs { border-color: var(--rs-blue); background: rgba(125,211,252,0.16); }++.rs-flow2-hub {++ display: grid; justify-items: center; gap: 2px; padding: 10px 18px; border-radius: 14px; text-align: center;++ background: linear-gradient(135deg, rgba(139,92,246,0.45), rgba(109,40,217,0.25)); border: 1px solid rgba(167,139,250,0.75);++ animation: rsGlowViolet 2s ease-in-out infinite;++}++.rs-flow2-hub b { font-size: 1rem; color: #fff; }++.rs-flow2-hub small { color: #e9d5ff; font-size: 0.74rem; font-weight: 600; }++.rs-flow2-wire { position: relative; width: 4px; height: 26px; border-radius: 2px; background: rgba(255,255,255,0.18); overflow: visible; }++.rs-flow2-fan { width: min(70%, 420px); height: 26px; background: none; }++.rs-flow2-fan::before { content: ''; position: absolute; left: 0; right: 0; top: 50%; height: 3px; background: repeating-linear-gradient(90deg, rgba(255,255,255,0.45) 0 8px, transparent 8px 16px); animation: rsDash 0.6s linear infinite; }++.rs-flow2-fan::after { content: ''; position: absolute; left: 50%; top: 0; width: 4px; height: 50%; background: rgba(255,255,255,0.18); transform: translateX(-50%); }++.rs-packet { position: absolute; left: 50%; top: -6px; width: 10px; height: 10px; margin-left: -5px; border-radius: 50%; background: #fde68a; box-shadow: 0 0 12px #fbbf24, 0 0 24px rgba(251,191,36,0.6); animation: rsPacketDown 1.2s linear infinite; }++.rs-packet:nth-child(2) { animation-delay: 0.6s; }++.rs-flow2-fan .rs-packet { top: 50%; margin-top: -5px; left: 50%; animation: rsPacketFan 1.4s ease-in-out infinite; }++.rs-flow2-fan .rs-packet:nth-child(2) { animation-name: rsPacketFanL; animation-delay: 0.45s; }++.rs-flow2-fan .rs-packet:nth-child(3) { animation-name: rsPacketFanR; animation-delay: 0.9s; }++@keyframes rsPacketDown { 0% { transform: translateY(0); opacity: 0; } 15% { opacity: 1; } 100% { transform: translateY(26px); opacity: 0; } }++@keyframes rsPacketFan { 0% { transform: translate(0, -14px) scale(0.6); opacity: 0; } 30% { opacity: 1; } 100% { transform: translate(0, 12px) scale(1); opacity: 0; } }++@keyframes rsPacketFanL { 0% { transform: translate(0, -14px) scale(0.6); opacity: 0; } 30% { opacity: 1; } 100% { transform: translate(-160px, 12px) scale(1); opacity: 0; } }++@keyframes rsPacketFanR { 0% { transform: translate(0, -14px) scale(0.6); opacity: 0; } 30% { opacity: 1; } 100% { transform: translate(160px, 12px) scale(1); opacity: 0; } }++.rs-features { display: flex; flex-wrap: wrap; justify-content: center; gap: 6px; margin-top: 10px; }++.rs-feat {++ display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; border-radius: 999px; font-size: 0.76rem; font-weight: 700; color: #fff;++ background: rgba(34,197,94,0.14); border: 1px solid rgba(34,197,94,0.5);++ animation: rsFeatPop 0.5s cubic-bezier(.2,1.5,.3,1) both; animation-delay: calc(0.4s + var(--i, 0) * 0.08s);++}++.rs-feat i { color: #86efac; }++@keyframes rsFeatPop { from { opacity: 0; transform: translateY(8px) scale(0.85); } to { opacity: 1; transform: none; } }++.rs-flow2.is-compact { margin: 8px 0 0; gap: 4px; }++.rs-flow2.is-compact .rs-flow2-node { padding: 3px 9px; font-size: 0.72rem; }++.rs-flow2.is-compact .rs-flow2-hub { padding: 6px 12px; }++.rs-flow2.is-compact .rs-flow2-hub b { font-size: 0.86rem; } .rs-flow2.is-compact .rs-flow2-hub small { font-size: 0.68rem; }++.rs-flow2.is-compact .rs-flow2-wire { height: 16px; } .rs-flow2.is-compact .rs-flow2-fan { height: 16px; }++.rs-tk-tour { max-width: 720px; margin: 0 auto 18px; padding: 12px 14px 14px; border-radius: 16px; background: rgba(0,0,0,0.28); border: 1px solid rgba(255,255,255,0.14); }++.rs-tk-tour-head { font-weight: 900; font-size: 1rem; color: #fff; margin-bottom: 4px; }++.rs-tk-tour-head i { color: var(--rs-blue); }++.rs-step .rs-flow2 { margin-top: 8px; }++@media (max-width: 720px) { .rs-flow2-fan { width: 90%; } }++/* ═══════════════════════════════════════════════════════════════STREAM-PAGE ALERT — under the player on every channel page═══════════════════════════════════════════════════════════════ */@@@ -587,7 +681,9 @@ body.rs-ticker-on .rs-modal, body.rs-ticker-on .rs-takeover { --rs-ticker-h: 0px.rs-tk-amount, .rs-tk-amount.pop .rs-money, .rs-chip, .rs-flow-node.rs-to, .rs-flow-arrow, .rs-walker,.rs-btn-gold, .rs-btn-gold::after, .rs-bills span, .rs-card-border, .rs-card-border::before, .rs-coin, .rs-card-kicker i,.rs-modal, .rs-modal-card, .rs-modal-card::before, .rs-step, .rs-modal-fine i, .rs-wiggle,-- .rs-stream-alert, .rs-sa-stripe, .rs-sa-siren, .rs-sa-mini i.fa-robot {++ .rs-stream-alert, .rs-sa-stripe, .rs-sa-siren, .rs-sa-mini i.fa-robot,++ .rs-flow2-node, .rs-flow2-hub, .rs-flow2-fan::before, .rs-packet, .rs-feat,++ .rs-vip, .rs-vip-bubbles span, .rs-vip-star, .rs-vip-pupil, .rs-vip-sparkle {animation: none !important;}.rs-tk-title .rs-w, .rs-tk-content, .rs-card-border, .rs-step, .rs-chip { opacity: 1; transform: none; }@@@ -67,7 +67,7 @@<link rel="stylesheet" href="/css/broadcast.css?v=11"><link rel="stylesheet" href="/css/cosmetics.css?v=2"><link rel="stylesheet" href="/css/messenger.css?v=1">-- <link rel="stylesheet" href="/css/rs-promo.css?v=3">++ <link rel="stylesheet" href="/css/rs-promo.css?v=6"><link rel="stylesheet" href="/css/i18n-star.css?v=2"><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">@@@ -5120,12 +5120,12 @@ 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=17" defer></script>++<script src="/js/arena.js?v=18" 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><script src="/js/messenger.js?v=2" defer></script>--<script src="/js/rs-promo.js?v=5" defer></script>++<script src="/js/rs-promo.js?v=8" defer></script><!-- ── OpenVibe shared libs (notifications, account sync, themes) ── --><script src="/shared/theme-loader.js" defer></script>@@@ -83,15 +83,13 @@ function _aBindSpeak(root) {function _aPortrait(f, size = 'md') {const u = f.user || f;const color = u.profile_color || '#8b5cf6';-- if (f.image_url) return `<div class="arena-portrait arena-portrait-${size}" style="--fc:${_aEsc(color)}"><img src="${_aEsc(f.image_url)}" alt="" loading="lazy"></div>`;if (u.avatar_url) return `<div class="arena-portrait arena-portrait-${size} arena-portrait-avatar" style="--fc:${_aEsc(color)}"><img src="${_aEsc(u.avatar_url)}" alt="" loading="lazy"><span class="arena-portrait-glow"></span></div>`;return `<div class="arena-portrait arena-portrait-${size} arena-portrait-initial" style="--fc:${_aEsc(color)}"><span>${_aEsc(_aInitial(u))}</span></div>`;}/** A fighter's radar: the seven mic stats, or their AI characteristics when the persona has them. */function _aCustomRadar(f, color, size = 240, { objective = false } = {}) {-- const cs = (!objective && 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 = ARENA_STATS.map(k => ({ name: ARENA_STAT_LABEL[k], value: Math.max(0, Math.min(99, Number(f.ratings?.[k]) || 0)), quip: _arenaRoster?.stat_meta?.[k]?.desc || '' }));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))];@@@ -107,11 +105,6 @@ function _aCustomRadar(f, color, size = 240, { objective = false } = {}) {${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) {-- const cs = (f.persona && Array.isArray(f.persona.custom_stats) ? f.persona.custom_stats : []);-- if (!cs.length) return '';-- return `<div class="arena-quips arena-quips-custom">${cs.map(x => `<div class="arena-quip"><b><span class="arena-quip-val">${Number(x.value)}</span> ${_aEsc(x.name)}</b><span>${_aEsc(x.quip || '')}</span></div>`).join('')}</div>`;--}/** Sparkline of a per-stream series. */function _aSpark(series, color) {@@@ -182,7 +175,7 @@ async function _aRenderHome(root) {<div class="arena-hero arena-hero-cam"><div><h1><i class="fa-solid fa-microphone-lines"></i> Battle Cam</h1>-- <p class="arena-lede">Pure mic. Nobody clicks, nobody votes, nothing typed in chat counts. The ears listen to every live cam: call another fighter out on mic and the beef opens itself — they're on the clock, silence is a forfeit. Talk shit at chat, at the mods, at the world, and it lands in the feed and levels you up. Every rating on the ladder comes from what was said into a microphone.</p>++ <p class="arena-lede">Pure mic. No personas, no AI characters, no chat points, no votes. This page is the shit talk that was actually said on stream — judged, scored, and replayable at the second it happened. Call another streamer out on mic and the beef opens itself; they're on the clock, silence is a forfeit. Talk shit at chat, at the mods, at the world, and it lands in the feed and levels you up.</p></div><div class="arena-hero-actions"><span class="arena-note">${roster.ai ? '<i class="fa-solid fa-ear-listen"></i> AI judge on' : '<i class="fa-solid fa-ear-deaf"></i> AI off — keyword judging'}</span>@@@ -215,7 +208,7 @@ async function _aRenderHome(root) {if (_aMe()) _aRenderMe().catch(() => { const el = document.getElementById('arena-me'); if (el) el.innerHTML = ''; });document.getElementById('arena-search')?.addEventListener('input', (e) => {const q = e.target.value.trim().toLowerCase();-- _aRenderList(!q ? roster.fighters : roster.fighters.filter(f => [f.persona.fighter_name, f.user.display_name, f.user.username, f.persona.class, f.persona.element].filter(Boolean).some(s => s.toLowerCase().includes(q))));++ _aRenderList(!q ? roster.fighters : roster.fighters.filter(f => [f.user.display_name, f.user.username].filter(Boolean).some(s => s.toLowerCase().includes(q))));});_aEvery(12000, async () => {try {@@@ -257,7 +250,7 @@ function _aRenderCams(live) {${_aA(_aChannelLink(f.user, f.stream.slug || f.stream.managed_stream_id), `${f.thumbnail_url ? `<img class="arena-live-thumb" src="${_aEsc(f.thumbnail_url)}" alt="">` : _aPortrait(f, 'md')}<span class="arena-cam-rec"><span class="arena-live-dot arena-live-dot-sm"></span> LIVE · <i class="fa-solid fa-eye"></i> ${_aNum(f.stream.viewer_count)}</span>`, 'arena-cam-thumb')}<div class="arena-cam-body"><div class="arena-mic-head">-- ${_aA(_aFighterLink(f.user), `<strong>${_aEsc(f.persona.fighter_name)}</strong>`)}++ ${_aA(_aFighterLink(f.user), `<strong>${_aEsc(f.user.display_name || f.user.username)}</strong>`)}<span class="arena-mic-meta">#${f.rank} · PWR ${f.ratings.power} · ${_aLevelPill(f.level)} · ${f.record.wins}W–${f.record.losses}L</span></div>${lock ? `<div class="arena-cam-lock"><i class="fa-solid fa-crosshairs fa-beat"></i> locked on <b>${_aEsc(lock.target || 'a fighter')}</b> · ${lock.hits} hit${lock.hits === 1 ? '' : 's'}</div>` : ''}@@@ -285,7 +278,7 @@ function _aMomentRow(m) {</div>`;}function _aRenderFeed(feed, { fresh = 0 } = {}) {-- return `<h2><i class="fa-solid fa-satellite-dish"></i> The feed <small>every judged line, newest first · ${feed.length ? `${feed.length} on record` : 'nothing yet'}${fresh ? ` · <b class="arena-fresh">+${fresh} new</b>` : ''}</small></h2>++ return `<h2><i class="fa-solid fa-satellite-dish"></i> Shit talk on record <small>what was actually said on stream, newest first · ${feed.length ? `${feed.length} lines` : 'nothing yet'}${fresh ? ` · <b class="arena-fresh">+${fresh} new</b>` : ''}</small></h2>${feed.length ? `<div class="arena-mic-feed-list">${feed.map(_aMomentRow).join('')}</div>` : `<div class="arena-beef-empty"><i class="fa-solid fa-microphone-lines"></i><p>Quiet. The moment a fighter on a live cam talks shit — at anyone — it lands here with a score.</p></div>`}`;}@@@ -376,26 +369,23 @@ 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>-- <span class="arena-row-sub">${_aEsc(f.user.display_name)} · ${_aEsc(f.persona.class)} · ${_aEsc(f.persona.element)}${f.mic ? ` · <i class="fa-solid fa-microphone" title="judged lines, 30 days"></i> ${f.mic.moments} lines · avg ${f.mic.avg_quality}` : ''}</span>-- ${f.last_line ? `<em class="arena-row-taunt">“${_aEsc(f.last_line.text)}”</em>` : `<em class="arena-row-taunt">“${_aEsc(f.persona.taunt)}”</em>`}++ <strong>${_aEsc(f.user.display_name || f.user.username)} ${f.live ? '<span class="arena-live-pill">LIVE</span>' : ''} ${_aLevelPill(f.level?.level)}<i class="fa-solid fa-chevron-right arena-chevron"></i></strong>++ <span class="arena-row-sub">${f.mic ? `<i class="fa-solid fa-microphone" title="judged lines, 30 days"></i> ${f.mic.moments} lines · avg ${f.mic.avg_quality}/10 · ${f.mic.bangers} bangers · ${f.mic.beef_hits} callouts` : ''}${f.voice?.has_data ? ` · ${_aNum(f.voice.speech_minutes)} min on mic` : ''}</span>++ ${f.last_line ? `<em class="arena-row-taunt">“${_aEsc(f.last_line.text)}”${f.last_line.aimed_at ? ` <small>— at ${_aEsc(f.last_line.aimed_at)}</small>` : ''}</em>` : '<em class="arena-row-taunt arena-note">nothing judged yet</em>'}</span><span class="arena-row-stats"><span class="arena-power"><b>${f.ratings.power}</b><small>PWR</small></span><span class="arena-record" title="beef record">${f.record.wins}W–${f.record.losses}L</span></span><div class="arena-row-expand">-- <div>${_aCustomRadar(f, f.user.profile_color || '#8b5cf6', 220, { objective: true })}<div class="arena-mini-record">the seven mic stats · ${f.category ? _aEsc(f.category) + ' · ' : ''}last live ${_aEsc(f.last_live_at ? _aDate(f.last_live_at) : '—')}</div></div>++ <div>${_aCustomRadar(f, f.user.profile_color || '#8b5cf6', 220, { objective: true })}<div class="arena-mini-record">the seven mic stats · last live ${_aEsc(f.last_live_at ? _aDate(f.last_live_at) : '—')}</div></div><div>-- <p class="arena-row-lore is-clamp">${_aEsc(f.persona.lore)}</p>-- ${(f.persona.taunts || []).length ? `<div class="arena-bubbles arena-row-bubbles">${f.persona.taunts.slice(0, 2).map(t => `<div class="arena-bubble"><span class="arena-bubble-who">${_aEsc(f.user.display_name || f.user.username)}</span><span class="arena-bubble-text">${_aEsc(t)}</span>${_aSpeakBtn(t, 'arena-bubble-speak', f.user.username)}</div>`).join('')}${f.persona.typing_style ? `<small class="arena-note"><i class="fa-solid fa-microphone"></i> ${_aEsc(f.persona.typing_style)}</small>` : ''}</div>` : ''}-- <div class="arena-quips">${ARENA_STATS.map(k => `<div class="arena-quip"><b>${_aEsc(ARENA_STAT_LABEL[k])} ${f.ratings[k]}</b><span>${_aEsc((f.persona.stat_quips || {})[k] || '')}</span></div>`).join('')}</div>-- ${(f.persona.custom_stats || []).length ? _aCustomQuips(f) : ''}++ <div class="arena-quips">${ARENA_STATS.map(k => `<div class="arena-quip"><b>${_aEsc(ARENA_STAT_LABEL[k])} ${f.ratings[k]}</b><span>${_aEsc(_arenaRoster?.stat_meta?.[k]?.desc || '')}</span></div>`).join('')}</div></div><div class="arena-row-expand-actions">-- ${_aA(_aFighterLink(f.user), '<i class="fa-solid fa-id-card"></i> Full profile', 'btn btn-primary')}++ ${_aA(_aFighterLink(f.user), '<i class="fa-solid fa-satellite-dish"></i> Their lines', 'btn btn-primary')}${f.live ? _aA(_aConsoleLink(f.user), '<i class="fa-solid fa-ear-listen"></i> Listen in', 'btn btn-ghost') : ''}-- ${_aSpeakBtn(f.persona.taunt, 'btn btn-ghost', f.user.username)}++ ${f.last_line ? _aSpeakBtn(f.last_line.text, 'btn btn-ghost', f.user.username) : ''}</div></div></div>`).join('');@@@ -534,33 +524,18 @@ async function _aRenderConsole(root, username) {function _aVoiceCard(f) {const v = f.voice || {};-- const q = f.quotes;const color = f.user.profile_color || '#8b5cf6';if (!v.has_data) {return `<div class="arena-voice" style="--fc:${_aEsc(color)}"><div class="arena-voice-head"><h3><i class="fa-solid fa-microphone-slash"></i> On the mic</h3></div><p class="arena-voice-empty">No transcript data yet — the audio transcription picks this up on their next streams. Until then the Arena can't hear them.</p></div>`;}-- const quotes = (q && q.picks && q.picks.length) ? q.picks : [];return `<div class="arena-voice" style="--fc:${_aEsc(color)}">-- <div class="arena-voice-head">-- <h3><i class="fa-solid fa-microphone"></i> On the mic <span class="arena-power" style="margin-left:6px"><b style="font-size:1.1rem">${f.ratings.mouth}</b><small>MOUTH</small></span></h3>-- ${q?.mic_style ? `<span class="arena-voice-style">${_aEsc(q.mic_style)}</span>` : ''}-- </div>-- ${q?.voice_verdict ? `<p class="arena-voice-verdict">${_aEsc(q.voice_verdict)}</p>` : ''}++ <div class="arena-voice-head"><h3><i class="fa-solid fa-microphone"></i> On the mic <span class="arena-power" style="margin-left:6px"><b style="font-size:1.1rem">${f.ratings.mouth}</b><small>MOUTH</small></span></h3></div><div class="arena-voice-meters"><div class="arena-voice-meter"><b>${_aEsc(v.talk_ratio_pct)}%</b><span>of stream time talking</span></div><div class="arena-voice-meter"><b>${_aEsc(_aNum(v.speech_minutes))} min</b><span>of speech heard (90d)</span></div><div class="arena-voice-meter"><b>${_aEsc(v.wpm)}</b><span>words per minute</span></div>-- <div class="arena-voice-meter"><b>${_aEsc(v.hype_per_hour)}</b><span>hype words / hour</span></div>-- <div class="arena-voice-meter"><b>${_aEsc(v.laughs_per_hour)}</b><span>laughs / hour</span></div><div class="arena-voice-meter"><b>${_aEsc(v.streams_heard)}</b><span>streams transcribed</span></div></div>-- ${v.top_sounds && v.top_sounds.length ? `<div class="arena-sounds"><span title="what the stream sounds like, from the audio-event detector"><i class="fa-solid fa-wave-square"></i> soundscape</span>${v.top_sounds.map(s => `<span>${_aEsc(s.label)} ×${s.n}</span>`).join('')}</div>` : ''}-- ${quotes.length ? `<div class="arena-quotes">${quotes.map(p => `-- <div class="arena-quote">-- <div><q>${_aEsc(p.text)}</q><small>${_aEsc(p.why || '')}${p.vod_id ? ` · at ${_aStamp(p.start_sec)}` : ''}</small></div>-- <div class="arena-quote-actions">${_aPlay(p.vod_id, p.start_sec)} ${_aSpeakBtn(p.text, '', f.user.username)}</div>-- </div>`).join('')}</div>` : '<p class="arena-voice-empty">Quotes appear once enough lines have been transcribed.</p>'}-- ${q?._fallback ? '<p class="arena-note">Quotes picked by heuristic — the AI curates these once enabled.</p>' : ''}</div>`;}@@@ -576,27 +551,26 @@ function _aLevelCard(f) {}async function _aRenderFighter(root, username) {-- root.innerHTML = _aSpinner('Pulling the fighter file…');++ root.innerHTML = _aSpinner('Pulling their lines…');const f = await api(`/arena/fighters/${encodeURIComponent(username)}`);if (f.not_on_roster) {root.innerHTML = `<div class="arena-empty"><i class="fa-solid fa-microphone-slash"></i><p><strong>${_aEsc(f.user.display_name)}</strong> is not on the roster — ${_aEsc(f.reason)}.</p>${_aA('/arena', 'Back to the cams', 'btn')}</div>`;return;}-- const p = f.persona, color = f.user.profile_color || '#8b5cf6';-- const beefs = f.beefs || [], rivalries = f.rivalries || [], moments = f.moments || [];++ const color = f.user.profile_color || '#8b5cf6';++ const name = f.user.display_name || f.user.username;++ const beefs = f.beefs || [], rivalries = f.rivalries || [], moments = f.moments || [], best = f.best_lines || [];root.innerHTML = `<div class="arena-back">${_aA('/arena', '<i class="fa-solid fa-arrow-left"></i> Battle Cam')} ${f.live ? _aA(_aConsoleLink(f.user), '<i class="fa-solid fa-ear-listen"></i> Listen in live', '') : ''}</div><div class="arena-profile" style="--fc:${_aEsc(color)}"><div class="arena-profile-portrait" id="arena-profile-portrait">${_aPortrait(f, 'lg')}-- ${f.image_pending ? '<div class="arena-portrait-pending"><i class="fa-solid fa-wand-magic-sparkles fa-fade"></i> painting portrait…</div>' : ''}<div class="arena-profile-rank">#${f.rank} <small>of ${f.roster_size}</small></div></div><div class="arena-profile-main"><div class="arena-profile-name">-- <h1>${_aEsc(p.fighter_name)} ${f.live ? '<span class="arena-live-pill">LIVE</span>' : ''}</h1>-- <p class="arena-title">${_aEsc(p.title)}</p>-- <p class="arena-handle">${_aA(_aChannelLink(f.user), `${_aEsc(f.user.display_name)} · @${_aEsc(f.user.username)}`)} · ${_aEsc(p.class)} · ${_aEsc(p.element)}</p>++ <h1>${_aEsc(name)} ${f.live ? '<span class="arena-live-pill">LIVE</span>' : ''}</h1>++ <p class="arena-handle">${_aA(_aChannelLink(f.user), `@${_aEsc(f.user.username)}`)} · ${f.record.wins}W–${f.record.losses}L · ${f.mic ? `${f.mic.moments} judged lines in 30 days` : ''}</p></div><div class="arena-profile-power"><div class="arena-power arena-power-lg"><b>${f.ratings.power}</b><small>POWER</small></div>@@@ -604,36 +578,19 @@ async function _aRenderFighter(root, username) {<div class="arena-record arena-record-lg" title="beef record">${f.record.wins}W – ${f.record.losses}L</div></div>${_aLevelCard(f)}-- <div class="arena-profile-stats is-bars">-- <div class="arena-bars-head"><b>The mic stats</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>++ ${best.length ? `<section class="arena-receipts-section"><h3><i class="fa-solid fa-fire"></i> Best shit talk <small>their highest-scored lines</small></h3><div class="arena-mic-feed-list">${best.map(_aMomentRow).join('')}</div></section>` : ''}<section class="arena-receipts-section"><h3><i class="fa-solid fa-satellite-dish"></i> On record <small>their judged lines, newest first</small></h3>${moments.length ? `<div class="arena-mic-feed-list">${moments.map(_aMomentRow).join('')}</div>` : '<p class="arena-note">Nothing judged yet. The ears are waiting.</p>'}</section>++ <div class="arena-profile-stats is-bars">++ <div class="arena-bars-head"><b>The mic stats</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(_arenaRoster?.stat_meta?.[k]?.desc || 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-profile-custom">-- <div>${_aCustomRadar(f, color, 280)}<div class="arena-mini-record">${(p.custom_stats || []).length ? 'their characteristics — AI-read from what they say on mic' : 'the seven mic stats (AI characteristics appear once the persona is generated)'}</div></div>-- ${(p.custom_stats || []).length ? _aCustomQuips(f) : ''}++ <div>${_aCustomRadar(f, color, 280, { objective: true })}<div class="arena-mini-record">the seven mic stats</div></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">Talks 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 enough mic time.</p>' : ''}-- </section>-- <section class="arena-taunts">-- <h3><i class="fa-solid fa-comment-dots"></i> Ragebait <small>in their own 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></div>@@@ -664,48 +621,6 @@ async function _aRenderFighter(root, username) {box.querySelector('.arena-stat-detail-close').addEventListener('click', () => { box.innerHTML = ''; box.dataset.stat = ''; });} catch (err) { box.innerHTML = `<div class="arena-stat-detail">${_aEsc(err?.message || 'Failed')}</div>`; }}));---- const portrait = root.querySelector('.arena-portrait-lg');-- if (portrait && f.image_url) portrait.addEventListener('click', () => _aLightbox(f));---- if (f.image_pending || (!f.image_url && f.image_generation === 'ai')) {-- let tries = 0;-- _arenaImagePoll = setInterval(async () => {-- if (++tries > 20 || currentPage !== 'arena') return _aStopTimers();-- try {-- const fresh = await api(`/arena/fighters/${encodeURIComponent(username)}?generate=0`);-- if (fresh.image_url) {-- const holder = document.getElementById('arena-profile-portrait');-- if (holder) {-- const old = holder.querySelector('.arena-portrait'); if (old) old.outerHTML = _aPortrait(fresh, 'lg');-- holder.querySelector('.arena-portrait-pending')?.remove();-- holder.querySelector('.arena-portrait-lg')?.addEventListener('click', () => _aLightbox(fresh));-- }-- clearInterval(_arenaImagePoll); _arenaImagePoll = null;-- }-- } catch { /* */ }-- }, 6000);-- }--}----function _aLightbox(f) {-- const box = document.createElement('div');-- box.className = 'arena-lightbox';-- box.innerHTML = `-- <button class="arena-lightbox-close" aria-label="Close">×</button>-- <div class="arena-lightbox-inner">-- <img src="${_aEsc(f.image_url)}" alt="">-- <div class="arena-lightbox-text">-- <h3>${_aEsc(f.persona.fighter_name)}</h3>-- <p>${_aEsc(f.persona.title)}</p>-- <p>How this was painted: the AI wrote the persona from what they say on mic, then an image model restyled real frames from their streams — their setup, gear, lighting, silhouette — into a character-select caricature.${f.image_model ? ` Model: <code>${_aEsc(f.image_model)}</code>.` : ''}</p>-- ${f.image_prompt ? `<div class="arena-lightbox-prompt">${_aEsc(f.image_prompt)}</div>` : ''}-- </div>-- </div>`;-- const close = () => box.remove();-- box.addEventListener('click', (e) => { if (e.target === box || e.target.closest('.arena-lightbox-close')) close(); });-- document.addEventListener('keydown', function onKey(e) { if (e.key === 'Escape') { close(); document.removeEventListener('keydown', onKey); } });-- document.body.appendChild(box);}function _aLevelUp(level) {@@@ -21,13 +21,13 @@(function () {'use strict';-- const PROMO_VERSION = 'rs-exclusive-v3'; // bump to re-show the takeover to everyone++ const PROMO_VERSION = 'rs-desperate-v5'; // bump to re-show the takeover to everyoneconst SEEN_KEY = 'ov_rs_promo_seen';const TICKER_KEY = 'ov_rs_ticker_hidden'; // sessionStorage — comes back next visitconst REDUCED = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;const COARSE = window.matchMedia && window.matchMedia('(pointer: coarse)').matches;-- const cfg = { enabled: true, amount: 50, amountMin: 25, referral: 10, github: 'https://github.com/OpenVibers/OpenVibe.Live', owner: 'admin', discord: 'https://discord.gg/M6MuRUaeJj' };++ const cfg = { enabled: true, amount: 50, amountMin: 25, referral: 10, vip: null, github: 'https://github.com/OpenVibers/OpenVibe.Live', owner: 'admin', discord: 'https://discord.gg/M6MuRUaeJj' };const STREAM_ALERT_KEY = 'ov_rs_stream_alert_min'; // sessionStorage — collapsed (never hidden) statelet modalEl = null, takeoverEl = null, tickerEl = null;let rainStop = null;@@@ -61,10 +61,36 @@const chipsHtml = () => `<span class="rs-chip rs-chip-zelle"><span class="rs-zelle-mark">Z</span> Zelle</span><span class="rs-chip rs-chip-paypal"><i class="fa-brands fa-paypal"></i> PayPal</span>++ <span class="rs-chip rs-chip-crypto"><i class="fa-brands fa-bitcoin"></i> Crypto</span><span class="rs-chip rs-chip-cash"><i class="fa-solid fa-money-bill-wave"></i> Real cash, no coins</span><span class="rs-chip rs-chip-build"><i class="fa-solid fa-hammer"></i> Features built on request</span><span class="rs-chip rs-chip-oss" title="Every line of this site is public — trust what you can read"><i class="fa-brands fa-github"></i> 100% open source</span>`;++ // "One stream, everywhere": stream from here, mirror anywhere — with the toys that only exist here.++ const restreamHtml = (compact = false) => `++ <div class="rs-flow2${compact ? ' is-compact' : ''}" aria-label="Stream from OpenVibe.Live and restream everywhere">++ <div class="rs-flow2-src"><span class="rs-flow2-node"><i class="fa-solid fa-video"></i> your cam</span><span class="rs-flow2-node"><i class="fa-solid fa-robot"></i> your robot</span></div>++ <div class="rs-flow2-wire" aria-hidden="true"><span class="rs-packet"></span><span class="rs-packet"></span></div>++ <div class="rs-flow2-hub"><b><i class="fa-solid fa-circle-nodes"></i> OpenVibe.Live</b><small>WebRTC <1s latency · emotes · sound commands · robot controls · VODs · clips · AI moments</small></div>++ <div class="rs-flow2-wire rs-flow2-fan" aria-hidden="true"><span class="rs-packet"></span><span class="rs-packet"></span><span class="rs-packet"></span></div>++ <div class="rs-flow2-out">++ <span class="rs-flow2-node rs-twitch"><i class="fa-brands fa-twitch"></i> Twitch</span>++ <span class="rs-flow2-node rs-yt"><i class="fa-brands fa-youtube"></i> YouTube</span>++ <span class="rs-flow2-node rs-kick"><i class="fa-solid fa-bolt"></i> Kick</span>++ <span class="rs-flow2-node rs-rs"><i class="fa-solid fa-robot"></i> RobotStreamer</span>++ <span class="rs-flow2-node"><i class="fa-solid fa-tower-broadcast"></i> any RTMP</span>++ </div>++ ${compact ? '' : `<div class="rs-features">++ <span class="rs-feat" style="--i:0"><i class="fa-solid fa-gauge-high"></i> Sub-second latency</span>++ <span class="rs-feat" style="--i:1"><i class="fa-solid fa-face-grin-squint-tears"></i> 7TV / BTTV / FFZ + custom emotes</span>++ <span class="rs-feat" style="--i:2"><i class="fa-solid fa-volume-high"></i> Sound commands & soundboard</span>++ <span class="rs-feat" style="--i:3"><i class="fa-solid fa-gamepad"></i> Robot & hardware controls</span>++ <span class="rs-feat" style="--i:4"><i class="fa-solid fa-satellite-dish"></i> Free restream, all at once</span>++ <span class="rs-feat" style="--i:5"><i class="fa-solid fa-scissors"></i> VODs, clips, AI moments</span>++ <span class="rs-feat" style="--i:6"><i class="fa-solid fa-comments"></i> One chat from every platform</span>++ </div>`}++ </div>`;++const coinHtml = () => `<div class="rs-coin-wrap" aria-hidden="true"><div class="rs-coin">@@@ -80,8 +106,10 @@function mountTicker() {if (tickerEl || store.get(TICKER_KEY, true) === '1') return;const copy = `-- <span class="rs-ticker-item"><i class="fa-solid fa-robot"></i> RobotStreamer streamers: quit RS for good, stream ONLY on OpenVibe.Live, get <span class="rs-money">${money()}</span> <span class="rs-ticker-sep">•</span> Zelle or PayPal <span class="rs-ticker-sep">•</span> <span class="rs-ticker-cta">Claim yours</span></span>-- <span class="rs-ticker-item"><i class="fa-solid fa-heart" style="color:#f472b6"></i> Converters get catered to — tell me what you want built and I build it <span class="rs-ticker-sep">•</span> paid out of pocket by the one guy who runs this place</span>++ <span class="rs-ticker-item"><i class="fa-solid fa-robot"></i> RobotStreamer streamers: I AM BEGGING. Leave Rick's trash site for good, stream ONLY here, get <span class="rs-money">${money()}</span> <span class="rs-ticker-sep">•</span> Zelle, PayPal or crypto <span class="rs-ticker-sep">•</span> <span class="rs-ticker-cta">Claim yours</span></span>++ <span class="rs-ticker-item"><i class="fa-solid fa-heart" style="color:#f472b6"></i> I am extremely desperate and I will do ANYTHING to get you here — name the feature, I build it <span class="rs-ticker-sep">•</span> OUT OF POCKET. No investors. Just a wallet and feelings.</span>++ <span class="rs-ticker-item"><i class="fa-solid fa-face-sad-cry" style="color:#fde68a"></i> I genuinely care about you streaming on my site. That is the whole business plan. Please. <span class="rs-ticker-sep">•</span> <span class="rs-ticker-cta">Take my money</span></span>++ <span class="rs-ticker-item"><i class="fa-solid fa-satellite-dish" style="color:#7dd3fc"></i> Stream here, restream to Twitch, YouTube, Kick, RobotStreamer & any RTMP at once <span class="rs-ticker-sep">•</span> emotes, sound commands, robot controls, <1s latency</span><span class="rs-ticker-item"><i class="fa-brands fa-github"></i> Open source beats closed source: every line of this site is on GitHub, theirs isn't <span class="rs-ticker-sep">•</span> trust what you can read</span><span class="rs-ticker-item"><i class="fa-solid fa-people-arrows" style="color:#86efac"></i> Already here? Bring a RobotStreamer streamer over and you get <span class="rs-money">${referral()}</span> too <span class="rs-ticker-sep">•</span> <span class="rs-ticker-cta">How it works</span></span>`;tickerEl = document.createElement('div');@@@ -115,7 +143,7 @@2. TAKEOVER (money rain canvas + count-up)───────────────────────────────────────────────────────────── */function buildTakeover() {-- const titleWords = ['Leave', 'RobotStreamer', 'for', 'good.', 'Get', 'paid.'];++ const titleWords = ['I', 'am', 'BEGGING', 'you.', 'Leave', "Rick's", 'trash', 'site.'];const el = document.createElement('div');el.className = 'rs-takeover';el.setAttribute('role', 'dialog');@@@ -130,7 +158,8 @@<div class="rs-tk-eyebrow"><i class="fa-solid fa-robot"></i> robotstreamer.com users</div><h1 class="rs-tk-title">${titleWords.map((w, i) => `<span class="rs-w" style="animation-delay:${0.25 + i * 0.09}s">${esc(w)}</span>`).join(' ')}</h1><div class="rs-tk-amount" aria-label="${money()}"><span class="rs-money" data-countup="${cfg.amount}">${cfg.amountMin < cfg.amount ? `$${cfg.amountMin}–$0` : '$0'}</span></div>-- <p class="rs-tk-sub">Never stream on RobotStreamer again. Stream <b>only</b> on <b>OpenVibe.Live</b>. I send you <b>${money()}</b> — Zelle or PayPal, your pick.<br>And I cater to converters: <b>ask for a feature, I build it.</b> Not a corporation — one guy who wants you here.</p>++ <p class="rs-tk-sub">I am <b>extremely desperate</b>. I will do <b>anything</b> to get you off RobotStreamer and onto <b>OpenVibe.Live</b>. Never stream there again, stream <b>only</b> here, and I personally send you <b>${money()}</b> — Zelle, PayPal or crypto, your pick. This is <b>out of my own pocket</b>. There are no investors. There is a wallet, and there are feelings.<br>Want a feature? <b>Name it. I build it.</b> Want me to grovel? This is the groveling. I genuinely care about you streaming here — that is the entire business plan. Not a corporation — one unhinged guy who wants you here more than Rick ever will.</p>++ <div class="rs-tk-tour"><div class="rs-tk-tour-head"><i class="fa-solid fa-satellite-dish"></i> Stream here. Be everywhere. Keep the toys.</div>${restreamHtml(false)}</div><p class="rs-tk-trust"><i class="fa-brands fa-github"></i> <b>Trust what you can read.</b> Every line of this site is ${ghLink('public on GitHub')}. Theirs is closed source — you'll never see what it does. An open alternative is the only kind worth trusting.<br><i class="fa-solid fa-people-arrows"></i> <b>Already one of us?</b> Bring a RobotStreamer streamer over and you get <b>${referral()}</b> too.</p><div class="rs-chips">${chipsHtml()}</div><div class="rs-flow" aria-hidden="true">@@@ -139,8 +168,8 @@<span class="rs-flow-node rs-to"><i class="fa-solid fa-circle-nodes"></i> OpenVibe.Live</span></div><div class="rs-tk-actions">-- <button class="rs-btn rs-btn-gold" type="button" data-act="claim"><i class="fa-solid fa-sack-dollar"></i> Go exclusive — claim ${money()}</button>-- <button class="rs-btn rs-btn-ghost" type="button" data-act="later"><i class="fa-solid fa-compass"></i> Show me around first</button>++ <button class="rs-btn rs-btn-gold" type="button" data-act="claim"><i class="fa-solid fa-sack-dollar"></i> Fine, I'll take the ${money()}</button>++ <button class="rs-btn rs-btn-ghost" type="button" data-act="later"><i class="fa-solid fa-compass"></i> Let me look around first</button></div><p class="rs-tk-foot"><i class="fa-solid fa-star"></i> You'll only see this splash once — the bar at the top brings you back here anytime.</p></div>`;@@@ -265,9 +294,10 @@${coinHtml()}<div class="rs-card-body"><div class="rs-card-kicker"><i class="fa-solid fa-robot"></i> RobotStreamer switch bonus</div>-- <h3 class="rs-card-title">Coming from RobotStreamer? Go exclusive here and get <span class="rs-money">${money()}</span></h3>-- <p class="rs-card-sub">Never stream on RobotStreamer again, stream only on OpenVibe.Live. Zelle or PayPal, paid personally by the guy who runs this place — and every converter gets catered to: ask for a feature, I build it. Every line of this site is ${ghLink('open source on GitHub')}; theirs is closed. Already here? Bring a RobotStreamer streamer over and you get <b>${referral()}</b>.</p>++ <h3 class="rs-card-title">RobotStreamer people: I am begging. Leave Rick's site, stream only here, get <span class="rs-money">${money()}</span></h3>++ <p class="rs-card-sub">Never stream on RobotStreamer again, stream only on OpenVibe.Live. Zelle, PayPal or crypto, out of my own pocket, because I genuinely care about you streaming here (that is the whole business plan). Every converter gets catered to: ask for a feature, I build it. Every line of this site is ${ghLink('open source on GitHub')}; theirs is closed. Already here? Bring a RobotStreamer streamer over and you get <b>${referral()}</b>.</p><div class="rs-card-chips">${chipsHtml()}</div>++ ${restreamHtml(true)}</div><div class="rs-card-action"><button class="rs-btn rs-btn-gold" type="button" onclick="rsPromoOpenClaim()"><i class="fa-solid fa-sack-dollar"></i> Claim ${money()}</button>@@@ -277,6 +307,37 @@</div>`;if (!REDUCED && !COARSE) attachTilt(mount.querySelector('.rs-card-border'), mount.querySelector('#rs-hero-card'));}++ // ── VIP recruiter card (home page, under the bounty card): the starfish bounty board ──++ function mountVipCard() {++ const mount = document.getElementById('rs-hero-mount');++ if (!mount || !cfg.vip || mount.querySelector('.rs-vip')) return;++ const v = cfg.vip;++ const me = window.currentUser && String(window.currentUser.username || '').toLowerCase() === String(v.username).toLowerCase();++ const name = esc(v.username);++ const el = document.createElement('div');++ el.className = 'rs-vip';++ el.innerHTML = `++ <div class="rs-vip-bubbles" aria-hidden="true"><span></span><span></span><span></span><span></span><span></span><span></span></div>++ <div class="rs-vip-star" aria-hidden="true">++ <svg viewBox="0 0 120 120" width="120" height="120">++ <defs><radialGradient id="rsVipG" cx="40%" cy="35%" r="70%"><stop offset="0%" stop-color="#ffb3d1"/><stop offset="100%" stop-color="#ff5fa2"/></radialGradient></defs>++ <path class="rs-vip-body" d="M60 6 L74 42 L112 44 L82 68 L93 106 L60 84 L27 106 L38 68 L8 44 L46 42 Z" fill="url(#rsVipG)" stroke="#c2185b" stroke-width="3" stroke-linejoin="round"/>++ <circle cx="48" cy="52" r="7" fill="#fff"/><circle cx="72" cy="52" r="7" fill="#fff"/>++ <circle class="rs-vip-pupil" cx="50" cy="53" r="3" fill="#222"/><circle class="rs-vip-pupil" cx="74" cy="53" r="3" fill="#222"/>++ <path d="M46 68 Q60 82 74 68" stroke="#7a1140" stroke-width="3.5" fill="none" stroke-linecap="round"/>++ <circle cx="36" cy="64" r="3" fill="#ff8ac0" opacity="0.8"/><circle cx="84" cy="64" r="3" fill="#ff8ac0" opacity="0.8"/>++ </svg>++ <span class="rs-vip-sparkle">✦</span><span class="rs-vip-sparkle">✦</span><span class="rs-vip-sparkle">✦</span>++ </div>++ <div class="rs-vip-body-text">++ <div class="rs-vip-kicker"><i class="fa-solid fa-star"></i> ${name}'s bounty board</div>++ <h3 class="rs-vip-title">${me ? `That's you, ${name}. Go get 'em.` : `${name} gets <span class="rs-money">$${Number(v.referral)}</span> per RobotStreamer streamer he brings over`}</h3>++ <p class="rs-vip-sub">${me ? `<span class="rs-money">$${Number(v.referral)}</span> every time a RobotStreamer streamer you brought in claims the switch bonus and names you. Everyone else gets $${cfg.referral}. You're the recruiter. The rock is yours.` : `He's going to be godlike at this, so his rate is double everyone else's ($${cfg.referral} for the rest of us). Converts name him in the claim message, he gets paid. Simple.`}</p>++ <div class="rs-vip-chips"><span class="rs-chip rs-chip-cash"><i class="fa-solid fa-money-bill-wave"></i> $${Number(v.referral)} per conversion</span><span class="rs-chip rs-chip-build"><i class="fa-solid fa-people-arrows"></i> everyone else: $${cfg.referral}</span>${me ? '<span class="rs-chip rs-chip-oss"><i class="fa-solid fa-crown"></i> VIP recruiter</span>' : ''}</div>++ </div>`;++ mount.appendChild(el);++ }++function attachTilt(zone, card) {if (!zone || !card) return;let raf = 0;@@@ -330,9 +391,10 @@<div class="rs-sa-body"><div class="rs-sa-siren" aria-hidden="true"><i class="fa-solid fa-robot"></i></div><div class="rs-sa-text">-- <div class="rs-sa-head">🚨 RobotStreamer streamers: get <span class="rs-money">${money()}</span> to switch — for good</div>-- <div class="rs-sa-sub">Never stream on RobotStreamer again, stream <b>only</b> on OpenVibe.Live, and I pay you <b>${money()}</b> by Zelle or PayPal. Converters get catered to: <b>ask for a feature and I build it.</b> Every line of this site is ${ghLink('open source')} — theirs is closed. Bring a RobotStreamer streamer over and <b>you</b> get ${referral()} too.</div>++ <div class="rs-sa-head">🚨 RobotStreamer streamers: I am DESPERATE. <span class="rs-money">${money()}</span> to leave Rick's trash site for good</div>++ <div class="rs-sa-sub">Never stream on RobotStreamer again, stream <b>only</b> on OpenVibe.Live, and I pay you <b>${money()}</b> by Zelle, PayPal or crypto — out of my own pocket, because I actually care about you streaming here. Converters get catered to: <b>ask for a feature and I build it.</b> Every line of this site is ${ghLink('open source')} — theirs is closed. Bring a RobotStreamer streamer over and <b>you</b> get ${referral()} too.</div><div class="rs-sa-chips">${chipsHtml()}</div>++ ${restreamHtml(true)}</div><div class="rs-sa-actions"><button class="rs-btn rs-btn-gold" type="button" onclick="rsPromoOpenClaim()"><i class="fa-solid fa-sack-dollar"></i> Claim ${money()}</button>@@@ -340,7 +402,7 @@</div></div><div class="rs-sa-mini" onclick="rsPromoToggleStreamAlert()">-- <i class="fa-solid fa-robot"></i> <span>RobotStreamer streamers: <span class="rs-money">${money()}</span> to switch for good — Zelle / PayPal + features built for you</span>++ <i class="fa-solid fa-robot"></i> <span>RobotStreamer streamers: I'm begging — <span class="rs-money">${money()}</span> (Zelle / PayPal / crypto) to leave Rick's site for good · restream everywhere from here</span><span class="rs-ticker-cta" onclick="event.stopPropagation(); rsPromoOpenClaim()">Claim</span></div><button class="rs-sa-toggle" type="button" onclick="rsPromoToggleStreamAlert()" title="Collapse / expand" aria-label="Collapse or expand the offer"><i class="fa-solid fa-chevron-up"></i></button>@@@ -357,9 +419,11 @@/* ─────────────────────────────────────────────────────────────4. CLAIM MODAL───────────────────────────────────────────────────────────── */++ const METHOD_NAME = { zelle: 'Zelle', paypal: 'PayPal', crypto: 'Crypto' };function claimMessage(method) {-- const via = method === 'paypal' ? 'PayPal' : 'Zelle';-- return `Hey! I'm coming over from RobotStreamer (RS username: ______). I'm done streaming on RS for good — OpenVibe.Live only from now on. I'd like to claim the ${money()} switch bonus via ${via}. My ${via} is: ______ · Referred by (OpenVibe user, if any): ______ 🤖💸`;++ const via = METHOD_NAME[method] || 'Zelle';++ const detail = method === 'crypto' ? 'My wallet (coin + network + address): ______' : `My ${via} is: ______`;++ return `Hey! I'm coming over from RobotStreamer (RS username: ______). I'm done streaming on RS for good — OpenVibe.Live only from now on. I'd like to claim the ${money()} switch bonus via ${via}. ${detail} · Referred by (OpenVibe user, if any): ______ 🤖💸`;}function openClaim() {if (modalEl) return;@@@ -378,24 +442,25 @@${coinHtml()}<div><h3>Claim your <span class="rs-money">${money()}</span></h3>-- <p>The deal: you never stream on RobotStreamer again and stream only here. I pay you personally — and I build what you ask for.</p>++ <p>I am desperate and not hiding it. The deal: you never stream on Rick's site again and stream only here. I pay you personally — Zelle, PayPal or crypto, out of my own pocket — I build whatever you ask for, and you keep restreaming everywhere you like, from here. I genuinely care about you streaming on my site. That is the plan. That is the whole plan.</p></div></div>-- <div class="rs-toggle" data-method="zelle" role="group" aria-label="How do you want to get paid?">++ <div class="rs-toggle rs-toggle-3" data-method="zelle" role="group" aria-label="How do you want to get paid?"><div class="rs-toggle-thumb" aria-hidden="true"></div><button type="button" data-method="zelle" aria-pressed="true"><span class="rs-zelle-mark">Z</span> Zelle</button><button type="button" data-method="paypal" aria-pressed="false"><i class="fa-brands fa-paypal"></i> PayPal</button>++ <button type="button" data-method="crypto" aria-pressed="false"><i class="fa-brands fa-bitcoin"></i> Crypto</button></div><ol class="rs-steps"><li class="rs-step ${loggedIn ? 'done' : ''}"><span class="rs-step-num"></span><div><strong>Sign in — it's free</strong><span>One OpenVibe account works across the whole network. ${loggedIn ? 'You\'re already in. ✔' : 'Takes about ten seconds.'}</span></div></li>-- <li class="rs-step"><span class="rs-step-num"></span><div><strong>Go exclusive — retire your RobotStreamer stream</strong><span>Set up here instead: browser (WebRTC), OBS/RTMP, or your robot's existing pipeline — <a href="/broadcast" onclick="return handleLinkClick(event, '/broadcast')">Go Live</a> walks you through it. From now on you stream on OpenVibe.Live only.</span></div></li>++ <li class="rs-step"><span class="rs-step-num"></span><div><strong>Go exclusive — your home is here now</strong><span>Set up on <a href="/broadcast" onclick="return handleLinkClick(event, '/broadcast')">Go Live</a>: browser (WebRTC), OBS/RTMP, or your robot's existing pipeline. Then flip on <b>restream</b> and mirror your OpenVibe stream to Twitch, YouTube, Kick, any RTMP — even RobotStreamer — while your chat, emotes, sound commands and robot controls all live here.</span>${restreamHtml(true)}</div></li><li class="rs-step"><span class="rs-step-num"></span><div><strong>Message me</strong><span>Send your RobotStreamer username plus your <b class="rs-method-word">Zelle</b> info — in-site DM to <b>@${owner}</b> or on Discord. Copy the message below to make it painless.</span></div></li><li class="rs-step"><span class="rs-step-num"></span><div><strong>Get paid — and get catered to</strong><span>I send the <b>${money()}</b> by hand. Then tell me what your stream needs: converters get features built on request.</span></div></li></ol><div class="rs-msg"><span class="rs-msg-text">${esc(claimMessage('zelle'))}</span><button class="rs-msg-copy" type="button" title="Copy message" aria-label="Copy message"><i class="fa-regular fa-copy"></i></button></div><div class="rs-trust"><div class="rs-trust-row"><i class="fa-brands fa-github"></i><div><b>Why trust this over RobotStreamer?</b> Because you can read it. Every line of OpenVibe.Live is ${ghLink('public on GitHub')} — what it does with your stream, your chat and your data is in the open. RobotStreamer is closed source; you are trusting a black box. An open alternative is the only kind that earns trust.</div></div>-- <div class="rs-trust-row"><i class="fa-solid fa-people-arrows"></i><div><b>Already on OpenVibe?</b> Bring a RobotStreamer streamer over — when they claim, name you in the message and <b>you get ${referral()}</b> as well. Yes, I'm paying our own users to phase that site out.</div></div>++ <div class="rs-trust-row"><i class="fa-solid fa-people-arrows"></i><div><b>Already on OpenVibe?</b> Bring a RobotStreamer streamer over — when they claim, name you in the message and <b>you get ${referral()}</b> as well${cfg.vip ? ` (${esc(cfg.vip.display_name || cfg.vip.username)} gets $${Number(cfg.vip.referral)} — he's the VIP recruiter)` : ''}. Yes, I'm paying our own users to phase that site out.</div></div></div><div class="rs-modal-actions">${loggedIn@@@ -406,7 +471,7 @@<a class="rs-btn rs-btn-ghost" href="/broadcast" onclick="return handleLinkClick(event, '/broadcast')" data-act="golive"><i class="fa-solid fa-tower-broadcast"></i> Set up my stream</a></div></div>-- <p class="rs-modal-fine">One bonus per RobotStreamer account · Zelle or PayPal only · the deal is exclusivity: no more streaming on RobotStreamer, ever. Built and paid for by one person trying to make this work — thanks for giving it a shot <i class="fa-solid fa-heart"></i></p>++ <p class="rs-modal-fine">One bonus per RobotStreamer account · Zelle, PayPal or crypto · the deal is exclusivity: no more streaming on RobotStreamer, ever. Built and paid for out of pocket by one person who cares way too much — thanks for giving it a shot <i class="fa-solid fa-heart"></i></p></div>`;modalEl.addEventListener('click', onModalClick);document.addEventListener('keydown', onModalKey);@@@ -434,7 +499,7 @@const tg = modalEl?.querySelector('.rs-toggle'); if (!tg) return;tg.dataset.method = method;tg.querySelectorAll('button').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.method === method)));-- modalEl.querySelector('.rs-method-word').textContent = method === 'paypal' ? 'PayPal' : 'Zelle';++ modalEl.querySelector('.rs-method-word').textContent = METHOD_NAME[method] || 'Zelle';modalEl.querySelector('.rs-msg-text').textContent = claimMessage(method);const copyBtn = modalEl.querySelector('.rs-msg-copy'); copyBtn.classList.remove('copied'); copyBtn.innerHTML = '<i class="fa-regular fa-copy"></i>';}@@@ -508,6 +573,7 @@if (Number.isFinite(j.amount) && j.amount > 0) cfg.amount = j.amount;if (Number.isFinite(j.amount_min) && j.amount_min > 0) cfg.amountMin = j.amount_min;if (Number.isFinite(j.referral) && j.referral > 0) cfg.referral = j.referral;++ if (j.vip && j.vip.username) cfg.vip = j.vip;if (j.github !== undefined) cfg.github = j.github || '';if (j.owner) cfg.owner = String(j.owner);if (j.discord !== undefined) cfg.discord = j.discord || '';@@@ -519,6 +585,7 @@if (!cfg.enabled) return;mountTicker();mountHeroCard();++ mountVipCard();mountStreamAlert();watchChannelPage();// Let the page settle (fonts, hero paint) before the splash slams in.@@@ -12,37 +12,19 @@const arena = require('./arena-service');const INTERVAL_MS = 20 * 60 * 1000;--const BATCH = 6;--const IMAGE_BATCH = 2;const CLOCK_MS = 60 * 1000;let _timer = null;let _clock = null;let _busy = false;++// Personas and portraits are no longer generated: the Arena shows only what was said on mic.++// The periodic tick now judges past speech (backfill) so the feed reflects the record, and++// catches up anything the live listener missed.async function tick() {if (_busy || !arena.arenaEnabled() || !arena.aiOn()) return;_busy = true;-- try {-- const db = require('../db/database');-- const roster = arena.loadRoster();-- let personas = 0, images = 0;-- for (const userId of roster.order) {-- if (personas >= BATCH && (images >= IMAGE_BATCH || !arena.imageGenAvailable())) break;-- const row = db.get('SELECT persona_generated_at, image_generated_at, image_path FROM arena_profiles WHERE user_id = ?', [userId]) || {};-- const personaStale = !row.persona_generated_at || Date.now() - Date.parse(row.persona_generated_at + 'Z') > 24 * 60 * 60 * 1000;-- if (personaStale && personas < BATCH) {-- try { await arena.generatePersona(userId); personas++; } catch (e) { console.warn('[Arena] job persona:', e.message); }-- }-- const imageStale = !row.image_path || !row.image_generated_at || Date.now() - Date.parse(row.image_generated_at + 'Z') > 7 * 24 * 60 * 60 * 1000;-- if (imageStale && images < IMAGE_BATCH && arena.imageGenAvailable()) {-- try { await arena.generateImage(userId); images++; } catch (e) { console.warn('[Arena] job image:', e.message); }-- }-- if (!arena.aiOn()) break; // budget ran out mid-tick-- }-- if (personas || images) console.log(`[Arena] job: ${personas} persona(s), ${images} portrait(s) refreshed`);-- } finally {-- _busy = false;-- }++ try { await require('./backfill').run(); } catch (e) { console.warn('[Arena] backfill:', e.message); }++ finally { _busy = false; }}function start() {@@@ -57,7 +39,7 @@ function start() {_clock = setInterval(() => housekeeping().catch(e => console.warn('[Arena] housekeeping:', e.message)), CLOCK_MS);if (_clock.unref) _clock.unref();setTimeout(() => housekeeping().catch(() => {}), 20_000).unref?.();-- console.log('[Arena] job started (personas every 20 min; listener every 15 s; beef clocks every 60 s) — Battle Cam mode: pure mic');++ console.log('[Arena] job started (backfill of past speech every 20 min; listener every 15 s; beef clocks every 60 s) — Battle Cam mode: pure mic');}async function housekeeping() {@@@ -543,11 +543,10 @@ async function getFighter(usernameOrId, { generate = true } = {}) {if (!user) return null;const roster = loadRoster();if (!roster.byId[user.id]) return { user: publicUser(user), not_on_roster: true, reason: `nothing heard on mic in the last ${ACTIVE_DAYS} days — the Arena only knows what the transcription hears` };-- if (generate) {-- const row = profileRow(user.id);-- if (aiOn() && !personaIsFresh(row)) { try { await generatePersona(user.id); } catch (e) { console.warn('[Arena] persona:', e.message); } }-- if (!quotesAreFresh(profileRow(user.id))) { try { await generateQuotes(user.id); } catch (e) { console.warn('[Arena] quotes:', e.message); } }-- if (aiOn() && !imageIsFresh(profileRow(user.id)) && imageGenAvailable()) generateImage(user.id).catch(() => {});++ // Battle Cam: no persona / portrait / quote generation on view — the page shows only what++ // was said on mic (mic.js). `generate` is kept for the admin refresh route.++ if (generate === 'force') {++ try { await generatePersona(user.id); } catch (e) { console.warn('[Arena] persona:', e.message); }}const card = cardFor(user.id, roster, { includeQuotes: true });card.image_pending = !card.image_url && _imageInFlight.has(user.id);nnew file mode 100644@@@ -0,0 +1,132 @@++/**++ * OpenVibe.Live — Arena backfill: judge what was ALREADY said on mic.++ *++ * The listener only hears streams that are live right now. Everything a fighter said before++ * (the transcripts already in stream_timeline_events) is judged here, so the Arena reflects++ * the shit talk on record the moment it ships, not weeks later:++ *++ * 1. every roster fighter's speech from the last DAYS days is read in stream order and cut++ * into ~60 s chunks (JUDGE_MIN_WORDS … CHUNK_MAX_WORDS, a gap > CHUNK_GAP_SEC starts a++ * new chunk);++ * 2. a chunk is only sent to a model if it looks like it could be shit talk (the SPICY++ * keyword pre-filter) or names another fighter — gameplay narration never costs a call;++ * 3. a name-drop chunk goes to the beef judge (no clocks / no beefs are opened for old++ * speech — it lands as a `callout` moment with the target attached); everything else++ * goes to the mic judge → `trash` moment. Both pay Trash Level XP.++ *++ * A per-fighter cursor (site setting `arena_backfill_cursor_<userId>` = last timeline row id++ * judged) makes every run incremental, so this also catches up anything the live listener++ * missed. Bounded per run (MAX_CALLS_PER_RUN model calls) so a big backlog drains over a few++ * runs instead of burning the budget at once.++ */++'use strict';++++const db = require('../db/database');++++const DAYS = 7;++const CHUNK_GAP_SEC = 25;++const CHUNK_MAX_WORDS = 110;++const MAX_CALLS_PER_RUN = 160;++const MAX_CHUNKS_PER_FIGHTER = 80;++++function arena() { return require('./arena-service'); }++function listener() { return require('./listener'); }++function mic() { return require('./mic'); }++function words(t) { return String(t || '').split(/\s+/).filter(Boolean).length; }++function cursorKey(uid) { return `arena_backfill_cursor_${uid}`; }++function getCursor(uid) { const v = parseInt(db.getSetting(cursorKey(uid)), 10); return Number.isFinite(v) ? v : 0; }++function setCursor(uid, id) { try { db.setSetting(cursorKey(uid), String(id)); } catch { /* */ } }++function sqlToIso(ts) { return ts ? String(ts).replace(' ', 'T') + (String(ts).endsWith('Z') ? '' : 'Z') : null; }++++/** Cut a fighter's speech rows into judge-sized chunks. */++function chunk(rows) {++ const out = [];++ let cur = null;++ for (const r of rows) {++ const w = words(r.text);++ if (!w) continue;++ const gap = cur && (r.stream_id !== cur.stream_id || r.start_sec - cur.end > CHUNK_GAP_SEC);++ if (!cur || gap || cur.words + w > CHUNK_MAX_WORDS) {++ if (cur && cur.words >= listener().JUDGE_MIN_WORDS) out.push(cur);++ cur = { stream_id: r.stream_id, vod_id: r.vod_id, started_at: r.started_at, start: r.start_sec, end: r.end_sec || r.start_sec + 3, lines: [], words: 0, lastId: r.id };++ }++ cur.lines.push({ t: String(r.text), s: Math.floor(r.start_sec), v: r.vod_id || null, id: r.id });++ cur.words += w; cur.end = r.end_sec || r.start_sec + 3; cur.lastId = r.id;++ }++ if (cur && cur.words >= listener().JUDGE_MIN_WORDS) out.push(cur);++ return out;++}++++function saidAtFor(c, sec) {++ const base = c.started_at ? Date.parse(sqlToIso(c.started_at)) : NaN;++ if (!Number.isFinite(base)) return null;++ return new Date(base + Math.max(0, sec || 0) * 1000).toISOString().replace('T', ' ').slice(0, 19);++}++++async function backfillFighter(uid, roster, budget) {++ const L = listener();++ const since = `-${DAYS} days`;++ const rows = db.all(`SELECT e.id, e.stream_id, e.vod_id, e.start_sec, e.end_sec, e.text, s.started_at++ FROM stream_timeline_events e LEFT JOIN streams s ON s.id = e.stream_id++ WHERE e.user_id = ? AND e.kind = 'speech' AND e.id > ? AND e.created_at >= datetime('now', ?)++ AND NOT EXISTS (SELECT 1 FROM streams l WHERE l.id = e.stream_id AND l.is_live = 1)++ ORDER BY e.stream_id, e.start_sec LIMIT 4000`, [uid, getCursor(uid), since]);++ if (!rows.length) return { judged: 0, moments: 0, skipped: 0 };++ const chunks = chunk(rows).slice(0, MAX_CHUNKS_PER_FIGHTER);++ let judged = 0, moments = 0, skipped = 0, lastId = getCursor(uid);++ for (const c of chunks) {++ if (budget.calls >= MAX_CALLS_PER_RUN) break;++ const text = c.lines.map(l => l.t).join(' ').replace(/\s+/g, ' ').slice(-1400);++ const mentions = L._mentionsDetailed(text, uid, roster);++ const spicy = L.SPICY.test(text.toLowerCase());++ lastId = Math.max(lastId, c.lastId);++ if (!mentions.length && !spicy) { skipped++; continue; }++ budget.calls++; judged++;++ const ref = (bestLine) => { const needle = String(bestLine || '').toLowerCase().slice(0, 30); const hit = (needle && c.lines.find(l => l.t.toLowerCase().includes(needle))) || c.lines[0]; return { vod_id: hit ? hit.v : null, sec: hit ? Math.max(0, hit.s - 2) : null }; };++ try {++ if (mentions.length) {++ const m = mentions[0];++ const j = await L._judgeBeef(uid, m.userId, text, roster, { context: null, named: true, how: m.how });++ if (j.aimed_at_target && j.quality >= (m.how === 'exact' ? 3 : 5)) {++ const r = ref(j.best_line);++ if (mic().addMoment({ userId: uid, streamId: c.stream_id, vodId: r.vod_id, sec: r.sec, kind: 'callout', targetUserId: m.userId, aimedAt: mic().nameOf(m.userId), text: j.best_line || text.slice(0, 220), about: j.about, quality: j.quality, announcer: j.announcer, saidAt: saidAtFor(c, r.sec) })) moments++;++ continue;++ }++ if (j.about_target) continue; // neutral mention — not shit talk, and not free talk either++ }++ const j = await L._judgeMic(uid, text);++ if (j.is_trash_talk && j.quality >= L.MIC_MIN_QUALITY) {++ const r = ref(j.best_line);++ const target = j.aimed_at ? L._mentionsDetailed(j.aimed_at, uid, roster)[0] : null;++ if (mic().addMoment({ userId: uid, streamId: c.stream_id, vodId: r.vod_id, sec: r.sec, kind: target ? 'callout' : 'trash', targetUserId: target ? target.userId : null, aimedAt: target ? mic().nameOf(target.userId) : (j.aimed_at || null), text: j.best_line || text.slice(0, 220), about: j.about, quality: j.quality, announcer: j.announcer, saidAt: saidAtFor(c, r.sec) })) moments++;++ }++ } catch (e) { console.warn(`[Arena] backfill judge (user ${uid}):`, e.message); }++ }++ setCursor(uid, lastId);++ return { judged, moments, skipped, chunks: chunks.length };++}++++let _busy = false;++/** Judge un-judged past speech for every roster fighter. Bounded; safe to call often. */++async function run({ force = false } = {}) {++ if (_busy) return { busy: true };++ if (!arena().arenaEnabled()) return { disabled: true };++ _busy = true;++ const started = Date.now();++ const budget = { calls: 0 };++ const out = { fighters: 0, judged: 0, moments: 0, skipped: 0, calls: 0 };++ try {++ const roster = arena().loadRoster(force);++ for (const uid of roster.order) {++ if (budget.calls >= MAX_CALLS_PER_RUN) break;++ const r = await backfillFighter(uid, roster, budget);++ out.fighters++; out.judged += r.judged; out.moments += r.moments; out.skipped += r.skipped;++ }++ out.calls = budget.calls;++ if (out.judged || out.moments) console.log(`[Arena] backfill: ${out.moments} moment(s) from ${out.judged} judged chunk(s) across ${out.fighters} fighter(s) (${out.skipped} skipped as not spicy, ${Math.round((Date.now() - started) / 1000)} s)`);++ if (out.moments) { try { arena().loadRoster(true); } catch { /* */ } }++ return out;++ } finally { _busy = false; }++}++++module.exports = { run, chunk, DAYS, MAX_CALLS_PER_RUN, _backfillFighter: backfillFighter };@@@ -82,11 +82,7 @@ function arena() { return require('./arena-service'); }function mic() { return require('./mic'); }function isLive(userId) { return !!db.get('SELECT 1 FROM streams WHERE user_id = ? AND is_live = 1 LIMIT 1', [userId]); }function nowIso() { return new Date().toISOString(); }--function nameOf(userId) {-- const p = parseJson(db.get('SELECT persona_json FROM arena_profiles WHERE user_id = ?', [userId])?.persona_json);-- const u = db.getUserById(userId);-- return p?.fighter_name || u?.display_name || u?.username || `user${userId}`;--}++function nameOf(userId) { return mic().nameOf(userId); }const HEADLINE_SCHEMA = { name: 'arena_headline', schema: { type: 'object', additionalProperties: false, required: ['headline'], properties: { headline: { type: 'string', description: '≤ 100 chars, tabloid, inflammatory but funny, names both fighters' } } } };async function headlineFor(kind, ctx) {try {@@@ -310,4 +310,4 @@ function start() {}function stop() { if (_timer) { clearInterval(_timer); _timer = null; } }--module.exports = { start, stop, tick, consoleState, TICK_MS, JUDGE_MIN_WORDS, JUDGE_MIN_INTERVAL_MS, MIC_MIN_QUALITY, CALLOUT_MIN_QUALITY, FOCUS_TAIL_MS, FOCUS_EXTEND_MS, FOCUS_MAX_MS, _mentionsIn: mentionsIn, _mentionsDetailed: mentionsDetailed, _aliases: aliases, _heuristicBeef: heuristicBeef, _heuristicMic: heuristicMic, _state: state };++module.exports = { start, stop, tick, consoleState, TICK_MS, JUDGE_MIN_WORDS, JUDGE_MIN_INTERVAL_MS, MIC_MIN_QUALITY, CALLOUT_MIN_QUALITY, FOCUS_TAIL_MS, FOCUS_EXTEND_MS, FOCUS_MAX_MS, SPICY, _mentionsIn: mentionsIn, _mentionsDetailed: mentionsDetailed, _aliases: aliases, _heuristicBeef: heuristicBeef, _heuristicMic: heuristicMic, _judgeBeef: judgeBeef, _judgeMic: judgeMic, _state: state };@@@ -133,18 +133,17 @@ function levelsLeaderboard(limit = 10) {// ── Names + briefs ───────────────────────────────────────────++// Real names only — no AI ring names. The fighter IS the streamer.function nameOf(userId) {-- const p = parseJson(db.get('SELECT persona_json FROM arena_profiles WHERE user_id = ?', [userId])?.persona_json);const u = db.getUserById(userId);-- return p?.fighter_name || u?.display_name || u?.username || `user${userId}`;++ return u?.display_name || u?.username || `user${userId}`;}function fighterBrief(userId, roster) {const f = roster && roster.byId ? roster.byId[userId] : null;-- const persona = parseJson(db.get('SELECT persona_json FROM arena_profiles WHERE user_id = ?', [userId])?.persona_json);return {user: f ? f.user : (db.getUserById(userId) ? arena().publicUser(db.getUserById(userId)) : { id: userId, username: `user${userId}`, display_name: `user${userId}` }),-- fighter_name: persona?.fighter_name || f?.user?.display_name || `user${userId}`,++ fighter_name: nameOf(userId),rank: f ? roster.order.indexOf(userId) + 1 : null,image_url: (() => { try { return arena().getFighterImageUrl(userId); } catch { return null; } })(),live: !!db.get('SELECT 1 FROM streams WHERE user_id = ? AND is_live = 1 LIMIT 1', [userId]),@@@ -169,7 +168,7 @@ function addMoment({ userId, streamId = null, vodId = null, sec = null, kind = 'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE(?, CURRENT_TIMESTAMP))`,[userId, streamId, vodId, sec == null ? null : Math.max(0, Math.floor(sec)), kind, targetUserId, beefId, aimedAt ? String(aimedAt).slice(0, 80) : null, t, about ? String(about).slice(0, 80) : null, q, announcer ? String(announcer).slice(0, 140) : null, saidAt]);const id = Number(r.lastInsertRowid);-- if (kind === 'trash') addXp(userId, q * XP_MOMENT, 'mic_trash', id, { moment: true, line: t, lineScore: q, lineVodId: vodId, lineSec: sec });++ if (kind === 'trash' || kind === 'callout') addXp(userId, q * XP_MOMENT, kind === 'callout' ? 'mic_callout' : 'mic_trash', id, { moment: true, line: t, lineScore: q, lineVodId: vodId, lineSec: sec });return db.get('SELECT * FROM arena_mic_moments WHERE id = ?', [id]);}@@@ -192,7 +191,7 @@ function feed({ limit = 40, since = null, userId = null } = {}) {if (userId) { where += ' AND user_id = ?'; params.push(userId); }if (since) { where += ' AND id > ?'; params.push(Number(since) || 0); }params.push(Math.min(200, Math.max(1, limit)));-- return db.all(`SELECT * FROM arena_mic_moments WHERE ${where} ORDER BY id DESC LIMIT ?`, params).map(m => momentView(m, roster));++ return db.all(`SELECT * FROM arena_mic_moments WHERE ${where} ORDER BY COALESCE(said_at, created_at) DESC, id DESC LIMIT ?`, params).map(m => momentView(m, roster));}function momentsFor(userId, limit = 12) { return feed({ limit, userId }); }function bestLines(userId, limit = 5) {@@@ -202,7 +201,7 @@ function bestLines(userId, limit = 5) {}function latestFor(userId) {ensureTables();-- const m = db.get('SELECT * FROM arena_mic_moments WHERE user_id = ? ORDER BY id DESC LIMIT 1', [userId]);++ const m = db.get('SELECT * FROM arena_mic_moments WHERE user_id = ? ORDER BY COALESCE(said_at, created_at) DESC, id DESC LIMIT 1', [userId]);return m ? momentView(m, arena().loadRoster()) : null;}@@@ -211,7 +210,7 @@ function micStats(userId, days = 30) {ensureTables();const win = `-${days} days`;const m = db.get(`SELECT COUNT(*) AS n, COALESCE(AVG(quality), 0) AS avg_q, COALESCE(MAX(quality), 0) AS best_q,-- COALESCE(SUM(kind = 'beef_hit'), 0) AS beef_hits, COALESCE(SUM(kind = 'trash'), 0) AS trash,++ COALESCE(SUM(kind IN ('beef_hit', 'callout')), 0) AS beef_hits, COALESCE(SUM(kind = 'trash'), 0) AS trash,COALESCE(SUM(quality >= 7), 0) AS bangersFROM arena_mic_moments WHERE user_id = ? AND created_at >= datetime('now', ?)`, [userId, win]) || {};let b = {};@@@ -76,6 +76,11 @@ router.post('/fighters/:user/refresh', requireAuth, permissions.requireAdmin, as} catch (err) { fail(res, err, 'Refresh failed'); }});++// Admin: judge past speech now (bounded per call; the job also does this every 20 min).++router.post('/backfill', requireAuth, permissions.requireAdmin, async (req, res) => {++ try { res.json(await require('./backfill').run({ force: true })); } catch (err) { fail(res, err, 'Backfill failed'); }++});++// ── Live console: what the ears hear for one fighter ──router.get('/console/:user', (req, res) => {try {@@@ -228,6 +228,9 @@ function buildConfig(registryValues) {amountMin: parseInt(process.env.RS_PROMO_AMOUNT_MIN, 10) || 25, // bottom of the range// Referral: an existing OpenVibe user who brings a RobotStreamer streamer over gets paid too.referral: parseInt(process.env.RS_PROMO_REFERRAL, 10) || 10,++ // VIP recruiter: one user with a higher per-conversion rate, spotlighted on the home page.++ vipUser: process.env.RS_PROMO_VIP_USER || 'patrick',++ vipReferral: parseInt(process.env.RS_PROMO_REFERRAL_VIP, 10) || 20,github: process.env.RS_PROMO_GITHUB || 'https://github.com/OpenVibers/OpenVibe.Live',// Who the claimant DMs in-site; defaults to the admin account.owner: process.env.RS_PROMO_OWNER || process.env.ADMIN_USERNAME || 'admin',@@@ -614,7 +614,9 @@ app.get('/api/health', (req, res) => {app.get('/api/promo/robotstreamer', (req, res) => {const p = config.rsPromo || {};res.set('Cache-Control', 'public, max-age=300');-- res.json({ enabled: p.enabled !== false, amount: p.amount || 50, amount_min: p.amountMin || 25, referral: p.referral || 10, github: p.github || null, owner: p.owner || 'admin', discord: p.discord || null });++ let vip = null;++ try { const u = p.vipUser ? db.getUserByUsername(p.vipUser) : null; vip = { username: u ? u.username : p.vipUser, display_name: u ? (u.display_name || u.username) : p.vipUser, avatar_url: u ? u.avatar_url : null, referral: p.vipReferral || 20, exists: !!u }; } catch { vip = { username: p.vipUser, display_name: p.vipUser, referral: p.vipReferral || 20, exists: false }; }++ res.json({ enabled: p.enabled !== false, amount: p.amount || 50, amount_min: p.amountMin || 25, referral: p.referral || 10, vip, github: p.github || null, owner: p.owner || 'admin', discord: p.discord || null });});// ── Updates / Changelog ──────────────────────────────────────@@@ -162,12 +162,36 @@ console.log('✅ roster + ratings are pure mic');assert.ok(sent.pop().includes('on the clock'));console.log('✅ chat: !hype / !beef only');++ // ── Backfill: past speech on ended streams gets judged into the feed (heuristic judges, no AI) ──++ const backfill = require('../server/arena/backfill');++ const old = Number(db.createStream({ user_id: u2, title: 'yesterday', category: 'irl', protocol: 'rtmp' }).lastInsertRowid);++ db.run(`UPDATE streams SET is_live = 0, started_at = datetime('now', '-1 day'), ended_at = datetime('now', '-23 hours'), duration_seconds = 3600 WHERE id = ?`, [old]);++ db.addTimelineEvents([++ { stream_id: old, user_id: u2, vod_id: 777, kind: 'speech', start_sec: 100, end_sec: 106, text: 'Chat, all of you are clowns, absolute bums, nobody in this chat could beat me at anything, sit down losers, bet!', label: null, confidence: 0.9 },++ { stream_id: old, user_id: u2, vod_id: 777, kind: 'speech', start_sec: 107, end_sec: 112, text: 'Pathetic, every single one of you is washed and delusional, I am better than this entire chat!', label: null, confidence: 0.9 },++ { stream_id: old, user_id: u2, vod_id: 777, kind: 'speech', start_sec: 400, end_sec: 406, text: 'Okay so we go to the shop, buy the potions, then head to the cave and farm the boss for the sword, let me check the map for the route.', label: null, confidence: 0.9 },++ { stream_id: old, user_id: u2, vod_id: 777, kind: 'speech', start_sec: 407, end_sec: 412, text: 'Yeah the cave is north of the village past the bridge, should take about ten minutes if we do not get lost again.', label: null, confidence: 0.9 },++ ]);++ const feedBefore = mic.feed({ limit: 100 }).length;++ const bfr = await backfill.run({ force: true });++ assert.ok(bfr.moments >= 1, `backfill judged old speech into the feed: ${JSON.stringify(bfr)}`);++ assert.ok(bfr.skipped >= 1, 'gameplay chunk never cost a judge call');++ const fromOld = mic.feed({ limit: 100 }).filter(m => m.stream_id === old);++ assert.ok(fromOld.length >= 1 && fromOld[0].vod_id === 777 && fromOld[0].aimed_at === 'chat', `moment carries VOD + target: ${JSON.stringify(fromOld[0])}`);++ assert.ok(fromOld[0].at && Date.now() - Date.parse(fromOld[0].at.replace(' ', 'T') + 'Z') > 20 * 3600 * 1000, 'moment is dated when it was SAID, not when it was judged');++ assert.ok(mic.feed({ limit: 100 }).length >= feedBefore + fromOld.length, 'other fighters\' spicy past lines were judged too');++ const again = await backfill.run({ force: true });++ assert.strictEqual(again.judged, 0, 'cursor: nothing re-judged on the second run');++ assert.deepStrictEqual(await backfill.run({ force: true }), await backfill.run({ force: true }));++ console.log('✅ backfill: past speech judged once, VOD-linked, dated when said');++// ── Public API smoke ──const express = require('express');const app = express(); app.use(express.json()); app.use('/api/arena', require('../server/arena/routes'));const srv = await new Promise(r => { const s = app.listen(0, () => r(s)); });const get = async (p) => { const res = await fetch(`http://127.0.0.1:${srv.address().port}/api/arena${p}`); const txt = await res.text(); let body = null; try { body = JSON.parse(txt); } catch { body = null; } return { status: res.status, body }; };const fd = await get('/feed?limit=5'); assert.strictEqual(fd.status, 200); assert.ok(fd.body.feed.length >= 3 && fd.body.feed[0].text);++ assert.strictEqual((await get('/backfill')).status, 404, 'backfill is POST + admin only');const fi = await get('/fighters'); assert.deepStrictEqual(fi.body.stats, arena.STAT_KEYS); assert.ok(fi.body.fighters.every(f => f.mic && typeof f.mic.avg_quality === 'number'));const bf = await get('/beefs'); assert.strictEqual(bf.body.open.length, 1); assert.strictEqual(bf.body.resolved.length, 1);const con = await get('/console/pixelqueen'); assert.strictEqual(con.body.listener.listening, true); assert.ok(con.body.recent_moments.length >= 1 && con.body.mic);@@@ -120,15 +120,14 @@ console.log('✅ roster from transcripts only; idle and never-transcribed streamassert.ok(!('followers' in card.raw) && !('peak_viewers' in card.raw), 'no audience numbers on the card');assert.strictEqual(card.image_url, null);assert.strictEqual(card.image_generation, 'off', 'no AI → no image generation');-- assert.ok(card.quotes && card.quotes.picks.length >= 3, 'heuristic quotes without AI');-- assert.ok(card.quotes.picks[0].vod_id === 901 && typeof card.quotes.picks[0].start_sec === 'number', 'quotes link to the VOD second');-- assert.strictEqual(card.quotes._fallback, true);++ assert.strictEqual(card.quotes, null, 'nothing is generated on view any more — no quotes, no persona, no portrait');++ assert.ok(card.persona_is_fallback, 'persona is the stats-only fallback (never generated)');assert.deepStrictEqual(card.beefs, []);assert.strictEqual(card.level.level, 1);const off = await arena.getFighter('mute');assert.strictEqual(off.not_on_roster, true);assert.ok(/mic/.test(off.reason));-- console.log('✅ fighter card + transcript quotes');++ console.log('✅ fighter card (mic-only, nothing generated on view)');const detail = arena.getStatDetail(u1, 'mouth');assert.strictEqual(detail.position, 1);
@@ -11,6 +11,8 @@ ADMIN_PASSWORD=changeme123
@@ -11,6 +11,8 @@ ADMIN_PASSWORD=changeme123
# RS_PROMO_AMOUNT=50 # top of the $25–$50 range
# RS_PROMO_AMOUNT=50 # top of the $25–$50 range
# RS_PROMO_AMOUNT_MIN=25
# RS_PROMO_AMOUNT_MIN=25
# RS_PROMO_REFERRAL=10 # paid to an existing user who brings a RobotStreamer streamer over
# RS_PROMO_REFERRAL=10 # paid to an existing user who brings a RobotStreamer streamer over
+# RS_PROMO_VIP_USER=patrick # the VIP recruiter spotlighted on the home page (username)
+# RS_PROMO_REFERRAL_VIP=20 # the VIP recruiter's per-conversion rate
# RS_PROMO_GITHUB=https://github.com/OpenVibers/OpenVibe.Live
# RS_PROMO_GITHUB=https://github.com/OpenVibers/OpenVibe.Live
# RS_PROMO_OWNER=alex # in-site username claimants DM (defaults to ADMIN_USERNAME)
# RS_PROMO_OWNER=alex # in-site username claimants DM (defaults to ADMIN_USERNAME)
# RS_PROMO_DISCORD=https://discord.gg/M6MuRUaeJj
# RS_PROMO_DISCORD=https://discord.gg/M6MuRUaeJj
@@ -6,7 +6,11 @@ The **Arena** tab (`/arena`) is streamer-vs-streamer shit talk with exactly one
@@ -6,7 +6,11 @@ The **Arena** tab (`/arena`) is streamer-vs-streamer shit talk with exactly one
- **The feed** — everything else that is actually shit talk (at chat, the mods, other platforms, the game, the world) is judged, scored 0–10, VOD-linked and lands in the live feed. It pays Trash Level XP. Gameplay narration and small talk score nothing.
- **The feed** — everything else that is actually shit talk (at chat, the mods, other platforms, the game, the world) is judged, scored 0–10, VOD-linked and lands in the live feed. It pays Trash Level XP. Gameplay narration and small talk score nothing.
- **The ladder** — every fighter is rated on seven mic-only stats, as percentiles across the roster, plus an AI persona and portrait written from their transcripts.
- **The ladder** — every fighter is rated on seven mic-only stats, as percentiles across the roster, plus an AI persona and portrait written from their transcripts.
-With AI off everything still works: keyword judges, template headlines, fallback personas.
+**No personas, no portraits, no AI ring names** (removed 2026-09-11): fighters are the streamers, under their own names and avatars. The only AI on this page is the judge.
+
+**Backfill** (`server/arena/backfill.js`): the listener only hears live cams, so past speech (last 7 days, ended streams) is judged by the job every 20 min (and 90 s after boot): chunks of ≥ 20 words, only the ones that look spicy or name a fighter go to a model (≤ 160 calls per run), name-drops become `callout` moments with the target attached (no clocks for old speech), the rest `trash` moments — dated when they were *said*, VOD-linked. A per-fighter cursor keeps it incremental. Admin: `POST /api/arena/backfill`.
+
+With AI off everything still works: keyword judges, template headlines.
## Speech policy
## Speech policy
@@ -207,6 +207,12 @@ body.rs-ticker-on .rs-modal, body.rs-ticker-on .rs-takeover { --rs-ticker-h: 0px
@@ -207,6 +207,12 @@ body.rs-ticker-on .rs-modal, body.rs-ticker-on .rs-takeover { --rs-ticker-h: 0px
.rs-chip-zelle { border-color: rgba(109,40,217,0.8); background: linear-gradient(135deg, rgba(109,40,217,0.5), rgba(139,92,246,0.25)); }
.rs-chip-zelle { border-color: rgba(109,40,217,0.8); background: linear-gradient(135deg, rgba(109,40,217,0.5), rgba(139,92,246,0.25)); }
.rs-chip-paypal { border-color: rgba(0,112,186,0.9); background: linear-gradient(135deg, rgba(0,48,135,0.6), rgba(0,156,222,0.3)); }
.rs-chip-paypal { border-color: rgba(0,112,186,0.9); background: linear-gradient(135deg, rgba(0,48,135,0.6), rgba(0,156,222,0.3)); }
.rs-chip-paypal i { color: #7cc4ff; }
.rs-chip-paypal i { color: #7cc4ff; }
+.rs-chip-crypto { border-color: rgba(247,147,26,0.9); background: linear-gradient(135deg, rgba(247,147,26,0.45), rgba(251,191,36,0.2)); }
+.rs-chip-crypto i { color: #ffd28a; }
+.rs-toggle-3 { grid-template-columns: 1fr 1fr 1fr; }
+.rs-toggle-3 .rs-toggle-thumb { width: calc(33.333% - 4px); }
+.rs-toggle-3[data-method="paypal"] .rs-toggle-thumb { transform: translateX(100%); }
+.rs-toggle-3[data-method="crypto"] .rs-toggle-thumb { transform: translateX(200%); background: linear-gradient(135deg, #b45309, #f7931a); box-shadow: 0 6px 18px rgba(247,147,26,0.5); }
.rs-chip-cash { border-color: rgba(34,197,94,0.8); background: linear-gradient(135deg, rgba(21,128,61,0.55), rgba(34,197,94,0.25)); }
.rs-chip-cash { border-color: rgba(34,197,94,0.8); background: linear-gradient(135deg, rgba(21,128,61,0.55), rgba(34,197,94,0.25)); }
.rs-chip-build { border-color: rgba(251,191,36,0.8); background: linear-gradient(135deg, rgba(180,83,9,0.5), rgba(251,191,36,0.22)); }
.rs-chip-build { border-color: rgba(251,191,36,0.8); background: linear-gradient(135deg, rgba(180,83,9,0.5), rgba(251,191,36,0.22)); }
.rs-chip-build i { color: #fde68a; }
.rs-chip-build i { color: #fde68a; }
@@ -374,6 +380,94 @@ body.rs-ticker-on .rs-modal, body.rs-ticker-on .rs-takeover { --rs-ticker-h: 0px
@@ -374,6 +380,94 @@ body.rs-ticker-on .rs-modal, body.rs-ticker-on .rs-takeover { --rs-ticker-h: 0px
.rs-card-replay { background: none; border: none; color: #94a3b8; font-size: 0.72rem; cursor: pointer; text-decoration: underline dotted; }
.rs-card-replay { background: none; border: none; color: #94a3b8; font-size: 0.72rem; cursor: pointer; text-decoration: underline dotted; }
.rs-card-replay:hover { color: #fff; }
.rs-card-replay:hover { color: #fff; }
+/* ═══════════════════════════════════════════════════════════════
+ VIP RECRUITER — the starfish bounty board under the hero card
+ ═══════════════════════════════════════════════════════════════ */
+.rs-vip {
+ position: relative; z-index: 1; margin-top: 14px; padding: 16px 20px; border-radius: 20px; overflow: hidden; text-align: left;
+ display: grid; grid-template-columns: 140px 1fr; gap: 18px; align-items: center; color: #fff;
+ background: linear-gradient(120deg, #0b3a5c, #0e5c7a 55%, #1a2f6b);
+ border: 2px solid rgba(255,143,190,0.6); box-shadow: 0 20px 50px rgba(0,0,0,0.45), 0 0 30px rgba(255,95,162,0.25);
+ animation: rsCardIn 0.9s cubic-bezier(.2,1.3,.3,1) 0.5s both;
+}
+.rs-vip::before { content: ''; position: absolute; inset: 0; background: repeating-linear-gradient(0deg, transparent 0 22px, rgba(255,255,255,0.03) 22px 23px); pointer-events: none; }
+.rs-vip-bubbles { position: absolute; inset: 0; pointer-events: none; }
+.rs-vip-bubbles span { position: absolute; bottom: -20px; width: 10px; height: 10px; border-radius: 50%; border: 2px solid rgba(255,255,255,0.55); background: rgba(255,255,255,0.08); animation: rsBubble 6s ease-in infinite; }
+.rs-vip-bubbles span:nth-child(1) { left: 8%; animation-delay: 0s; } .rs-vip-bubbles span:nth-child(2) { left: 22%; width: 6px; height: 6px; animation-delay: -2.2s; }
+.rs-vip-bubbles span:nth-child(3) { left: 47%; animation-delay: -4s; } .rs-vip-bubbles span:nth-child(4) { left: 63%; width: 14px; height: 14px; animation-delay: -1.3s; }
+.rs-vip-bubbles span:nth-child(5) { left: 80%; animation-delay: -3.1s; } .rs-vip-bubbles span:nth-child(6) { left: 93%; width: 7px; height: 7px; animation-delay: -5s; }
+@keyframes rsBubble { 0% { transform: translate(0, 0) scale(0.6); opacity: 0; } 10% { opacity: 0.9; } 100% { transform: translate(-14px, -170px) scale(1.2); opacity: 0; } }
+.rs-vip-star { position: relative; width: 120px; height: 120px; margin: 0 auto; transform-origin: 50% 80%; animation: rsWobble 2.6s ease-in-out infinite; filter: drop-shadow(0 8px 14px rgba(0,0,0,0.45)); }
+.rs-vip-star svg { width: 100%; height: 100%; display: block; }
+.rs-vip-pupil { animation: rsGoogly 3.2s ease-in-out infinite; transform-origin: center; }
+@keyframes rsWobble { 0%,100% { transform: rotate(-7deg) scale(1, 1); } 25% { transform: rotate(6deg) scale(1.04, 0.94); } 50% { transform: rotate(-4deg) scale(0.96, 1.06); } 75% { transform: rotate(7deg) scale(1.03, 0.97); } }
+@keyframes rsGoogly { 0%,100% { transform: translate(0, 0); } 30% { transform: translate(2px, 1px); } 60% { transform: translate(-2px, 2px); } 80% { transform: translate(1px, -1px); } }
+.rs-vip-sparkle { position: absolute; color: #fde68a; font-size: 1.1rem; animation: rsTwinkle 1.6s ease-in-out infinite; text-shadow: 0 0 10px #fbbf24; }
+.rs-vip-sparkle:nth-child(2) { left: -6px; top: 10px; } .rs-vip-sparkle:nth-child(3) { right: -8px; top: 24px; animation-delay: -0.5s; } .rs-vip-sparkle:nth-child(4) { left: 50%; bottom: -8px; animation-delay: -1s; }
+@keyframes rsTwinkle { 0%,100% { transform: scale(0.6) rotate(0); opacity: 0.4; } 50% { transform: scale(1.3) rotate(45deg); opacity: 1; } }
+.rs-vip-kicker { display: inline-flex; align-items: center; gap: 6px; color: #ffb3d1; font-weight: 800; font-size: 0.72rem; letter-spacing: 0.12em; text-transform: uppercase; margin-bottom: 4px; }
+.rs-vip-title { margin: 0 0 6px; font-size: clamp(1.05rem, 2.2vw, 1.4rem); font-weight: 900; line-height: 1.2; }
+.rs-vip-title .rs-money { font-size: 1.4em; vertical-align: -0.08em; }
+.rs-vip-sub { margin: 0 0 10px; color: #dbeafe; font-size: 0.86rem; line-height: 1.45; }
+.rs-vip-chips { display: flex; flex-wrap: wrap; gap: 8px; }
+.rs-vip-chips .rs-chip { padding: 4px 10px; font-size: 0.78rem; animation: none; }
+@media (max-width: 720px) { .rs-vip { grid-template-columns: 1fr; text-align: center; } .rs-vip-chips { justify-content: center; } }
+
+/* ═══════════════════════════════════════════════════════════════
+ RESTREAM TOUR — cam/robot → OpenVibe hub → every platform, with packets flowing
+ ═══════════════════════════════════════════════════════════════ */
+.rs-flow2 { display: grid; gap: 6px; justify-items: center; margin: 12px 0 4px; }
+.rs-flow2-src, .rs-flow2-out { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; }
+.rs-flow2-node {
+ display: inline-flex; align-items: center; gap: 6px; padding: 5px 12px; border-radius: 999px; font-weight: 700; font-size: 0.8rem;
+ color: #fff; background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.22); backdrop-filter: blur(6px);
+ animation: rsChipIn 0.6s cubic-bezier(.2,1.4,.3,1) both;
+}
+.rs-flow2-out .rs-flow2-node:nth-child(1) { animation-delay: 0.05s; } .rs-flow2-out .rs-flow2-node:nth-child(2) { animation-delay: 0.12s; }
+.rs-flow2-out .rs-flow2-node:nth-child(3) { animation-delay: 0.19s; } .rs-flow2-out .rs-flow2-node:nth-child(4) { animation-delay: 0.26s; } .rs-flow2-out .rs-flow2-node:nth-child(5) { animation-delay: 0.33s; }
+.rs-flow2-node.rs-twitch { border-color: #9146ff; background: rgba(145,70,255,0.25); }
+.rs-flow2-node.rs-yt { border-color: #ff0000; background: rgba(255,0,0,0.2); }
+.rs-flow2-node.rs-kick { border-color: #53fc18; background: rgba(83,252,24,0.16); color: #d9ffcc; }
+.rs-flow2-node.rs-rs { border-color: var(--rs-blue); background: rgba(125,211,252,0.16); }
+.rs-flow2-hub {
+ display: grid; justify-items: center; gap: 2px; padding: 10px 18px; border-radius: 14px; text-align: center;
+ background: linear-gradient(135deg, rgba(139,92,246,0.45), rgba(109,40,217,0.25)); border: 1px solid rgba(167,139,250,0.75);
+ animation: rsGlowViolet 2s ease-in-out infinite;
+}
+.rs-flow2-hub b { font-size: 1rem; color: #fff; }
+.rs-flow2-hub small { color: #e9d5ff; font-size: 0.74rem; font-weight: 600; }
+.rs-flow2-wire { position: relative; width: 4px; height: 26px; border-radius: 2px; background: rgba(255,255,255,0.18); overflow: visible; }
+.rs-flow2-fan { width: min(70%, 420px); height: 26px; background: none; }
+.rs-flow2-fan::before { content: ''; position: absolute; left: 0; right: 0; top: 50%; height: 3px; background: repeating-linear-gradient(90deg, rgba(255,255,255,0.45) 0 8px, transparent 8px 16px); animation: rsDash 0.6s linear infinite; }
+.rs-flow2-fan::after { content: ''; position: absolute; left: 50%; top: 0; width: 4px; height: 50%; background: rgba(255,255,255,0.18); transform: translateX(-50%); }
+.rs-packet { position: absolute; left: 50%; top: -6px; width: 10px; height: 10px; margin-left: -5px; border-radius: 50%; background: #fde68a; box-shadow: 0 0 12px #fbbf24, 0 0 24px rgba(251,191,36,0.6); animation: rsPacketDown 1.2s linear infinite; }
+.rs-packet:nth-child(2) { animation-delay: 0.6s; }
+.rs-flow2-fan .rs-packet { top: 50%; margin-top: -5px; left: 50%; animation: rsPacketFan 1.4s ease-in-out infinite; }
+.rs-flow2-fan .rs-packet:nth-child(2) { animation-name: rsPacketFanL; animation-delay: 0.45s; }
+.rs-flow2-fan .rs-packet:nth-child(3) { animation-name: rsPacketFanR; animation-delay: 0.9s; }
+@keyframes rsPacketDown { 0% { transform: translateY(0); opacity: 0; } 15% { opacity: 1; } 100% { transform: translateY(26px); opacity: 0; } }
+@keyframes rsPacketFan { 0% { transform: translate(0, -14px) scale(0.6); opacity: 0; } 30% { opacity: 1; } 100% { transform: translate(0, 12px) scale(1); opacity: 0; } }
+@keyframes rsPacketFanL { 0% { transform: translate(0, -14px) scale(0.6); opacity: 0; } 30% { opacity: 1; } 100% { transform: translate(-160px, 12px) scale(1); opacity: 0; } }
+@keyframes rsPacketFanR { 0% { transform: translate(0, -14px) scale(0.6); opacity: 0; } 30% { opacity: 1; } 100% { transform: translate(160px, 12px) scale(1); opacity: 0; } }
+.rs-features { display: flex; flex-wrap: wrap; justify-content: center; gap: 6px; margin-top: 10px; }
+.rs-feat {
+ display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; border-radius: 999px; font-size: 0.76rem; font-weight: 700; color: #fff;
+ background: rgba(34,197,94,0.14); border: 1px solid rgba(34,197,94,0.5);
+ animation: rsFeatPop 0.5s cubic-bezier(.2,1.5,.3,1) both; animation-delay: calc(0.4s + var(--i, 0) * 0.08s);
+}
+.rs-feat i { color: #86efac; }
+@keyframes rsFeatPop { from { opacity: 0; transform: translateY(8px) scale(0.85); } to { opacity: 1; transform: none; } }
+.rs-flow2.is-compact { margin: 8px 0 0; gap: 4px; }
+.rs-flow2.is-compact .rs-flow2-node { padding: 3px 9px; font-size: 0.72rem; }
+.rs-flow2.is-compact .rs-flow2-hub { padding: 6px 12px; }
+.rs-flow2.is-compact .rs-flow2-hub b { font-size: 0.86rem; } .rs-flow2.is-compact .rs-flow2-hub small { font-size: 0.68rem; }
+.rs-flow2.is-compact .rs-flow2-wire { height: 16px; } .rs-flow2.is-compact .rs-flow2-fan { height: 16px; }
+.rs-tk-tour { max-width: 720px; margin: 0 auto 18px; padding: 12px 14px 14px; border-radius: 16px; background: rgba(0,0,0,0.28); border: 1px solid rgba(255,255,255,0.14); }
+.rs-tk-tour-head { font-weight: 900; font-size: 1rem; color: #fff; margin-bottom: 4px; }
+.rs-tk-tour-head i { color: var(--rs-blue); }
+.rs-step .rs-flow2 { margin-top: 8px; }
+@media (max-width: 720px) { .rs-flow2-fan { width: 90%; } }
+
/* ═══════════════════════════════════════════════════════════════
/* ═══════════════════════════════════════════════════════════════
STREAM-PAGE ALERT — under the player on every channel page
STREAM-PAGE ALERT — under the player on every channel page
═══════════════════════════════════════════════════════════════ */
═══════════════════════════════════════════════════════════════ */
@@ -587,7 +681,9 @@ body.rs-ticker-on .rs-modal, body.rs-ticker-on .rs-takeover { --rs-ticker-h: 0px
@@ -587,7 +681,9 @@ body.rs-ticker-on .rs-modal, body.rs-ticker-on .rs-takeover { --rs-ticker-h: 0px
.rs-tk-amount, .rs-tk-amount.pop .rs-money, .rs-chip, .rs-flow-node.rs-to, .rs-flow-arrow, .rs-walker,
.rs-tk-amount, .rs-tk-amount.pop .rs-money, .rs-chip, .rs-flow-node.rs-to, .rs-flow-arrow, .rs-walker,
.rs-btn-gold, .rs-btn-gold::after, .rs-bills span, .rs-card-border, .rs-card-border::before, .rs-coin, .rs-card-kicker i,
.rs-btn-gold, .rs-btn-gold::after, .rs-bills span, .rs-card-border, .rs-card-border::before, .rs-coin, .rs-card-kicker i,
.rs-modal, .rs-modal-card, .rs-modal-card::before, .rs-step, .rs-modal-fine i, .rs-wiggle,
.rs-modal, .rs-modal-card, .rs-modal-card::before, .rs-step, .rs-modal-fine i, .rs-wiggle,
- .rs-stream-alert, .rs-sa-stripe, .rs-sa-siren, .rs-sa-mini i.fa-robot {
+ .rs-stream-alert, .rs-sa-stripe, .rs-sa-siren, .rs-sa-mini i.fa-robot,
+ .rs-flow2-node, .rs-flow2-hub, .rs-flow2-fan::before, .rs-packet, .rs-feat,
+ .rs-vip, .rs-vip-bubbles span, .rs-vip-star, .rs-vip-pupil, .rs-vip-sparkle {
animation: none !important;
animation: none !important;
}
}
.rs-tk-title .rs-w, .rs-tk-content, .rs-card-border, .rs-step, .rs-chip { opacity: 1; transform: none; }
.rs-tk-title .rs-w, .rs-tk-content, .rs-card-border, .rs-step, .rs-chip { opacity: 1; transform: none; }
@@ -67,7 +67,7 @@
@@ -67,7 +67,7 @@
<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">
<link rel="stylesheet" href="/css/messenger.css?v=1">
<link rel="stylesheet" href="/css/messenger.css?v=1">
- <link rel="stylesheet" href="/css/rs-promo.css?v=3">
+ <link rel="stylesheet" href="/css/rs-promo.css?v=6">
<link rel="stylesheet" href="/css/i18n-star.css?v=2">
<link rel="stylesheet" href="/css/i18n-star.css?v=2">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
@@ -5120,12 +5120,12 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
@@ -5120,12 +5120,12 @@ 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=17" defer></script>
+<script src="/js/arena.js?v=18" 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>
<script src="/js/messenger.js?v=2" defer></script>
<script src="/js/messenger.js?v=2" defer></script>
-<script src="/js/rs-promo.js?v=5" defer></script>
+<script src="/js/rs-promo.js?v=8" defer></script>
<!-- ── OpenVibe shared libs (notifications, account sync, themes) ── -->
<!-- ── OpenVibe shared libs (notifications, account sync, themes) ── -->
<script src="/shared/theme-loader.js" defer></script>
<script src="/shared/theme-loader.js" defer></script>
@@ -83,15 +83,13 @@ function _aBindSpeak(root) {
@@ -83,15 +83,13 @@ function _aBindSpeak(root) {
function _aPortrait(f, size = 'md') {
function _aPortrait(f, size = 'md') {
const u = f.user || f;
const u = f.user || f;
const color = u.profile_color || '#8b5cf6';
const color = u.profile_color || '#8b5cf6';
- if (f.image_url) return `<div class="arena-portrait arena-portrait-${size}" style="--fc:${_aEsc(color)}"><img src="${_aEsc(f.image_url)}" alt="" loading="lazy"></div>`;
if (u.avatar_url) return `<div class="arena-portrait arena-portrait-${size} arena-portrait-avatar" style="--fc:${_aEsc(color)}"><img src="${_aEsc(u.avatar_url)}" alt="" loading="lazy"><span class="arena-portrait-glow"></span></div>`;
if (u.avatar_url) return `<div class="arena-portrait arena-portrait-${size} arena-portrait-avatar" style="--fc:${_aEsc(color)}"><img src="${_aEsc(u.avatar_url)}" alt="" loading="lazy"><span class="arena-portrait-glow"></span></div>`;
return `<div class="arena-portrait arena-portrait-${size} arena-portrait-initial" style="--fc:${_aEsc(color)}"><span>${_aEsc(_aInitial(u))}</span></div>`;
return `<div class="arena-portrait arena-portrait-${size} arena-portrait-initial" style="--fc:${_aEsc(color)}"><span>${_aEsc(_aInitial(u))}</span></div>`;
}
}
/** A fighter's radar: the seven mic stats, or their AI characteristics when the persona has them. */
/** A fighter's radar: the seven mic stats, or their AI characteristics when the persona has them. */
function _aCustomRadar(f, color, size = 240, { objective = false } = {}) {
function _aCustomRadar(f, color, size = 240, { objective = false } = {}) {
- const cs = (!objective && 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 = ARENA_STATS.map(k => ({ name: ARENA_STAT_LABEL[k], value: Math.max(0, Math.min(99, Number(f.ratings?.[k]) || 0)), quip: _arenaRoster?.stat_meta?.[k]?.desc || '' }));
const N = axes.length, c = size / 2, R = size / 2 - 62;
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))];
@@ -107,11 +105,6 @@ function _aCustomRadar(f, color, size = 240, { objective = false } = {}) {
@@ -107,11 +105,6 @@ function _aCustomRadar(f, color, size = 240, { objective = false } = {}) {
${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('')}
${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) {
- const cs = (f.persona && Array.isArray(f.persona.custom_stats) ? f.persona.custom_stats : []);
- if (!cs.length) return '';
- return `<div class="arena-quips arena-quips-custom">${cs.map(x => `<div class="arena-quip"><b><span class="arena-quip-val">${Number(x.value)}</span> ${_aEsc(x.name)}</b><span>${_aEsc(x.quip || '')}</span></div>`).join('')}</div>`;
-}
/** Sparkline of a per-stream series. */
/** Sparkline of a per-stream series. */
function _aSpark(series, color) {
function _aSpark(series, color) {
@@ -182,7 +175,7 @@ async function _aRenderHome(root) {
@@ -182,7 +175,7 @@ async function _aRenderHome(root) {
<div class="arena-hero arena-hero-cam">
<div class="arena-hero arena-hero-cam">
<div>
<div>
<h1><i class="fa-solid fa-microphone-lines"></i> Battle Cam</h1>
<h1><i class="fa-solid fa-microphone-lines"></i> Battle Cam</h1>
- <p class="arena-lede">Pure mic. Nobody clicks, nobody votes, nothing typed in chat counts. The ears listen to every live cam: call another fighter out on mic and the beef opens itself — they're on the clock, silence is a forfeit. Talk shit at chat, at the mods, at the world, and it lands in the feed and levels you up. Every rating on the ladder comes from what was said into a microphone.</p>
+ <p class="arena-lede">Pure mic. No personas, no AI characters, no chat points, no votes. This page is the shit talk that was actually said on stream — judged, scored, and replayable at the second it happened. Call another streamer out on mic and the beef opens itself; they're on the clock, silence is a forfeit. Talk shit at chat, at the mods, at the world, and it lands in the feed and levels you up.</p>
</div>
</div>
<div class="arena-hero-actions">
<div class="arena-hero-actions">
<span class="arena-note">${roster.ai ? '<i class="fa-solid fa-ear-listen"></i> AI judge on' : '<i class="fa-solid fa-ear-deaf"></i> AI off — keyword judging'}</span>
<span class="arena-note">${roster.ai ? '<i class="fa-solid fa-ear-listen"></i> AI judge on' : '<i class="fa-solid fa-ear-deaf"></i> AI off — keyword judging'}</span>
@@ -215,7 +208,7 @@ async function _aRenderHome(root) {
@@ -215,7 +208,7 @@ async function _aRenderHome(root) {
if (_aMe()) _aRenderMe().catch(() => { const el = document.getElementById('arena-me'); if (el) el.innerHTML = ''; });
if (_aMe()) _aRenderMe().catch(() => { const el = document.getElementById('arena-me'); if (el) el.innerHTML = ''; });
document.getElementById('arena-search')?.addEventListener('input', (e) => {
document.getElementById('arena-search')?.addEventListener('input', (e) => {
const q = e.target.value.trim().toLowerCase();
const q = e.target.value.trim().toLowerCase();
- _aRenderList(!q ? roster.fighters : roster.fighters.filter(f => [f.persona.fighter_name, f.user.display_name, f.user.username, f.persona.class, f.persona.element].filter(Boolean).some(s => s.toLowerCase().includes(q))));
+ _aRenderList(!q ? roster.fighters : roster.fighters.filter(f => [f.user.display_name, f.user.username].filter(Boolean).some(s => s.toLowerCase().includes(q))));
});
});
_aEvery(12000, async () => {
_aEvery(12000, async () => {
try {
try {
@@ -257,7 +250,7 @@ function _aRenderCams(live) {
@@ -257,7 +250,7 @@ function _aRenderCams(live) {
${_aA(_aChannelLink(f.user, f.stream.slug || f.stream.managed_stream_id), `${f.thumbnail_url ? `<img class="arena-live-thumb" src="${_aEsc(f.thumbnail_url)}" alt="">` : _aPortrait(f, 'md')}<span class="arena-cam-rec"><span class="arena-live-dot arena-live-dot-sm"></span> LIVE · <i class="fa-solid fa-eye"></i> ${_aNum(f.stream.viewer_count)}</span>`, 'arena-cam-thumb')}
${_aA(_aChannelLink(f.user, f.stream.slug || f.stream.managed_stream_id), `${f.thumbnail_url ? `<img class="arena-live-thumb" src="${_aEsc(f.thumbnail_url)}" alt="">` : _aPortrait(f, 'md')}<span class="arena-cam-rec"><span class="arena-live-dot arena-live-dot-sm"></span> LIVE · <i class="fa-solid fa-eye"></i> ${_aNum(f.stream.viewer_count)}</span>`, 'arena-cam-thumb')}
<div class="arena-cam-body">
<div class="arena-cam-body">
<div class="arena-mic-head">
<div class="arena-mic-head">
- ${_aA(_aFighterLink(f.user), `<strong>${_aEsc(f.persona.fighter_name)}</strong>`)}
+ ${_aA(_aFighterLink(f.user), `<strong>${_aEsc(f.user.display_name || f.user.username)}</strong>`)}
<span class="arena-mic-meta">#${f.rank} · PWR ${f.ratings.power} · ${_aLevelPill(f.level)} · ${f.record.wins}W–${f.record.losses}L</span>
<span class="arena-mic-meta">#${f.rank} · PWR ${f.ratings.power} · ${_aLevelPill(f.level)} · ${f.record.wins}W–${f.record.losses}L</span>
</div>
</div>
${lock ? `<div class="arena-cam-lock"><i class="fa-solid fa-crosshairs fa-beat"></i> locked on <b>${_aEsc(lock.target || 'a fighter')}</b> · ${lock.hits} hit${lock.hits === 1 ? '' : 's'}</div>` : ''}
${lock ? `<div class="arena-cam-lock"><i class="fa-solid fa-crosshairs fa-beat"></i> locked on <b>${_aEsc(lock.target || 'a fighter')}</b> · ${lock.hits} hit${lock.hits === 1 ? '' : 's'}</div>` : ''}
@@ -285,7 +278,7 @@ function _aMomentRow(m) {
@@ -285,7 +278,7 @@ function _aMomentRow(m) {
</div>`;
</div>`;
}
}
function _aRenderFeed(feed, { fresh = 0 } = {}) {
function _aRenderFeed(feed, { fresh = 0 } = {}) {
- return `<h2><i class="fa-solid fa-satellite-dish"></i> The feed <small>every judged line, newest first · ${feed.length ? `${feed.length} on record` : 'nothing yet'}${fresh ? ` · <b class="arena-fresh">+${fresh} new</b>` : ''}</small></h2>
+ return `<h2><i class="fa-solid fa-satellite-dish"></i> Shit talk on record <small>what was actually said on stream, newest first · ${feed.length ? `${feed.length} lines` : 'nothing yet'}${fresh ? ` · <b class="arena-fresh">+${fresh} new</b>` : ''}</small></h2>
${feed.length ? `<div class="arena-mic-feed-list">${feed.map(_aMomentRow).join('')}</div>` : `<div class="arena-beef-empty"><i class="fa-solid fa-microphone-lines"></i><p>Quiet. The moment a fighter on a live cam talks shit — at anyone — it lands here with a score.</p></div>`}`;
${feed.length ? `<div class="arena-mic-feed-list">${feed.map(_aMomentRow).join('')}</div>` : `<div class="arena-beef-empty"><i class="fa-solid fa-microphone-lines"></i><p>Quiet. The moment a fighter on a live cam talks shit — at anyone — it lands here with a score.</p></div>`}`;
}
}
@@ -376,26 +369,23 @@ function _aRenderList(fighters) {
@@ -376,26 +369,23 @@ 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>
- <span class="arena-row-sub">${_aEsc(f.user.display_name)} · ${_aEsc(f.persona.class)} · ${_aEsc(f.persona.element)}${f.mic ? ` · <i class="fa-solid fa-microphone" title="judged lines, 30 days"></i> ${f.mic.moments} lines · avg ${f.mic.avg_quality}` : ''}</span>
- ${f.last_line ? `<em class="arena-row-taunt">“${_aEsc(f.last_line.text)}”</em>` : `<em class="arena-row-taunt">“${_aEsc(f.persona.taunt)}”</em>`}
+ <strong>${_aEsc(f.user.display_name || f.user.username)} ${f.live ? '<span class="arena-live-pill">LIVE</span>' : ''} ${_aLevelPill(f.level?.level)}<i class="fa-solid fa-chevron-right arena-chevron"></i></strong>
+ <span class="arena-row-sub">${f.mic ? `<i class="fa-solid fa-microphone" title="judged lines, 30 days"></i> ${f.mic.moments} lines · avg ${f.mic.avg_quality}/10 · ${f.mic.bangers} bangers · ${f.mic.beef_hits} callouts` : ''}${f.voice?.has_data ? ` · ${_aNum(f.voice.speech_minutes)} min on mic` : ''}</span>
+ ${f.last_line ? `<em class="arena-row-taunt">“${_aEsc(f.last_line.text)}”${f.last_line.aimed_at ? ` <small>— at ${_aEsc(f.last_line.aimed_at)}</small>` : ''}</em>` : '<em class="arena-row-taunt arena-note">nothing judged yet</em>'}
</span>
</span>
<span class="arena-row-stats">
<span class="arena-row-stats">
<span class="arena-power"><b>${f.ratings.power}</b><small>PWR</small></span>
<span class="arena-power"><b>${f.ratings.power}</b><small>PWR</small></span>
<span class="arena-record" title="beef record">${f.record.wins}W–${f.record.losses}L</span>
<span class="arena-record" title="beef record">${f.record.wins}W–${f.record.losses}L</span>
</span>
</span>
<div class="arena-row-expand">
<div class="arena-row-expand">
- <div>${_aCustomRadar(f, f.user.profile_color || '#8b5cf6', 220, { objective: true })}<div class="arena-mini-record">the seven mic stats · ${f.category ? _aEsc(f.category) + ' · ' : ''}last live ${_aEsc(f.last_live_at ? _aDate(f.last_live_at) : '—')}</div></div>
+ <div>${_aCustomRadar(f, f.user.profile_color || '#8b5cf6', 220, { objective: true })}<div class="arena-mini-record">the seven mic stats · last live ${_aEsc(f.last_live_at ? _aDate(f.last_live_at) : '—')}</div></div>
<div>
<div>
- <p class="arena-row-lore is-clamp">${_aEsc(f.persona.lore)}</p>
- ${(f.persona.taunts || []).length ? `<div class="arena-bubbles arena-row-bubbles">${f.persona.taunts.slice(0, 2).map(t => `<div class="arena-bubble"><span class="arena-bubble-who">${_aEsc(f.user.display_name || f.user.username)}</span><span class="arena-bubble-text">${_aEsc(t)}</span>${_aSpeakBtn(t, 'arena-bubble-speak', f.user.username)}</div>`).join('')}${f.persona.typing_style ? `<small class="arena-note"><i class="fa-solid fa-microphone"></i> ${_aEsc(f.persona.typing_style)}</small>` : ''}</div>` : ''}
- <div class="arena-quips">${ARENA_STATS.map(k => `<div class="arena-quip"><b>${_aEsc(ARENA_STAT_LABEL[k])} ${f.ratings[k]}</b><span>${_aEsc((f.persona.stat_quips || {})[k] || '')}</span></div>`).join('')}</div>
- ${(f.persona.custom_stats || []).length ? _aCustomQuips(f) : ''}
+ <div class="arena-quips">${ARENA_STATS.map(k => `<div class="arena-quip"><b>${_aEsc(ARENA_STAT_LABEL[k])} ${f.ratings[k]}</b><span>${_aEsc(_arenaRoster?.stat_meta?.[k]?.desc || '')}</span></div>`).join('')}</div>
</div>
</div>
<div class="arena-row-expand-actions">
<div class="arena-row-expand-actions">
- ${_aA(_aFighterLink(f.user), '<i class="fa-solid fa-id-card"></i> Full profile', 'btn btn-primary')}
+ ${_aA(_aFighterLink(f.user), '<i class="fa-solid fa-satellite-dish"></i> Their lines', 'btn btn-primary')}
${f.live ? _aA(_aConsoleLink(f.user), '<i class="fa-solid fa-ear-listen"></i> Listen in', 'btn btn-ghost') : ''}
${f.live ? _aA(_aConsoleLink(f.user), '<i class="fa-solid fa-ear-listen"></i> Listen in', 'btn btn-ghost') : ''}
- ${_aSpeakBtn(f.persona.taunt, 'btn btn-ghost', f.user.username)}
+ ${f.last_line ? _aSpeakBtn(f.last_line.text, 'btn btn-ghost', f.user.username) : ''}
</div>
</div>
</div>
</div>
</div>`).join('');
</div>`).join('');
@@ -534,33 +524,18 @@ async function _aRenderConsole(root, username) {
@@ -534,33 +524,18 @@ async function _aRenderConsole(root, username) {
function _aVoiceCard(f) {
function _aVoiceCard(f) {
const v = f.voice || {};
const v = f.voice || {};
- const q = f.quotes;
const color = f.user.profile_color || '#8b5cf6';
const color = f.user.profile_color || '#8b5cf6';
if (!v.has_data) {
if (!v.has_data) {
return `<div class="arena-voice" style="--fc:${_aEsc(color)}"><div class="arena-voice-head"><h3><i class="fa-solid fa-microphone-slash"></i> On the mic</h3></div><p class="arena-voice-empty">No transcript data yet — the audio transcription picks this up on their next streams. Until then the Arena can't hear them.</p></div>`;
return `<div class="arena-voice" style="--fc:${_aEsc(color)}"><div class="arena-voice-head"><h3><i class="fa-solid fa-microphone-slash"></i> On the mic</h3></div><p class="arena-voice-empty">No transcript data yet — the audio transcription picks this up on their next streams. Until then the Arena can't hear them.</p></div>`;
}
}
- const quotes = (q && q.picks && q.picks.length) ? q.picks : [];
return `<div class="arena-voice" style="--fc:${_aEsc(color)}">
return `<div class="arena-voice" style="--fc:${_aEsc(color)}">
- <div class="arena-voice-head">
- <h3><i class="fa-solid fa-microphone"></i> On the mic <span class="arena-power" style="margin-left:6px"><b style="font-size:1.1rem">${f.ratings.mouth}</b><small>MOUTH</small></span></h3>
- ${q?.mic_style ? `<span class="arena-voice-style">${_aEsc(q.mic_style)}</span>` : ''}
- </div>
- ${q?.voice_verdict ? `<p class="arena-voice-verdict">${_aEsc(q.voice_verdict)}</p>` : ''}
+ <div class="arena-voice-head"><h3><i class="fa-solid fa-microphone"></i> On the mic <span class="arena-power" style="margin-left:6px"><b style="font-size:1.1rem">${f.ratings.mouth}</b><small>MOUTH</small></span></h3></div>
<div class="arena-voice-meters">
<div class="arena-voice-meters">
<div class="arena-voice-meter"><b>${_aEsc(v.talk_ratio_pct)}%</b><span>of stream time talking</span></div>
<div class="arena-voice-meter"><b>${_aEsc(v.talk_ratio_pct)}%</b><span>of stream time talking</span></div>
<div class="arena-voice-meter"><b>${_aEsc(_aNum(v.speech_minutes))} min</b><span>of speech heard (90d)</span></div>
<div class="arena-voice-meter"><b>${_aEsc(_aNum(v.speech_minutes))} min</b><span>of speech heard (90d)</span></div>
<div class="arena-voice-meter"><b>${_aEsc(v.wpm)}</b><span>words per minute</span></div>
<div class="arena-voice-meter"><b>${_aEsc(v.wpm)}</b><span>words per minute</span></div>
- <div class="arena-voice-meter"><b>${_aEsc(v.hype_per_hour)}</b><span>hype words / hour</span></div>
- <div class="arena-voice-meter"><b>${_aEsc(v.laughs_per_hour)}</b><span>laughs / hour</span></div>
<div class="arena-voice-meter"><b>${_aEsc(v.streams_heard)}</b><span>streams transcribed</span></div>
<div class="arena-voice-meter"><b>${_aEsc(v.streams_heard)}</b><span>streams transcribed</span></div>
</div>
</div>
- ${v.top_sounds && v.top_sounds.length ? `<div class="arena-sounds"><span title="what the stream sounds like, from the audio-event detector"><i class="fa-solid fa-wave-square"></i> soundscape</span>${v.top_sounds.map(s => `<span>${_aEsc(s.label)} ×${s.n}</span>`).join('')}</div>` : ''}
- ${quotes.length ? `<div class="arena-quotes">${quotes.map(p => `
- <div class="arena-quote">
- <div><q>${_aEsc(p.text)}</q><small>${_aEsc(p.why || '')}${p.vod_id ? ` · at ${_aStamp(p.start_sec)}` : ''}</small></div>
- <div class="arena-quote-actions">${_aPlay(p.vod_id, p.start_sec)} ${_aSpeakBtn(p.text, '', f.user.username)}</div>
- </div>`).join('')}</div>` : '<p class="arena-voice-empty">Quotes appear once enough lines have been transcribed.</p>'}
- ${q?._fallback ? '<p class="arena-note">Quotes picked by heuristic — the AI curates these once enabled.</p>' : ''}
</div>`;
</div>`;
}
}
@@ -576,27 +551,26 @@ function _aLevelCard(f) {
@@ -576,27 +551,26 @@ function _aLevelCard(f) {
}
}
async function _aRenderFighter(root, username) {
async function _aRenderFighter(root, username) {
- root.innerHTML = _aSpinner('Pulling the fighter file…');
+ root.innerHTML = _aSpinner('Pulling their lines…');
const f = await api(`/arena/fighters/${encodeURIComponent(username)}`);
const f = await api(`/arena/fighters/${encodeURIComponent(username)}`);
if (f.not_on_roster) {
if (f.not_on_roster) {
root.innerHTML = `<div class="arena-empty"><i class="fa-solid fa-microphone-slash"></i><p><strong>${_aEsc(f.user.display_name)}</strong> is not on the roster — ${_aEsc(f.reason)}.</p>${_aA('/arena', 'Back to the cams', 'btn')}</div>`;
root.innerHTML = `<div class="arena-empty"><i class="fa-solid fa-microphone-slash"></i><p><strong>${_aEsc(f.user.display_name)}</strong> is not on the roster — ${_aEsc(f.reason)}.</p>${_aA('/arena', 'Back to the cams', 'btn')}</div>`;
return;
return;
}
}
- const p = f.persona, color = f.user.profile_color || '#8b5cf6';
- const beefs = f.beefs || [], rivalries = f.rivalries || [], moments = f.moments || [];
+ const color = f.user.profile_color || '#8b5cf6';
+ const name = f.user.display_name || f.user.username;
+ const beefs = f.beefs || [], rivalries = f.rivalries || [], moments = f.moments || [], best = f.best_lines || [];
root.innerHTML = `
root.innerHTML = `
<div class="arena-back">${_aA('/arena', '<i class="fa-solid fa-arrow-left"></i> Battle Cam')} ${f.live ? _aA(_aConsoleLink(f.user), '<i class="fa-solid fa-ear-listen"></i> Listen in live', '') : ''}</div>
<div class="arena-back">${_aA('/arena', '<i class="fa-solid fa-arrow-left"></i> Battle Cam')} ${f.live ? _aA(_aConsoleLink(f.user), '<i class="fa-solid fa-ear-listen"></i> Listen in live', '') : ''}</div>
<div class="arena-profile" style="--fc:${_aEsc(color)}">
<div class="arena-profile" style="--fc:${_aEsc(color)}">
<div class="arena-profile-portrait" id="arena-profile-portrait">
<div class="arena-profile-portrait" id="arena-profile-portrait">
${_aPortrait(f, 'lg')}
${_aPortrait(f, 'lg')}
- ${f.image_pending ? '<div class="arena-portrait-pending"><i class="fa-solid fa-wand-magic-sparkles fa-fade"></i> painting portrait…</div>' : ''}
<div class="arena-profile-rank">#${f.rank} <small>of ${f.roster_size}</small></div>
<div class="arena-profile-rank">#${f.rank} <small>of ${f.roster_size}</small></div>
</div>
</div>
<div class="arena-profile-main">
<div class="arena-profile-main">
<div class="arena-profile-name">
<div class="arena-profile-name">
- <h1>${_aEsc(p.fighter_name)} ${f.live ? '<span class="arena-live-pill">LIVE</span>' : ''}</h1>
- <p class="arena-title">${_aEsc(p.title)}</p>
- <p class="arena-handle">${_aA(_aChannelLink(f.user), `${_aEsc(f.user.display_name)} · @${_aEsc(f.user.username)}`)} · ${_aEsc(p.class)} · ${_aEsc(p.element)}</p>
+ <h1>${_aEsc(name)} ${f.live ? '<span class="arena-live-pill">LIVE</span>' : ''}</h1>
+ <p class="arena-handle">${_aA(_aChannelLink(f.user), `@${_aEsc(f.user.username)}`)} · ${f.record.wins}W–${f.record.losses}L · ${f.mic ? `${f.mic.moments} judged lines in 30 days` : ''}</p>
</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>
@@ -604,36 +578,19 @@ async function _aRenderFighter(root, username) {
@@ -604,36 +578,19 @@ async function _aRenderFighter(root, username) {
<div class="arena-record arena-record-lg" title="beef record">${f.record.wins}W – ${f.record.losses}L</div>
<div class="arena-record arena-record-lg" title="beef record">${f.record.wins}W – ${f.record.losses}L</div>
</div>
</div>
${_aLevelCard(f)}
${_aLevelCard(f)}
- <div class="arena-profile-stats is-bars">
- <div class="arena-bars-head"><b>The mic stats</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>
+ ${best.length ? `<section class="arena-receipts-section"><h3><i class="fa-solid fa-fire"></i> Best shit talk <small>their highest-scored lines</small></h3><div class="arena-mic-feed-list">${best.map(_aMomentRow).join('')}</div></section>` : ''}
<section class="arena-receipts-section">
<section class="arena-receipts-section">
<h3><i class="fa-solid fa-satellite-dish"></i> On record <small>their judged lines, newest first</small></h3>
<h3><i class="fa-solid fa-satellite-dish"></i> On record <small>their judged lines, newest first</small></h3>
${moments.length ? `<div class="arena-mic-feed-list">${moments.map(_aMomentRow).join('')}</div>` : '<p class="arena-note">Nothing judged yet. The ears are waiting.</p>'}
${moments.length ? `<div class="arena-mic-feed-list">${moments.map(_aMomentRow).join('')}</div>` : '<p class="arena-note">Nothing judged yet. The ears are waiting.</p>'}
</section>
</section>
+ <div class="arena-profile-stats is-bars">
+ <div class="arena-bars-head"><b>The mic stats</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(_arenaRoster?.stat_meta?.[k]?.desc || 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-profile-custom">
<div class="arena-profile-custom">
- <div>${_aCustomRadar(f, color, 280)}<div class="arena-mini-record">${(p.custom_stats || []).length ? 'their characteristics — AI-read from what they say on mic' : 'the seven mic stats (AI characteristics appear once the persona is generated)'}</div></div>
- ${(p.custom_stats || []).length ? _aCustomQuips(f) : ''}
+ <div>${_aCustomRadar(f, color, 280, { objective: true })}<div class="arena-mini-record">the seven mic stats</div></div>
</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">Talks 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 enough mic time.</p>' : ''}
- </section>
- <section class="arena-taunts">
- <h3><i class="fa-solid fa-comment-dots"></i> Ragebait <small>in their own 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)}
${_aVoiceCard(f)}
</div>
</div>
</div>
</div>
@@ -664,48 +621,6 @@ async function _aRenderFighter(root, username) {
@@ -664,48 +621,6 @@ async function _aRenderFighter(root, username) {
box.querySelector('.arena-stat-detail-close').addEventListener('click', () => { box.innerHTML = ''; box.dataset.stat = ''; });
box.querySelector('.arena-stat-detail-close').addEventListener('click', () => { box.innerHTML = ''; box.dataset.stat = ''; });
} catch (err) { box.innerHTML = `<div class="arena-stat-detail">${_aEsc(err?.message || 'Failed')}</div>`; }
} catch (err) { box.innerHTML = `<div class="arena-stat-detail">${_aEsc(err?.message || 'Failed')}</div>`; }
}));
}));
-
- const portrait = root.querySelector('.arena-portrait-lg');
- if (portrait && f.image_url) portrait.addEventListener('click', () => _aLightbox(f));
-
- if (f.image_pending || (!f.image_url && f.image_generation === 'ai')) {
- let tries = 0;
- _arenaImagePoll = setInterval(async () => {
- if (++tries > 20 || currentPage !== 'arena') return _aStopTimers();
- try {
- const fresh = await api(`/arena/fighters/${encodeURIComponent(username)}?generate=0`);
- if (fresh.image_url) {
- const holder = document.getElementById('arena-profile-portrait');
- if (holder) {
- const old = holder.querySelector('.arena-portrait'); if (old) old.outerHTML = _aPortrait(fresh, 'lg');
- holder.querySelector('.arena-portrait-pending')?.remove();
- holder.querySelector('.arena-portrait-lg')?.addEventListener('click', () => _aLightbox(fresh));
- }
- clearInterval(_arenaImagePoll); _arenaImagePoll = null;
- }
- } catch { /* */ }
- }, 6000);
- }
-}
-
-function _aLightbox(f) {
- const box = document.createElement('div');
- box.className = 'arena-lightbox';
- box.innerHTML = `
- <button class="arena-lightbox-close" aria-label="Close">×</button>
- <div class="arena-lightbox-inner">
- <img src="${_aEsc(f.image_url)}" alt="">
- <div class="arena-lightbox-text">
- <h3>${_aEsc(f.persona.fighter_name)}</h3>
- <p>${_aEsc(f.persona.title)}</p>
- <p>How this was painted: the AI wrote the persona from what they say on mic, then an image model restyled real frames from their streams — their setup, gear, lighting, silhouette — into a character-select caricature.${f.image_model ? ` Model: <code>${_aEsc(f.image_model)}</code>.` : ''}</p>
- ${f.image_prompt ? `<div class="arena-lightbox-prompt">${_aEsc(f.image_prompt)}</div>` : ''}
- </div>
- </div>`;
- const close = () => box.remove();
- box.addEventListener('click', (e) => { if (e.target === box || e.target.closest('.arena-lightbox-close')) close(); });
- document.addEventListener('keydown', function onKey(e) { if (e.key === 'Escape') { close(); document.removeEventListener('keydown', onKey); } });
- document.body.appendChild(box);
}
}
function _aLevelUp(level) {
function _aLevelUp(level) {
@@ -21,13 +21,13 @@
@@ -21,13 +21,13 @@
(function () {
(function () {
'use strict';
'use strict';
- const PROMO_VERSION = 'rs-exclusive-v3'; // bump to re-show the takeover to everyone
+ const PROMO_VERSION = 'rs-desperate-v5'; // bump to re-show the takeover to everyone
const SEEN_KEY = 'ov_rs_promo_seen';
const SEEN_KEY = 'ov_rs_promo_seen';
const TICKER_KEY = 'ov_rs_ticker_hidden'; // sessionStorage — comes back next visit
const TICKER_KEY = 'ov_rs_ticker_hidden'; // sessionStorage — comes back next visit
const REDUCED = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const REDUCED = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const COARSE = window.matchMedia && window.matchMedia('(pointer: coarse)').matches;
const COARSE = window.matchMedia && window.matchMedia('(pointer: coarse)').matches;
- const cfg = { enabled: true, amount: 50, amountMin: 25, referral: 10, github: 'https://github.com/OpenVibers/OpenVibe.Live', owner: 'admin', discord: 'https://discord.gg/M6MuRUaeJj' };
+ const cfg = { enabled: true, amount: 50, amountMin: 25, referral: 10, vip: null, github: 'https://github.com/OpenVibers/OpenVibe.Live', owner: 'admin', discord: 'https://discord.gg/M6MuRUaeJj' };
const STREAM_ALERT_KEY = 'ov_rs_stream_alert_min'; // sessionStorage — collapsed (never hidden) state
const STREAM_ALERT_KEY = 'ov_rs_stream_alert_min'; // sessionStorage — collapsed (never hidden) state
let modalEl = null, takeoverEl = null, tickerEl = null;
let modalEl = null, takeoverEl = null, tickerEl = null;
let rainStop = null;
let rainStop = null;
@@ -61,10 +61,36 @@
@@ -61,10 +61,36 @@
const chipsHtml = () => `
const chipsHtml = () => `
<span class="rs-chip rs-chip-zelle"><span class="rs-zelle-mark">Z</span> Zelle</span>
<span class="rs-chip rs-chip-zelle"><span class="rs-zelle-mark">Z</span> Zelle</span>
<span class="rs-chip rs-chip-paypal"><i class="fa-brands fa-paypal"></i> PayPal</span>
<span class="rs-chip rs-chip-paypal"><i class="fa-brands fa-paypal"></i> PayPal</span>
+ <span class="rs-chip rs-chip-crypto"><i class="fa-brands fa-bitcoin"></i> Crypto</span>
<span class="rs-chip rs-chip-cash"><i class="fa-solid fa-money-bill-wave"></i> Real cash, no coins</span>
<span class="rs-chip rs-chip-cash"><i class="fa-solid fa-money-bill-wave"></i> Real cash, no coins</span>
<span class="rs-chip rs-chip-build"><i class="fa-solid fa-hammer"></i> Features built on request</span>
<span class="rs-chip rs-chip-build"><i class="fa-solid fa-hammer"></i> Features built on request</span>
<span class="rs-chip rs-chip-oss" title="Every line of this site is public — trust what you can read"><i class="fa-brands fa-github"></i> 100% open source</span>`;
<span class="rs-chip rs-chip-oss" title="Every line of this site is public — trust what you can read"><i class="fa-brands fa-github"></i> 100% open source</span>`;
+ // "One stream, everywhere": stream from here, mirror anywhere — with the toys that only exist here.
+ const restreamHtml = (compact = false) => `
+ <div class="rs-flow2${compact ? ' is-compact' : ''}" aria-label="Stream from OpenVibe.Live and restream everywhere">
+ <div class="rs-flow2-src"><span class="rs-flow2-node"><i class="fa-solid fa-video"></i> your cam</span><span class="rs-flow2-node"><i class="fa-solid fa-robot"></i> your robot</span></div>
+ <div class="rs-flow2-wire" aria-hidden="true"><span class="rs-packet"></span><span class="rs-packet"></span></div>
+ <div class="rs-flow2-hub"><b><i class="fa-solid fa-circle-nodes"></i> OpenVibe.Live</b><small>WebRTC <1s latency · emotes · sound commands · robot controls · VODs · clips · AI moments</small></div>
+ <div class="rs-flow2-wire rs-flow2-fan" aria-hidden="true"><span class="rs-packet"></span><span class="rs-packet"></span><span class="rs-packet"></span></div>
+ <div class="rs-flow2-out">
+ <span class="rs-flow2-node rs-twitch"><i class="fa-brands fa-twitch"></i> Twitch</span>
+ <span class="rs-flow2-node rs-yt"><i class="fa-brands fa-youtube"></i> YouTube</span>
+ <span class="rs-flow2-node rs-kick"><i class="fa-solid fa-bolt"></i> Kick</span>
+ <span class="rs-flow2-node rs-rs"><i class="fa-solid fa-robot"></i> RobotStreamer</span>
+ <span class="rs-flow2-node"><i class="fa-solid fa-tower-broadcast"></i> any RTMP</span>
+ </div>
+ ${compact ? '' : `<div class="rs-features">
+ <span class="rs-feat" style="--i:0"><i class="fa-solid fa-gauge-high"></i> Sub-second latency</span>
+ <span class="rs-feat" style="--i:1"><i class="fa-solid fa-face-grin-squint-tears"></i> 7TV / BTTV / FFZ + custom emotes</span>
+ <span class="rs-feat" style="--i:2"><i class="fa-solid fa-volume-high"></i> Sound commands & soundboard</span>
+ <span class="rs-feat" style="--i:3"><i class="fa-solid fa-gamepad"></i> Robot & hardware controls</span>
+ <span class="rs-feat" style="--i:4"><i class="fa-solid fa-satellite-dish"></i> Free restream, all at once</span>
+ <span class="rs-feat" style="--i:5"><i class="fa-solid fa-scissors"></i> VODs, clips, AI moments</span>
+ <span class="rs-feat" style="--i:6"><i class="fa-solid fa-comments"></i> One chat from every platform</span>
+ </div>`}
+ </div>`;
+
const coinHtml = () => `
const coinHtml = () => `
<div class="rs-coin-wrap" aria-hidden="true">
<div class="rs-coin-wrap" aria-hidden="true">
<div class="rs-coin">
<div class="rs-coin">
@@ -80,8 +106,10 @@
@@ -80,8 +106,10 @@
function mountTicker() {
function mountTicker() {
if (tickerEl || store.get(TICKER_KEY, true) === '1') return;
if (tickerEl || store.get(TICKER_KEY, true) === '1') return;
const copy = `
const copy = `
- <span class="rs-ticker-item"><i class="fa-solid fa-robot"></i> RobotStreamer streamers: quit RS for good, stream ONLY on OpenVibe.Live, get <span class="rs-money">${money()}</span> <span class="rs-ticker-sep">•</span> Zelle or PayPal <span class="rs-ticker-sep">•</span> <span class="rs-ticker-cta">Claim yours</span></span>
- <span class="rs-ticker-item"><i class="fa-solid fa-heart" style="color:#f472b6"></i> Converters get catered to — tell me what you want built and I build it <span class="rs-ticker-sep">•</span> paid out of pocket by the one guy who runs this place</span>
+ <span class="rs-ticker-item"><i class="fa-solid fa-robot"></i> RobotStreamer streamers: I AM BEGGING. Leave Rick's trash site for good, stream ONLY here, get <span class="rs-money">${money()}</span> <span class="rs-ticker-sep">•</span> Zelle, PayPal or crypto <span class="rs-ticker-sep">•</span> <span class="rs-ticker-cta">Claim yours</span></span>
+ <span class="rs-ticker-item"><i class="fa-solid fa-heart" style="color:#f472b6"></i> I am extremely desperate and I will do ANYTHING to get you here — name the feature, I build it <span class="rs-ticker-sep">•</span> OUT OF POCKET. No investors. Just a wallet and feelings.</span>
+ <span class="rs-ticker-item"><i class="fa-solid fa-face-sad-cry" style="color:#fde68a"></i> I genuinely care about you streaming on my site. That is the whole business plan. Please. <span class="rs-ticker-sep">•</span> <span class="rs-ticker-cta">Take my money</span></span>
+ <span class="rs-ticker-item"><i class="fa-solid fa-satellite-dish" style="color:#7dd3fc"></i> Stream here, restream to Twitch, YouTube, Kick, RobotStreamer & any RTMP at once <span class="rs-ticker-sep">•</span> emotes, sound commands, robot controls, <1s latency</span>
<span class="rs-ticker-item"><i class="fa-brands fa-github"></i> Open source beats closed source: every line of this site is on GitHub, theirs isn't <span class="rs-ticker-sep">•</span> trust what you can read</span>
<span class="rs-ticker-item"><i class="fa-brands fa-github"></i> Open source beats closed source: every line of this site is on GitHub, theirs isn't <span class="rs-ticker-sep">•</span> trust what you can read</span>
<span class="rs-ticker-item"><i class="fa-solid fa-people-arrows" style="color:#86efac"></i> Already here? Bring a RobotStreamer streamer over and you get <span class="rs-money">${referral()}</span> too <span class="rs-ticker-sep">•</span> <span class="rs-ticker-cta">How it works</span></span>`;
<span class="rs-ticker-item"><i class="fa-solid fa-people-arrows" style="color:#86efac"></i> Already here? Bring a RobotStreamer streamer over and you get <span class="rs-money">${referral()}</span> too <span class="rs-ticker-sep">•</span> <span class="rs-ticker-cta">How it works</span></span>`;
tickerEl = document.createElement('div');
tickerEl = document.createElement('div');
@@ -115,7 +143,7 @@
@@ -115,7 +143,7 @@
2. TAKEOVER (money rain canvas + count-up)
2. TAKEOVER (money rain canvas + count-up)
───────────────────────────────────────────────────────────── */
───────────────────────────────────────────────────────────── */
function buildTakeover() {
function buildTakeover() {
- const titleWords = ['Leave', 'RobotStreamer', 'for', 'good.', 'Get', 'paid.'];
+ const titleWords = ['I', 'am', 'BEGGING', 'you.', 'Leave', "Rick's", 'trash', 'site.'];
const el = document.createElement('div');
const el = document.createElement('div');
el.className = 'rs-takeover';
el.className = 'rs-takeover';
el.setAttribute('role', 'dialog');
el.setAttribute('role', 'dialog');
@@ -130,7 +158,8 @@
@@ -130,7 +158,8 @@
<div class="rs-tk-eyebrow"><i class="fa-solid fa-robot"></i> robotstreamer.com users</div>
<div class="rs-tk-eyebrow"><i class="fa-solid fa-robot"></i> robotstreamer.com users</div>
<h1 class="rs-tk-title">${titleWords.map((w, i) => `<span class="rs-w" style="animation-delay:${0.25 + i * 0.09}s">${esc(w)}</span>`).join(' ')}</h1>
<h1 class="rs-tk-title">${titleWords.map((w, i) => `<span class="rs-w" style="animation-delay:${0.25 + i * 0.09}s">${esc(w)}</span>`).join(' ')}</h1>
<div class="rs-tk-amount" aria-label="${money()}"><span class="rs-money" data-countup="${cfg.amount}">${cfg.amountMin < cfg.amount ? `$${cfg.amountMin}–$0` : '$0'}</span></div>
<div class="rs-tk-amount" aria-label="${money()}"><span class="rs-money" data-countup="${cfg.amount}">${cfg.amountMin < cfg.amount ? `$${cfg.amountMin}–$0` : '$0'}</span></div>
- <p class="rs-tk-sub">Never stream on RobotStreamer again. Stream <b>only</b> on <b>OpenVibe.Live</b>. I send you <b>${money()}</b> — Zelle or PayPal, your pick.<br>And I cater to converters: <b>ask for a feature, I build it.</b> Not a corporation — one guy who wants you here.</p>
+ <p class="rs-tk-sub">I am <b>extremely desperate</b>. I will do <b>anything</b> to get you off RobotStreamer and onto <b>OpenVibe.Live</b>. Never stream there again, stream <b>only</b> here, and I personally send you <b>${money()}</b> — Zelle, PayPal or crypto, your pick. This is <b>out of my own pocket</b>. There are no investors. There is a wallet, and there are feelings.<br>Want a feature? <b>Name it. I build it.</b> Want me to grovel? This is the groveling. I genuinely care about you streaming here — that is the entire business plan. Not a corporation — one unhinged guy who wants you here more than Rick ever will.</p>
+ <div class="rs-tk-tour"><div class="rs-tk-tour-head"><i class="fa-solid fa-satellite-dish"></i> Stream here. Be everywhere. Keep the toys.</div>${restreamHtml(false)}</div>
<p class="rs-tk-trust"><i class="fa-brands fa-github"></i> <b>Trust what you can read.</b> Every line of this site is ${ghLink('public on GitHub')}. Theirs is closed source — you'll never see what it does. An open alternative is the only kind worth trusting.<br><i class="fa-solid fa-people-arrows"></i> <b>Already one of us?</b> Bring a RobotStreamer streamer over and you get <b>${referral()}</b> too.</p>
<p class="rs-tk-trust"><i class="fa-brands fa-github"></i> <b>Trust what you can read.</b> Every line of this site is ${ghLink('public on GitHub')}. Theirs is closed source — you'll never see what it does. An open alternative is the only kind worth trusting.<br><i class="fa-solid fa-people-arrows"></i> <b>Already one of us?</b> Bring a RobotStreamer streamer over and you get <b>${referral()}</b> too.</p>
<div class="rs-chips">${chipsHtml()}</div>
<div class="rs-chips">${chipsHtml()}</div>
<div class="rs-flow" aria-hidden="true">
<div class="rs-flow" aria-hidden="true">
@@ -139,8 +168,8 @@
@@ -139,8 +168,8 @@
<span class="rs-flow-node rs-to"><i class="fa-solid fa-circle-nodes"></i> OpenVibe.Live</span>
<span class="rs-flow-node rs-to"><i class="fa-solid fa-circle-nodes"></i> OpenVibe.Live</span>
</div>
</div>
<div class="rs-tk-actions">
<div class="rs-tk-actions">
- <button class="rs-btn rs-btn-gold" type="button" data-act="claim"><i class="fa-solid fa-sack-dollar"></i> Go exclusive — claim ${money()}</button>
- <button class="rs-btn rs-btn-ghost" type="button" data-act="later"><i class="fa-solid fa-compass"></i> Show me around first</button>
+ <button class="rs-btn rs-btn-gold" type="button" data-act="claim"><i class="fa-solid fa-sack-dollar"></i> Fine, I'll take the ${money()}</button>
+ <button class="rs-btn rs-btn-ghost" type="button" data-act="later"><i class="fa-solid fa-compass"></i> Let me look around first</button>
</div>
</div>
<p class="rs-tk-foot"><i class="fa-solid fa-star"></i> You'll only see this splash once — the bar at the top brings you back here anytime.</p>
<p class="rs-tk-foot"><i class="fa-solid fa-star"></i> You'll only see this splash once — the bar at the top brings you back here anytime.</p>
</div>`;
</div>`;
@@ -265,9 +294,10 @@
@@ -265,9 +294,10 @@
${coinHtml()}
${coinHtml()}
<div class="rs-card-body">
<div class="rs-card-body">
<div class="rs-card-kicker"><i class="fa-solid fa-robot"></i> RobotStreamer switch bonus</div>
<div class="rs-card-kicker"><i class="fa-solid fa-robot"></i> RobotStreamer switch bonus</div>
- <h3 class="rs-card-title">Coming from RobotStreamer? Go exclusive here and get <span class="rs-money">${money()}</span></h3>
- <p class="rs-card-sub">Never stream on RobotStreamer again, stream only on OpenVibe.Live. Zelle or PayPal, paid personally by the guy who runs this place — and every converter gets catered to: ask for a feature, I build it. Every line of this site is ${ghLink('open source on GitHub')}; theirs is closed. Already here? Bring a RobotStreamer streamer over and you get <b>${referral()}</b>.</p>
+ <h3 class="rs-card-title">RobotStreamer people: I am begging. Leave Rick's site, stream only here, get <span class="rs-money">${money()}</span></h3>
+ <p class="rs-card-sub">Never stream on RobotStreamer again, stream only on OpenVibe.Live. Zelle, PayPal or crypto, out of my own pocket, because I genuinely care about you streaming here (that is the whole business plan). Every converter gets catered to: ask for a feature, I build it. Every line of this site is ${ghLink('open source on GitHub')}; theirs is closed. Already here? Bring a RobotStreamer streamer over and you get <b>${referral()}</b>.</p>
<div class="rs-card-chips">${chipsHtml()}</div>
<div class="rs-card-chips">${chipsHtml()}</div>
+ ${restreamHtml(true)}
</div>
</div>
<div class="rs-card-action">
<div class="rs-card-action">
<button class="rs-btn rs-btn-gold" type="button" onclick="rsPromoOpenClaim()"><i class="fa-solid fa-sack-dollar"></i> Claim ${money()}</button>
<button class="rs-btn rs-btn-gold" type="button" onclick="rsPromoOpenClaim()"><i class="fa-solid fa-sack-dollar"></i> Claim ${money()}</button>
@@ -277,6 +307,37 @@
@@ -277,6 +307,37 @@
</div>`;
</div>`;
if (!REDUCED && !COARSE) attachTilt(mount.querySelector('.rs-card-border'), mount.querySelector('#rs-hero-card'));
if (!REDUCED && !COARSE) attachTilt(mount.querySelector('.rs-card-border'), mount.querySelector('#rs-hero-card'));
}
}
+ // ── VIP recruiter card (home page, under the bounty card): the starfish bounty board ──
+ function mountVipCard() {
+ const mount = document.getElementById('rs-hero-mount');
+ if (!mount || !cfg.vip || mount.querySelector('.rs-vip')) return;
+ const v = cfg.vip;
+ const me = window.currentUser && String(window.currentUser.username || '').toLowerCase() === String(v.username).toLowerCase();
+ const name = esc(v.username);
+ const el = document.createElement('div');
+ el.className = 'rs-vip';
+ el.innerHTML = `
+ <div class="rs-vip-bubbles" aria-hidden="true"><span></span><span></span><span></span><span></span><span></span><span></span></div>
+ <div class="rs-vip-star" aria-hidden="true">
+ <svg viewBox="0 0 120 120" width="120" height="120">
+ <defs><radialGradient id="rsVipG" cx="40%" cy="35%" r="70%"><stop offset="0%" stop-color="#ffb3d1"/><stop offset="100%" stop-color="#ff5fa2"/></radialGradient></defs>
+ <path class="rs-vip-body" d="M60 6 L74 42 L112 44 L82 68 L93 106 L60 84 L27 106 L38 68 L8 44 L46 42 Z" fill="url(#rsVipG)" stroke="#c2185b" stroke-width="3" stroke-linejoin="round"/>
+ <circle cx="48" cy="52" r="7" fill="#fff"/><circle cx="72" cy="52" r="7" fill="#fff"/>
+ <circle class="rs-vip-pupil" cx="50" cy="53" r="3" fill="#222"/><circle class="rs-vip-pupil" cx="74" cy="53" r="3" fill="#222"/>
+ <path d="M46 68 Q60 82 74 68" stroke="#7a1140" stroke-width="3.5" fill="none" stroke-linecap="round"/>
+ <circle cx="36" cy="64" r="3" fill="#ff8ac0" opacity="0.8"/><circle cx="84" cy="64" r="3" fill="#ff8ac0" opacity="0.8"/>
+ </svg>
+ <span class="rs-vip-sparkle">✦</span><span class="rs-vip-sparkle">✦</span><span class="rs-vip-sparkle">✦</span>
+ </div>
+ <div class="rs-vip-body-text">
+ <div class="rs-vip-kicker"><i class="fa-solid fa-star"></i> ${name}'s bounty board</div>
+ <h3 class="rs-vip-title">${me ? `That's you, ${name}. Go get 'em.` : `${name} gets <span class="rs-money">$${Number(v.referral)}</span> per RobotStreamer streamer he brings over`}</h3>
+ <p class="rs-vip-sub">${me ? `<span class="rs-money">$${Number(v.referral)}</span> every time a RobotStreamer streamer you brought in claims the switch bonus and names you. Everyone else gets $${cfg.referral}. You're the recruiter. The rock is yours.` : `He's going to be godlike at this, so his rate is double everyone else's ($${cfg.referral} for the rest of us). Converts name him in the claim message, he gets paid. Simple.`}</p>
+ <div class="rs-vip-chips"><span class="rs-chip rs-chip-cash"><i class="fa-solid fa-money-bill-wave"></i> $${Number(v.referral)} per conversion</span><span class="rs-chip rs-chip-build"><i class="fa-solid fa-people-arrows"></i> everyone else: $${cfg.referral}</span>${me ? '<span class="rs-chip rs-chip-oss"><i class="fa-solid fa-crown"></i> VIP recruiter</span>' : ''}</div>
+ </div>`;
+ mount.appendChild(el);
+ }
+
function attachTilt(zone, card) {
function attachTilt(zone, card) {
if (!zone || !card) return;
if (!zone || !card) return;
let raf = 0;
let raf = 0;
@@ -330,9 +391,10 @@
@@ -330,9 +391,10 @@
<div class="rs-sa-body">
<div class="rs-sa-body">
<div class="rs-sa-siren" aria-hidden="true"><i class="fa-solid fa-robot"></i></div>
<div class="rs-sa-siren" aria-hidden="true"><i class="fa-solid fa-robot"></i></div>
<div class="rs-sa-text">
<div class="rs-sa-text">
- <div class="rs-sa-head">🚨 RobotStreamer streamers: get <span class="rs-money">${money()}</span> to switch — for good</div>
- <div class="rs-sa-sub">Never stream on RobotStreamer again, stream <b>only</b> on OpenVibe.Live, and I pay you <b>${money()}</b> by Zelle or PayPal. Converters get catered to: <b>ask for a feature and I build it.</b> Every line of this site is ${ghLink('open source')} — theirs is closed. Bring a RobotStreamer streamer over and <b>you</b> get ${referral()} too.</div>
+ <div class="rs-sa-head">🚨 RobotStreamer streamers: I am DESPERATE. <span class="rs-money">${money()}</span> to leave Rick's trash site for good</div>
+ <div class="rs-sa-sub">Never stream on RobotStreamer again, stream <b>only</b> on OpenVibe.Live, and I pay you <b>${money()}</b> by Zelle, PayPal or crypto — out of my own pocket, because I actually care about you streaming here. Converters get catered to: <b>ask for a feature and I build it.</b> Every line of this site is ${ghLink('open source')} — theirs is closed. Bring a RobotStreamer streamer over and <b>you</b> get ${referral()} too.</div>
<div class="rs-sa-chips">${chipsHtml()}</div>
<div class="rs-sa-chips">${chipsHtml()}</div>
+ ${restreamHtml(true)}
</div>
</div>
<div class="rs-sa-actions">
<div class="rs-sa-actions">
<button class="rs-btn rs-btn-gold" type="button" onclick="rsPromoOpenClaim()"><i class="fa-solid fa-sack-dollar"></i> Claim ${money()}</button>
<button class="rs-btn rs-btn-gold" type="button" onclick="rsPromoOpenClaim()"><i class="fa-solid fa-sack-dollar"></i> Claim ${money()}</button>
@@ -340,7 +402,7 @@
@@ -340,7 +402,7 @@
</div>
</div>
</div>
</div>
<div class="rs-sa-mini" onclick="rsPromoToggleStreamAlert()">
<div class="rs-sa-mini" onclick="rsPromoToggleStreamAlert()">
- <i class="fa-solid fa-robot"></i> <span>RobotStreamer streamers: <span class="rs-money">${money()}</span> to switch for good — Zelle / PayPal + features built for you</span>
+ <i class="fa-solid fa-robot"></i> <span>RobotStreamer streamers: I'm begging — <span class="rs-money">${money()}</span> (Zelle / PayPal / crypto) to leave Rick's site for good · restream everywhere from here</span>
<span class="rs-ticker-cta" onclick="event.stopPropagation(); rsPromoOpenClaim()">Claim</span>
<span class="rs-ticker-cta" onclick="event.stopPropagation(); rsPromoOpenClaim()">Claim</span>
</div>
</div>
<button class="rs-sa-toggle" type="button" onclick="rsPromoToggleStreamAlert()" title="Collapse / expand" aria-label="Collapse or expand the offer"><i class="fa-solid fa-chevron-up"></i></button>
<button class="rs-sa-toggle" type="button" onclick="rsPromoToggleStreamAlert()" title="Collapse / expand" aria-label="Collapse or expand the offer"><i class="fa-solid fa-chevron-up"></i></button>
@@ -357,9 +419,11 @@
@@ -357,9 +419,11 @@
/* ─────────────────────────────────────────────────────────────
/* ─────────────────────────────────────────────────────────────
4. CLAIM MODAL
4. CLAIM MODAL
───────────────────────────────────────────────────────────── */
───────────────────────────────────────────────────────────── */
+ const METHOD_NAME = { zelle: 'Zelle', paypal: 'PayPal', crypto: 'Crypto' };
function claimMessage(method) {
function claimMessage(method) {
- const via = method === 'paypal' ? 'PayPal' : 'Zelle';
- return `Hey! I'm coming over from RobotStreamer (RS username: ______). I'm done streaming on RS for good — OpenVibe.Live only from now on. I'd like to claim the ${money()} switch bonus via ${via}. My ${via} is: ______ · Referred by (OpenVibe user, if any): ______ 🤖💸`;
+ const via = METHOD_NAME[method] || 'Zelle';
+ const detail = method === 'crypto' ? 'My wallet (coin + network + address): ______' : `My ${via} is: ______`;
+ return `Hey! I'm coming over from RobotStreamer (RS username: ______). I'm done streaming on RS for good — OpenVibe.Live only from now on. I'd like to claim the ${money()} switch bonus via ${via}. ${detail} · Referred by (OpenVibe user, if any): ______ 🤖💸`;
}
}
function openClaim() {
function openClaim() {
if (modalEl) return;
if (modalEl) return;
@@ -378,24 +442,25 @@
@@ -378,24 +442,25 @@
${coinHtml()}
${coinHtml()}
<div>
<div>
<h3>Claim your <span class="rs-money">${money()}</span></h3>
<h3>Claim your <span class="rs-money">${money()}</span></h3>
- <p>The deal: you never stream on RobotStreamer again and stream only here. I pay you personally — and I build what you ask for.</p>
+ <p>I am desperate and not hiding it. The deal: you never stream on Rick's site again and stream only here. I pay you personally — Zelle, PayPal or crypto, out of my own pocket — I build whatever you ask for, and you keep restreaming everywhere you like, from here. I genuinely care about you streaming on my site. That is the plan. That is the whole plan.</p>
</div>
</div>
</div>
</div>
- <div class="rs-toggle" data-method="zelle" role="group" aria-label="How do you want to get paid?">
+ <div class="rs-toggle rs-toggle-3" data-method="zelle" role="group" aria-label="How do you want to get paid?">
<div class="rs-toggle-thumb" aria-hidden="true"></div>
<div class="rs-toggle-thumb" aria-hidden="true"></div>
<button type="button" data-method="zelle" aria-pressed="true"><span class="rs-zelle-mark">Z</span> Zelle</button>
<button type="button" data-method="zelle" aria-pressed="true"><span class="rs-zelle-mark">Z</span> Zelle</button>
<button type="button" data-method="paypal" aria-pressed="false"><i class="fa-brands fa-paypal"></i> PayPal</button>
<button type="button" data-method="paypal" aria-pressed="false"><i class="fa-brands fa-paypal"></i> PayPal</button>
+ <button type="button" data-method="crypto" aria-pressed="false"><i class="fa-brands fa-bitcoin"></i> Crypto</button>
</div>
</div>
<ol class="rs-steps">
<ol class="rs-steps">
<li class="rs-step ${loggedIn ? 'done' : ''}"><span class="rs-step-num"></span><div><strong>Sign in — it's free</strong><span>One OpenVibe account works across the whole network. ${loggedIn ? 'You\'re already in. ✔' : 'Takes about ten seconds.'}</span></div></li>
<li class="rs-step ${loggedIn ? 'done' : ''}"><span class="rs-step-num"></span><div><strong>Sign in — it's free</strong><span>One OpenVibe account works across the whole network. ${loggedIn ? 'You\'re already in. ✔' : 'Takes about ten seconds.'}</span></div></li>
- <li class="rs-step"><span class="rs-step-num"></span><div><strong>Go exclusive — retire your RobotStreamer stream</strong><span>Set up here instead: browser (WebRTC), OBS/RTMP, or your robot's existing pipeline — <a href="/broadcast" onclick="return handleLinkClick(event, '/broadcast')">Go Live</a> walks you through it. From now on you stream on OpenVibe.Live only.</span></div></li>
+ <li class="rs-step"><span class="rs-step-num"></span><div><strong>Go exclusive — your home is here now</strong><span>Set up on <a href="/broadcast" onclick="return handleLinkClick(event, '/broadcast')">Go Live</a>: browser (WebRTC), OBS/RTMP, or your robot's existing pipeline. Then flip on <b>restream</b> and mirror your OpenVibe stream to Twitch, YouTube, Kick, any RTMP — even RobotStreamer — while your chat, emotes, sound commands and robot controls all live here.</span>${restreamHtml(true)}</div></li>
<li class="rs-step"><span class="rs-step-num"></span><div><strong>Message me</strong><span>Send your RobotStreamer username plus your <b class="rs-method-word">Zelle</b> info — in-site DM to <b>@${owner}</b> or on Discord. Copy the message below to make it painless.</span></div></li>
<li class="rs-step"><span class="rs-step-num"></span><div><strong>Message me</strong><span>Send your RobotStreamer username plus your <b class="rs-method-word">Zelle</b> info — in-site DM to <b>@${owner}</b> or on Discord. Copy the message below to make it painless.</span></div></li>
<li class="rs-step"><span class="rs-step-num"></span><div><strong>Get paid — and get catered to</strong><span>I send the <b>${money()}</b> by hand. Then tell me what your stream needs: converters get features built on request.</span></div></li>
<li class="rs-step"><span class="rs-step-num"></span><div><strong>Get paid — and get catered to</strong><span>I send the <b>${money()}</b> by hand. Then tell me what your stream needs: converters get features built on request.</span></div></li>
</ol>
</ol>
<div class="rs-msg"><span class="rs-msg-text">${esc(claimMessage('zelle'))}</span><button class="rs-msg-copy" type="button" title="Copy message" aria-label="Copy message"><i class="fa-regular fa-copy"></i></button></div>
<div class="rs-msg"><span class="rs-msg-text">${esc(claimMessage('zelle'))}</span><button class="rs-msg-copy" type="button" title="Copy message" aria-label="Copy message"><i class="fa-regular fa-copy"></i></button></div>
<div class="rs-trust">
<div class="rs-trust">
<div class="rs-trust-row"><i class="fa-brands fa-github"></i><div><b>Why trust this over RobotStreamer?</b> Because you can read it. Every line of OpenVibe.Live is ${ghLink('public on GitHub')} — what it does with your stream, your chat and your data is in the open. RobotStreamer is closed source; you are trusting a black box. An open alternative is the only kind that earns trust.</div></div>
<div class="rs-trust-row"><i class="fa-brands fa-github"></i><div><b>Why trust this over RobotStreamer?</b> Because you can read it. Every line of OpenVibe.Live is ${ghLink('public on GitHub')} — what it does with your stream, your chat and your data is in the open. RobotStreamer is closed source; you are trusting a black box. An open alternative is the only kind that earns trust.</div></div>
- <div class="rs-trust-row"><i class="fa-solid fa-people-arrows"></i><div><b>Already on OpenVibe?</b> Bring a RobotStreamer streamer over — when they claim, name you in the message and <b>you get ${referral()}</b> as well. Yes, I'm paying our own users to phase that site out.</div></div>
+ <div class="rs-trust-row"><i class="fa-solid fa-people-arrows"></i><div><b>Already on OpenVibe?</b> Bring a RobotStreamer streamer over — when they claim, name you in the message and <b>you get ${referral()}</b> as well${cfg.vip ? ` (${esc(cfg.vip.display_name || cfg.vip.username)} gets $${Number(cfg.vip.referral)} — he's the VIP recruiter)` : ''}. Yes, I'm paying our own users to phase that site out.</div></div>
</div>
</div>
<div class="rs-modal-actions">
<div class="rs-modal-actions">
${loggedIn
${loggedIn
@@ -406,7 +471,7 @@
@@ -406,7 +471,7 @@
<a class="rs-btn rs-btn-ghost" href="/broadcast" onclick="return handleLinkClick(event, '/broadcast')" data-act="golive"><i class="fa-solid fa-tower-broadcast"></i> Set up my stream</a>
<a class="rs-btn rs-btn-ghost" href="/broadcast" onclick="return handleLinkClick(event, '/broadcast')" data-act="golive"><i class="fa-solid fa-tower-broadcast"></i> Set up my stream</a>
</div>
</div>
</div>
</div>
- <p class="rs-modal-fine">One bonus per RobotStreamer account · Zelle or PayPal only · the deal is exclusivity: no more streaming on RobotStreamer, ever. Built and paid for by one person trying to make this work — thanks for giving it a shot <i class="fa-solid fa-heart"></i></p>
+ <p class="rs-modal-fine">One bonus per RobotStreamer account · Zelle, PayPal or crypto · the deal is exclusivity: no more streaming on RobotStreamer, ever. Built and paid for out of pocket by one person who cares way too much — thanks for giving it a shot <i class="fa-solid fa-heart"></i></p>
</div>`;
</div>`;
modalEl.addEventListener('click', onModalClick);
modalEl.addEventListener('click', onModalClick);
document.addEventListener('keydown', onModalKey);
document.addEventListener('keydown', onModalKey);
@@ -434,7 +499,7 @@
@@ -434,7 +499,7 @@
const tg = modalEl?.querySelector('.rs-toggle'); if (!tg) return;
const tg = modalEl?.querySelector('.rs-toggle'); if (!tg) return;
tg.dataset.method = method;
tg.dataset.method = method;
tg.querySelectorAll('button').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.method === method)));
tg.querySelectorAll('button').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.method === method)));
- modalEl.querySelector('.rs-method-word').textContent = method === 'paypal' ? 'PayPal' : 'Zelle';
+ modalEl.querySelector('.rs-method-word').textContent = METHOD_NAME[method] || 'Zelle';
modalEl.querySelector('.rs-msg-text').textContent = claimMessage(method);
modalEl.querySelector('.rs-msg-text').textContent = claimMessage(method);
const copyBtn = modalEl.querySelector('.rs-msg-copy'); copyBtn.classList.remove('copied'); copyBtn.innerHTML = '<i class="fa-regular fa-copy"></i>';
const copyBtn = modalEl.querySelector('.rs-msg-copy'); copyBtn.classList.remove('copied'); copyBtn.innerHTML = '<i class="fa-regular fa-copy"></i>';
}
}
@@ -508,6 +573,7 @@
@@ -508,6 +573,7 @@
if (Number.isFinite(j.amount) && j.amount > 0) cfg.amount = j.amount;
if (Number.isFinite(j.amount) && j.amount > 0) cfg.amount = j.amount;
if (Number.isFinite(j.amount_min) && j.amount_min > 0) cfg.amountMin = j.amount_min;
if (Number.isFinite(j.amount_min) && j.amount_min > 0) cfg.amountMin = j.amount_min;
if (Number.isFinite(j.referral) && j.referral > 0) cfg.referral = j.referral;
if (Number.isFinite(j.referral) && j.referral > 0) cfg.referral = j.referral;
+ if (j.vip && j.vip.username) cfg.vip = j.vip;
if (j.github !== undefined) cfg.github = j.github || '';
if (j.github !== undefined) cfg.github = j.github || '';
if (j.owner) cfg.owner = String(j.owner);
if (j.owner) cfg.owner = String(j.owner);
if (j.discord !== undefined) cfg.discord = j.discord || '';
if (j.discord !== undefined) cfg.discord = j.discord || '';
@@ -519,6 +585,7 @@
@@ -519,6 +585,7 @@
if (!cfg.enabled) return;
if (!cfg.enabled) return;
mountTicker();
mountTicker();
mountHeroCard();
mountHeroCard();
+ mountVipCard();
mountStreamAlert();
mountStreamAlert();
watchChannelPage();
watchChannelPage();
// Let the page settle (fonts, hero paint) before the splash slams in.
// Let the page settle (fonts, hero paint) before the splash slams in.
@@ -12,37 +12,19 @@
@@ -12,37 +12,19 @@
const arena = require('./arena-service');
const arena = require('./arena-service');
const INTERVAL_MS = 20 * 60 * 1000;
const INTERVAL_MS = 20 * 60 * 1000;
-const BATCH = 6;
-const IMAGE_BATCH = 2;
const CLOCK_MS = 60 * 1000;
const CLOCK_MS = 60 * 1000;
let _timer = null;
let _timer = null;
let _clock = null;
let _clock = null;
let _busy = false;
let _busy = false;
+// Personas and portraits are no longer generated: the Arena shows only what was said on mic.
+// The periodic tick now judges past speech (backfill) so the feed reflects the record, and
+// catches up anything the live listener missed.
async function tick() {
async function tick() {
if (_busy || !arena.arenaEnabled() || !arena.aiOn()) return;
if (_busy || !arena.arenaEnabled() || !arena.aiOn()) return;
_busy = true;
_busy = true;
- try {
- const db = require('../db/database');
- const roster = arena.loadRoster();
- let personas = 0, images = 0;
- for (const userId of roster.order) {
- if (personas >= BATCH && (images >= IMAGE_BATCH || !arena.imageGenAvailable())) break;
- const row = db.get('SELECT persona_generated_at, image_generated_at, image_path FROM arena_profiles WHERE user_id = ?', [userId]) || {};
- const personaStale = !row.persona_generated_at || Date.now() - Date.parse(row.persona_generated_at + 'Z') > 24 * 60 * 60 * 1000;
- if (personaStale && personas < BATCH) {
- try { await arena.generatePersona(userId); personas++; } catch (e) { console.warn('[Arena] job persona:', e.message); }
- }
- const imageStale = !row.image_path || !row.image_generated_at || Date.now() - Date.parse(row.image_generated_at + 'Z') > 7 * 24 * 60 * 60 * 1000;
- if (imageStale && images < IMAGE_BATCH && arena.imageGenAvailable()) {
- try { await arena.generateImage(userId); images++; } catch (e) { console.warn('[Arena] job image:', e.message); }
- }
- if (!arena.aiOn()) break; // budget ran out mid-tick
- }
- if (personas || images) console.log(`[Arena] job: ${personas} persona(s), ${images} portrait(s) refreshed`);
- } finally {
- _busy = false;
- }
+ try { await require('./backfill').run(); } catch (e) { console.warn('[Arena] backfill:', e.message); }
+ finally { _busy = false; }
}
}
function start() {
function start() {
@@ -57,7 +39,7 @@ function start() {
@@ -57,7 +39,7 @@ function start() {
_clock = setInterval(() => housekeeping().catch(e => console.warn('[Arena] housekeeping:', e.message)), CLOCK_MS);
_clock = setInterval(() => housekeeping().catch(e => console.warn('[Arena] housekeeping:', e.message)), CLOCK_MS);
if (_clock.unref) _clock.unref();
if (_clock.unref) _clock.unref();
setTimeout(() => housekeeping().catch(() => {}), 20_000).unref?.();
setTimeout(() => housekeeping().catch(() => {}), 20_000).unref?.();
- console.log('[Arena] job started (personas every 20 min; listener every 15 s; beef clocks every 60 s) — Battle Cam mode: pure mic');
+ console.log('[Arena] job started (backfill of past speech every 20 min; listener every 15 s; beef clocks every 60 s) — Battle Cam mode: pure mic');
}
}
async function housekeeping() {
async function housekeeping() {
@@ -543,11 +543,10 @@ async function getFighter(usernameOrId, { generate = true } = {}) {
@@ -543,11 +543,10 @@ async function getFighter(usernameOrId, { generate = true } = {}) {
if (!user) return null;
if (!user) return null;
const roster = loadRoster();
const roster = loadRoster();
if (!roster.byId[user.id]) return { user: publicUser(user), not_on_roster: true, reason: `nothing heard on mic in the last ${ACTIVE_DAYS} days — the Arena only knows what the transcription hears` };
if (!roster.byId[user.id]) return { user: publicUser(user), not_on_roster: true, reason: `nothing heard on mic in the last ${ACTIVE_DAYS} days — the Arena only knows what the transcription hears` };
- if (generate) {
- const row = profileRow(user.id);
- if (aiOn() && !personaIsFresh(row)) { try { await generatePersona(user.id); } catch (e) { console.warn('[Arena] persona:', e.message); } }
- if (!quotesAreFresh(profileRow(user.id))) { try { await generateQuotes(user.id); } catch (e) { console.warn('[Arena] quotes:', e.message); } }
- if (aiOn() && !imageIsFresh(profileRow(user.id)) && imageGenAvailable()) generateImage(user.id).catch(() => {});
+ // Battle Cam: no persona / portrait / quote generation on view — the page shows only what
+ // was said on mic (mic.js). `generate` is kept for the admin refresh route.
+ if (generate === 'force') {
+ try { await generatePersona(user.id); } catch (e) { console.warn('[Arena] persona:', e.message); }
}
}
const card = cardFor(user.id, roster, { includeQuotes: true });
const card = cardFor(user.id, roster, { includeQuotes: true });
card.image_pending = !card.image_url && _imageInFlight.has(user.id);
card.image_pending = !card.image_url && _imageInFlight.has(user.id);
new file mode 100644
new file mode 100644
@@ -0,0 +1,132 @@
@@ -0,0 +1,132 @@
+/**
+ * OpenVibe.Live — Arena backfill: judge what was ALREADY said on mic.
+ *
+ * The listener only hears streams that are live right now. Everything a fighter said before
+ * (the transcripts already in stream_timeline_events) is judged here, so the Arena reflects
+ * the shit talk on record the moment it ships, not weeks later:
+ *
+ * 1. every roster fighter's speech from the last DAYS days is read in stream order and cut
+ * into ~60 s chunks (JUDGE_MIN_WORDS … CHUNK_MAX_WORDS, a gap > CHUNK_GAP_SEC starts a
+ * new chunk);
+ * 2. a chunk is only sent to a model if it looks like it could be shit talk (the SPICY
+ * keyword pre-filter) or names another fighter — gameplay narration never costs a call;
+ * 3. a name-drop chunk goes to the beef judge (no clocks / no beefs are opened for old
+ * speech — it lands as a `callout` moment with the target attached); everything else
+ * goes to the mic judge → `trash` moment. Both pay Trash Level XP.
+ *
+ * A per-fighter cursor (site setting `arena_backfill_cursor_<userId>` = last timeline row id
+ * judged) makes every run incremental, so this also catches up anything the live listener
+ * missed. Bounded per run (MAX_CALLS_PER_RUN model calls) so a big backlog drains over a few
+ * runs instead of burning the budget at once.
+ */
+'use strict';
+
+const db = require('../db/database');
+
+const DAYS = 7;
+const CHUNK_GAP_SEC = 25;
+const CHUNK_MAX_WORDS = 110;
+const MAX_CALLS_PER_RUN = 160;
+const MAX_CHUNKS_PER_FIGHTER = 80;
+
+function arena() { return require('./arena-service'); }
+function listener() { return require('./listener'); }
+function mic() { return require('./mic'); }
+function words(t) { return String(t || '').split(/\s+/).filter(Boolean).length; }
+function cursorKey(uid) { return `arena_backfill_cursor_${uid}`; }
+function getCursor(uid) { const v = parseInt(db.getSetting(cursorKey(uid)), 10); return Number.isFinite(v) ? v : 0; }
+function setCursor(uid, id) { try { db.setSetting(cursorKey(uid), String(id)); } catch { /* */ } }
+function sqlToIso(ts) { return ts ? String(ts).replace(' ', 'T') + (String(ts).endsWith('Z') ? '' : 'Z') : null; }
+
+/** Cut a fighter's speech rows into judge-sized chunks. */
+function chunk(rows) {
+ const out = [];
+ let cur = null;
+ for (const r of rows) {
+ const w = words(r.text);
+ if (!w) continue;
+ const gap = cur && (r.stream_id !== cur.stream_id || r.start_sec - cur.end > CHUNK_GAP_SEC);
+ if (!cur || gap || cur.words + w > CHUNK_MAX_WORDS) {
+ if (cur && cur.words >= listener().JUDGE_MIN_WORDS) out.push(cur);
+ cur = { stream_id: r.stream_id, vod_id: r.vod_id, started_at: r.started_at, start: r.start_sec, end: r.end_sec || r.start_sec + 3, lines: [], words: 0, lastId: r.id };
+ }
+ cur.lines.push({ t: String(r.text), s: Math.floor(r.start_sec), v: r.vod_id || null, id: r.id });
+ cur.words += w; cur.end = r.end_sec || r.start_sec + 3; cur.lastId = r.id;
+ }
+ if (cur && cur.words >= listener().JUDGE_MIN_WORDS) out.push(cur);
+ return out;
+}
+
+function saidAtFor(c, sec) {
+ const base = c.started_at ? Date.parse(sqlToIso(c.started_at)) : NaN;
+ if (!Number.isFinite(base)) return null;
+ return new Date(base + Math.max(0, sec || 0) * 1000).toISOString().replace('T', ' ').slice(0, 19);
+}
+
+async function backfillFighter(uid, roster, budget) {
+ const L = listener();
+ const since = `-${DAYS} days`;
+ const rows = db.all(`SELECT e.id, e.stream_id, e.vod_id, e.start_sec, e.end_sec, e.text, s.started_at
+ FROM stream_timeline_events e LEFT JOIN streams s ON s.id = e.stream_id
+ WHERE e.user_id = ? AND e.kind = 'speech' AND e.id > ? AND e.created_at >= datetime('now', ?)
+ AND NOT EXISTS (SELECT 1 FROM streams l WHERE l.id = e.stream_id AND l.is_live = 1)
+ ORDER BY e.stream_id, e.start_sec LIMIT 4000`, [uid, getCursor(uid), since]);
+ if (!rows.length) return { judged: 0, moments: 0, skipped: 0 };
+ const chunks = chunk(rows).slice(0, MAX_CHUNKS_PER_FIGHTER);
+ let judged = 0, moments = 0, skipped = 0, lastId = getCursor(uid);
+ for (const c of chunks) {
+ if (budget.calls >= MAX_CALLS_PER_RUN) break;
+ const text = c.lines.map(l => l.t).join(' ').replace(/\s+/g, ' ').slice(-1400);
+ const mentions = L._mentionsDetailed(text, uid, roster);
+ const spicy = L.SPICY.test(text.toLowerCase());
+ lastId = Math.max(lastId, c.lastId);
+ if (!mentions.length && !spicy) { skipped++; continue; }
+ budget.calls++; judged++;
+ const ref = (bestLine) => { const needle = String(bestLine || '').toLowerCase().slice(0, 30); const hit = (needle && c.lines.find(l => l.t.toLowerCase().includes(needle))) || c.lines[0]; return { vod_id: hit ? hit.v : null, sec: hit ? Math.max(0, hit.s - 2) : null }; };
+ try {
+ if (mentions.length) {
+ const m = mentions[0];
+ const j = await L._judgeBeef(uid, m.userId, text, roster, { context: null, named: true, how: m.how });
+ if (j.aimed_at_target && j.quality >= (m.how === 'exact' ? 3 : 5)) {
+ const r = ref(j.best_line);
+ if (mic().addMoment({ userId: uid, streamId: c.stream_id, vodId: r.vod_id, sec: r.sec, kind: 'callout', targetUserId: m.userId, aimedAt: mic().nameOf(m.userId), text: j.best_line || text.slice(0, 220), about: j.about, quality: j.quality, announcer: j.announcer, saidAt: saidAtFor(c, r.sec) })) moments++;
+ continue;
+ }
+ if (j.about_target) continue; // neutral mention — not shit talk, and not free talk either
+ }
+ const j = await L._judgeMic(uid, text);
+ if (j.is_trash_talk && j.quality >= L.MIC_MIN_QUALITY) {
+ const r = ref(j.best_line);
+ const target = j.aimed_at ? L._mentionsDetailed(j.aimed_at, uid, roster)[0] : null;
+ if (mic().addMoment({ userId: uid, streamId: c.stream_id, vodId: r.vod_id, sec: r.sec, kind: target ? 'callout' : 'trash', targetUserId: target ? target.userId : null, aimedAt: target ? mic().nameOf(target.userId) : (j.aimed_at || null), text: j.best_line || text.slice(0, 220), about: j.about, quality: j.quality, announcer: j.announcer, saidAt: saidAtFor(c, r.sec) })) moments++;
+ }
+ } catch (e) { console.warn(`[Arena] backfill judge (user ${uid}):`, e.message); }
+ }
+ setCursor(uid, lastId);
+ return { judged, moments, skipped, chunks: chunks.length };
+}
+
+let _busy = false;
+/** Judge un-judged past speech for every roster fighter. Bounded; safe to call often. */
+async function run({ force = false } = {}) {
+ if (_busy) return { busy: true };
+ if (!arena().arenaEnabled()) return { disabled: true };
+ _busy = true;
+ const started = Date.now();
+ const budget = { calls: 0 };
+ const out = { fighters: 0, judged: 0, moments: 0, skipped: 0, calls: 0 };
+ try {
+ const roster = arena().loadRoster(force);
+ for (const uid of roster.order) {
+ if (budget.calls >= MAX_CALLS_PER_RUN) break;
+ const r = await backfillFighter(uid, roster, budget);
+ out.fighters++; out.judged += r.judged; out.moments += r.moments; out.skipped += r.skipped;
+ }
+ out.calls = budget.calls;
+ if (out.judged || out.moments) console.log(`[Arena] backfill: ${out.moments} moment(s) from ${out.judged} judged chunk(s) across ${out.fighters} fighter(s) (${out.skipped} skipped as not spicy, ${Math.round((Date.now() - started) / 1000)} s)`);
+ if (out.moments) { try { arena().loadRoster(true); } catch { /* */ } }
+ return out;
+ } finally { _busy = false; }
+}
+
+module.exports = { run, chunk, DAYS, MAX_CALLS_PER_RUN, _backfillFighter: backfillFighter };
@@ -82,11 +82,7 @@ function arena() { return require('./arena-service'); }
@@ -82,11 +82,7 @@ function arena() { return require('./arena-service'); }
function mic() { return require('./mic'); }
function mic() { return require('./mic'); }
function isLive(userId) { return !!db.get('SELECT 1 FROM streams WHERE user_id = ? AND is_live = 1 LIMIT 1', [userId]); }
function isLive(userId) { return !!db.get('SELECT 1 FROM streams WHERE user_id = ? AND is_live = 1 LIMIT 1', [userId]); }
function nowIso() { return new Date().toISOString(); }
function nowIso() { return new Date().toISOString(); }
-function nameOf(userId) {
- const p = parseJson(db.get('SELECT persona_json FROM arena_profiles WHERE user_id = ?', [userId])?.persona_json);
- const u = db.getUserById(userId);
- return p?.fighter_name || u?.display_name || u?.username || `user${userId}`;
-}
+function nameOf(userId) { return mic().nameOf(userId); }
const HEADLINE_SCHEMA = { name: 'arena_headline', schema: { type: 'object', additionalProperties: false, required: ['headline'], properties: { headline: { type: 'string', description: '≤ 100 chars, tabloid, inflammatory but funny, names both fighters' } } } };
const HEADLINE_SCHEMA = { name: 'arena_headline', schema: { type: 'object', additionalProperties: false, required: ['headline'], properties: { headline: { type: 'string', description: '≤ 100 chars, tabloid, inflammatory but funny, names both fighters' } } } };
async function headlineFor(kind, ctx) {
async function headlineFor(kind, ctx) {
try {
try {
@@ -310,4 +310,4 @@ function start() {
@@ -310,4 +310,4 @@ function start() {
}
}
function stop() { if (_timer) { clearInterval(_timer); _timer = null; } }
function stop() { if (_timer) { clearInterval(_timer); _timer = null; } }
-module.exports = { start, stop, tick, consoleState, TICK_MS, JUDGE_MIN_WORDS, JUDGE_MIN_INTERVAL_MS, MIC_MIN_QUALITY, CALLOUT_MIN_QUALITY, FOCUS_TAIL_MS, FOCUS_EXTEND_MS, FOCUS_MAX_MS, _mentionsIn: mentionsIn, _mentionsDetailed: mentionsDetailed, _aliases: aliases, _heuristicBeef: heuristicBeef, _heuristicMic: heuristicMic, _state: state };
+module.exports = { start, stop, tick, consoleState, TICK_MS, JUDGE_MIN_WORDS, JUDGE_MIN_INTERVAL_MS, MIC_MIN_QUALITY, CALLOUT_MIN_QUALITY, FOCUS_TAIL_MS, FOCUS_EXTEND_MS, FOCUS_MAX_MS, SPICY, _mentionsIn: mentionsIn, _mentionsDetailed: mentionsDetailed, _aliases: aliases, _heuristicBeef: heuristicBeef, _heuristicMic: heuristicMic, _judgeBeef: judgeBeef, _judgeMic: judgeMic, _state: state };
@@ -133,18 +133,17 @@ function levelsLeaderboard(limit = 10) {
@@ -133,18 +133,17 @@ function levelsLeaderboard(limit = 10) {
// ── Names + briefs ───────────────────────────────────────────
// ── Names + briefs ───────────────────────────────────────────
+// Real names only — no AI ring names. The fighter IS the streamer.
function nameOf(userId) {
function nameOf(userId) {
- const p = parseJson(db.get('SELECT persona_json FROM arena_profiles WHERE user_id = ?', [userId])?.persona_json);
const u = db.getUserById(userId);
const u = db.getUserById(userId);
- return p?.fighter_name || u?.display_name || u?.username || `user${userId}`;
+ return u?.display_name || u?.username || `user${userId}`;
}
}
function fighterBrief(userId, roster) {
function fighterBrief(userId, roster) {
const f = roster && roster.byId ? roster.byId[userId] : null;
const f = roster && roster.byId ? roster.byId[userId] : null;
- const persona = parseJson(db.get('SELECT persona_json FROM arena_profiles WHERE user_id = ?', [userId])?.persona_json);
return {
return {
user: f ? f.user : (db.getUserById(userId) ? arena().publicUser(db.getUserById(userId)) : { id: userId, username: `user${userId}`, display_name: `user${userId}` }),
user: f ? f.user : (db.getUserById(userId) ? arena().publicUser(db.getUserById(userId)) : { id: userId, username: `user${userId}`, display_name: `user${userId}` }),
- fighter_name: persona?.fighter_name || f?.user?.display_name || `user${userId}`,
+ fighter_name: nameOf(userId),
rank: f ? roster.order.indexOf(userId) + 1 : null,
rank: f ? roster.order.indexOf(userId) + 1 : null,
image_url: (() => { try { return arena().getFighterImageUrl(userId); } catch { return null; } })(),
image_url: (() => { try { return arena().getFighterImageUrl(userId); } catch { return null; } })(),
live: !!db.get('SELECT 1 FROM streams WHERE user_id = ? AND is_live = 1 LIMIT 1', [userId]),
live: !!db.get('SELECT 1 FROM streams WHERE user_id = ? AND is_live = 1 LIMIT 1', [userId]),
@@ -169,7 +168,7 @@ function addMoment({ userId, streamId = null, vodId = null, sec = null, kind = '
@@ -169,7 +168,7 @@ function addMoment({ userId, streamId = null, vodId = null, sec = null, kind = '
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE(?, CURRENT_TIMESTAMP))`,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE(?, CURRENT_TIMESTAMP))`,
[userId, streamId, vodId, sec == null ? null : Math.max(0, Math.floor(sec)), kind, targetUserId, beefId, aimedAt ? String(aimedAt).slice(0, 80) : null, t, about ? String(about).slice(0, 80) : null, q, announcer ? String(announcer).slice(0, 140) : null, saidAt]);
[userId, streamId, vodId, sec == null ? null : Math.max(0, Math.floor(sec)), kind, targetUserId, beefId, aimedAt ? String(aimedAt).slice(0, 80) : null, t, about ? String(about).slice(0, 80) : null, q, announcer ? String(announcer).slice(0, 140) : null, saidAt]);
const id = Number(r.lastInsertRowid);
const id = Number(r.lastInsertRowid);
- if (kind === 'trash') addXp(userId, q * XP_MOMENT, 'mic_trash', id, { moment: true, line: t, lineScore: q, lineVodId: vodId, lineSec: sec });
+ if (kind === 'trash' || kind === 'callout') addXp(userId, q * XP_MOMENT, kind === 'callout' ? 'mic_callout' : 'mic_trash', id, { moment: true, line: t, lineScore: q, lineVodId: vodId, lineSec: sec });
return db.get('SELECT * FROM arena_mic_moments WHERE id = ?', [id]);
return db.get('SELECT * FROM arena_mic_moments WHERE id = ?', [id]);
}
}
@@ -192,7 +191,7 @@ function feed({ limit = 40, since = null, userId = null } = {}) {
@@ -192,7 +191,7 @@ function feed({ limit = 40, since = null, userId = null } = {}) {
if (userId) { where += ' AND user_id = ?'; params.push(userId); }
if (userId) { where += ' AND user_id = ?'; params.push(userId); }
if (since) { where += ' AND id > ?'; params.push(Number(since) || 0); }
if (since) { where += ' AND id > ?'; params.push(Number(since) || 0); }
params.push(Math.min(200, Math.max(1, limit)));
params.push(Math.min(200, Math.max(1, limit)));
- return db.all(`SELECT * FROM arena_mic_moments WHERE ${where} ORDER BY id DESC LIMIT ?`, params).map(m => momentView(m, roster));
+ return db.all(`SELECT * FROM arena_mic_moments WHERE ${where} ORDER BY COALESCE(said_at, created_at) DESC, id DESC LIMIT ?`, params).map(m => momentView(m, roster));
}
}
function momentsFor(userId, limit = 12) { return feed({ limit, userId }); }
function momentsFor(userId, limit = 12) { return feed({ limit, userId }); }
function bestLines(userId, limit = 5) {
function bestLines(userId, limit = 5) {
@@ -202,7 +201,7 @@ function bestLines(userId, limit = 5) {
@@ -202,7 +201,7 @@ function bestLines(userId, limit = 5) {
}
}
function latestFor(userId) {
function latestFor(userId) {
ensureTables();
ensureTables();
- const m = db.get('SELECT * FROM arena_mic_moments WHERE user_id = ? ORDER BY id DESC LIMIT 1', [userId]);
+ const m = db.get('SELECT * FROM arena_mic_moments WHERE user_id = ? ORDER BY COALESCE(said_at, created_at) DESC, id DESC LIMIT 1', [userId]);
return m ? momentView(m, arena().loadRoster()) : null;
return m ? momentView(m, arena().loadRoster()) : null;
}
}
@@ -211,7 +210,7 @@ function micStats(userId, days = 30) {
@@ -211,7 +210,7 @@ function micStats(userId, days = 30) {
ensureTables();
ensureTables();
const win = `-${days} days`;
const win = `-${days} days`;
const m = db.get(`SELECT COUNT(*) AS n, COALESCE(AVG(quality), 0) AS avg_q, COALESCE(MAX(quality), 0) AS best_q,
const m = db.get(`SELECT COUNT(*) AS n, COALESCE(AVG(quality), 0) AS avg_q, COALESCE(MAX(quality), 0) AS best_q,
- COALESCE(SUM(kind = 'beef_hit'), 0) AS beef_hits, COALESCE(SUM(kind = 'trash'), 0) AS trash,
+ COALESCE(SUM(kind IN ('beef_hit', 'callout')), 0) AS beef_hits, COALESCE(SUM(kind = 'trash'), 0) AS trash,
COALESCE(SUM(quality >= 7), 0) AS bangers
COALESCE(SUM(quality >= 7), 0) AS bangers
FROM arena_mic_moments WHERE user_id = ? AND created_at >= datetime('now', ?)`, [userId, win]) || {};
FROM arena_mic_moments WHERE user_id = ? AND created_at >= datetime('now', ?)`, [userId, win]) || {};
let b = {};
let b = {};
@@ -76,6 +76,11 @@ router.post('/fighters/:user/refresh', requireAuth, permissions.requireAdmin, as
@@ -76,6 +76,11 @@ router.post('/fighters/:user/refresh', requireAuth, permissions.requireAdmin, as
} catch (err) { fail(res, err, 'Refresh failed'); }
} catch (err) { fail(res, err, 'Refresh failed'); }
});
});
+// Admin: judge past speech now (bounded per call; the job also does this every 20 min).
+router.post('/backfill', requireAuth, permissions.requireAdmin, async (req, res) => {
+ try { res.json(await require('./backfill').run({ force: true })); } catch (err) { fail(res, err, 'Backfill failed'); }
+});
+
// ── Live console: what the ears hear for one fighter ──
// ── Live console: what the ears hear for one fighter ──
router.get('/console/:user', (req, res) => {
router.get('/console/:user', (req, res) => {
try {
try {
@@ -228,6 +228,9 @@ function buildConfig(registryValues) {
@@ -228,6 +228,9 @@ function buildConfig(registryValues) {
amountMin: parseInt(process.env.RS_PROMO_AMOUNT_MIN, 10) || 25, // bottom of the range
amountMin: parseInt(process.env.RS_PROMO_AMOUNT_MIN, 10) || 25, // bottom of the range
// Referral: an existing OpenVibe user who brings a RobotStreamer streamer over gets paid too.
// Referral: an existing OpenVibe user who brings a RobotStreamer streamer over gets paid too.
referral: parseInt(process.env.RS_PROMO_REFERRAL, 10) || 10,
referral: parseInt(process.env.RS_PROMO_REFERRAL, 10) || 10,
+ // VIP recruiter: one user with a higher per-conversion rate, spotlighted on the home page.
+ vipUser: process.env.RS_PROMO_VIP_USER || 'patrick',
+ vipReferral: parseInt(process.env.RS_PROMO_REFERRAL_VIP, 10) || 20,
github: process.env.RS_PROMO_GITHUB || 'https://github.com/OpenVibers/OpenVibe.Live',
github: process.env.RS_PROMO_GITHUB || 'https://github.com/OpenVibers/OpenVibe.Live',
// Who the claimant DMs in-site; defaults to the admin account.
// Who the claimant DMs in-site; defaults to the admin account.
owner: process.env.RS_PROMO_OWNER || process.env.ADMIN_USERNAME || 'admin',
owner: process.env.RS_PROMO_OWNER || process.env.ADMIN_USERNAME || 'admin',
@@ -614,7 +614,9 @@ app.get('/api/health', (req, res) => {
@@ -614,7 +614,9 @@ app.get('/api/health', (req, res) => {
app.get('/api/promo/robotstreamer', (req, res) => {
app.get('/api/promo/robotstreamer', (req, res) => {
const p = config.rsPromo || {};
const p = config.rsPromo || {};
res.set('Cache-Control', 'public, max-age=300');
res.set('Cache-Control', 'public, max-age=300');
- res.json({ enabled: p.enabled !== false, amount: p.amount || 50, amount_min: p.amountMin || 25, referral: p.referral || 10, github: p.github || null, owner: p.owner || 'admin', discord: p.discord || null });
+ let vip = null;
+ try { const u = p.vipUser ? db.getUserByUsername(p.vipUser) : null; vip = { username: u ? u.username : p.vipUser, display_name: u ? (u.display_name || u.username) : p.vipUser, avatar_url: u ? u.avatar_url : null, referral: p.vipReferral || 20, exists: !!u }; } catch { vip = { username: p.vipUser, display_name: p.vipUser, referral: p.vipReferral || 20, exists: false }; }
+ res.json({ enabled: p.enabled !== false, amount: p.amount || 50, amount_min: p.amountMin || 25, referral: p.referral || 10, vip, github: p.github || null, owner: p.owner || 'admin', discord: p.discord || null });
});
});
// ── Updates / Changelog ──────────────────────────────────────
// ── Updates / Changelog ──────────────────────────────────────
@@ -162,12 +162,36 @@ console.log('✅ roster + ratings are pure mic');
@@ -162,12 +162,36 @@ console.log('✅ roster + ratings are pure mic');
assert.ok(sent.pop().includes('on the clock'));
assert.ok(sent.pop().includes('on the clock'));
console.log('✅ chat: !hype / !beef only');
console.log('✅ chat: !hype / !beef only');
+ // ── Backfill: past speech on ended streams gets judged into the feed (heuristic judges, no AI) ──
+ const backfill = require('../server/arena/backfill');
+ const old = Number(db.createStream({ user_id: u2, title: 'yesterday', category: 'irl', protocol: 'rtmp' }).lastInsertRowid);
+ db.run(`UPDATE streams SET is_live = 0, started_at = datetime('now', '-1 day'), ended_at = datetime('now', '-23 hours'), duration_seconds = 3600 WHERE id = ?`, [old]);
+ db.addTimelineEvents([
+ { stream_id: old, user_id: u2, vod_id: 777, kind: 'speech', start_sec: 100, end_sec: 106, text: 'Chat, all of you are clowns, absolute bums, nobody in this chat could beat me at anything, sit down losers, bet!', label: null, confidence: 0.9 },
+ { stream_id: old, user_id: u2, vod_id: 777, kind: 'speech', start_sec: 107, end_sec: 112, text: 'Pathetic, every single one of you is washed and delusional, I am better than this entire chat!', label: null, confidence: 0.9 },
+ { stream_id: old, user_id: u2, vod_id: 777, kind: 'speech', start_sec: 400, end_sec: 406, text: 'Okay so we go to the shop, buy the potions, then head to the cave and farm the boss for the sword, let me check the map for the route.', label: null, confidence: 0.9 },
+ { stream_id: old, user_id: u2, vod_id: 777, kind: 'speech', start_sec: 407, end_sec: 412, text: 'Yeah the cave is north of the village past the bridge, should take about ten minutes if we do not get lost again.', label: null, confidence: 0.9 },
+ ]);
+ const feedBefore = mic.feed({ limit: 100 }).length;
+ const bfr = await backfill.run({ force: true });
+ assert.ok(bfr.moments >= 1, `backfill judged old speech into the feed: ${JSON.stringify(bfr)}`);
+ assert.ok(bfr.skipped >= 1, 'gameplay chunk never cost a judge call');
+ const fromOld = mic.feed({ limit: 100 }).filter(m => m.stream_id === old);
+ assert.ok(fromOld.length >= 1 && fromOld[0].vod_id === 777 && fromOld[0].aimed_at === 'chat', `moment carries VOD + target: ${JSON.stringify(fromOld[0])}`);
+ assert.ok(fromOld[0].at && Date.now() - Date.parse(fromOld[0].at.replace(' ', 'T') + 'Z') > 20 * 3600 * 1000, 'moment is dated when it was SAID, not when it was judged');
+ assert.ok(mic.feed({ limit: 100 }).length >= feedBefore + fromOld.length, 'other fighters\' spicy past lines were judged too');
+ const again = await backfill.run({ force: true });
+ assert.strictEqual(again.judged, 0, 'cursor: nothing re-judged on the second run');
+ assert.deepStrictEqual(await backfill.run({ force: true }), await backfill.run({ force: true }));
+ console.log('✅ backfill: past speech judged once, VOD-linked, dated when said');
+
// ── Public API smoke ──
// ── Public API smoke ──
const express = require('express');
const express = require('express');
const app = express(); app.use(express.json()); app.use('/api/arena', require('../server/arena/routes'));
const app = express(); app.use(express.json()); app.use('/api/arena', require('../server/arena/routes'));
const srv = await new Promise(r => { const s = app.listen(0, () => r(s)); });
const srv = await new Promise(r => { const s = app.listen(0, () => r(s)); });
const get = async (p) => { const res = await fetch(`http://127.0.0.1:${srv.address().port}/api/arena${p}`); const txt = await res.text(); let body = null; try { body = JSON.parse(txt); } catch { body = null; } return { status: res.status, body }; };
const get = async (p) => { const res = await fetch(`http://127.0.0.1:${srv.address().port}/api/arena${p}`); const txt = await res.text(); let body = null; try { body = JSON.parse(txt); } catch { body = null; } return { status: res.status, body }; };
const fd = await get('/feed?limit=5'); assert.strictEqual(fd.status, 200); assert.ok(fd.body.feed.length >= 3 && fd.body.feed[0].text);
const fd = await get('/feed?limit=5'); assert.strictEqual(fd.status, 200); assert.ok(fd.body.feed.length >= 3 && fd.body.feed[0].text);
+ assert.strictEqual((await get('/backfill')).status, 404, 'backfill is POST + admin only');
const fi = await get('/fighters'); assert.deepStrictEqual(fi.body.stats, arena.STAT_KEYS); assert.ok(fi.body.fighters.every(f => f.mic && typeof f.mic.avg_quality === 'number'));
const fi = await get('/fighters'); assert.deepStrictEqual(fi.body.stats, arena.STAT_KEYS); assert.ok(fi.body.fighters.every(f => f.mic && typeof f.mic.avg_quality === 'number'));
const bf = await get('/beefs'); assert.strictEqual(bf.body.open.length, 1); assert.strictEqual(bf.body.resolved.length, 1);
const bf = await get('/beefs'); assert.strictEqual(bf.body.open.length, 1); assert.strictEqual(bf.body.resolved.length, 1);
const con = await get('/console/pixelqueen'); assert.strictEqual(con.body.listener.listening, true); assert.ok(con.body.recent_moments.length >= 1 && con.body.mic);
const con = await get('/console/pixelqueen'); assert.strictEqual(con.body.listener.listening, true); assert.ok(con.body.recent_moments.length >= 1 && con.body.mic);
@@ -120,15 +120,14 @@ console.log('✅ roster from transcripts only; idle and never-transcribed stream
@@ -120,15 +120,14 @@ console.log('✅ roster from transcripts only; idle and never-transcribed stream
assert.ok(!('followers' in card.raw) && !('peak_viewers' in card.raw), 'no audience numbers on the card');
assert.ok(!('followers' in card.raw) && !('peak_viewers' in card.raw), 'no audience numbers on the card');
assert.strictEqual(card.image_url, null);
assert.strictEqual(card.image_url, null);
assert.strictEqual(card.image_generation, 'off', 'no AI → no image generation');
assert.strictEqual(card.image_generation, 'off', 'no AI → no image generation');
- assert.ok(card.quotes && card.quotes.picks.length >= 3, 'heuristic quotes without AI');
- assert.ok(card.quotes.picks[0].vod_id === 901 && typeof card.quotes.picks[0].start_sec === 'number', 'quotes link to the VOD second');
- assert.strictEqual(card.quotes._fallback, true);
+ assert.strictEqual(card.quotes, null, 'nothing is generated on view any more — no quotes, no persona, no portrait');
+ assert.ok(card.persona_is_fallback, 'persona is the stats-only fallback (never generated)');
assert.deepStrictEqual(card.beefs, []);
assert.deepStrictEqual(card.beefs, []);
assert.strictEqual(card.level.level, 1);
assert.strictEqual(card.level.level, 1);
const off = await arena.getFighter('mute');
const off = await arena.getFighter('mute');
assert.strictEqual(off.not_on_roster, true);
assert.strictEqual(off.not_on_roster, true);
assert.ok(/mic/.test(off.reason));
assert.ok(/mic/.test(off.reason));
- console.log('✅ fighter card + transcript quotes');
+ console.log('✅ fighter card (mic-only, nothing generated on view)');
const detail = arena.getStatDetail(u1, 'mouth');
const detail = arena.getStatDetail(u1, 'mouth');
assert.strictEqual(detail.position, 1);
assert.strictEqual(detail.position, 1);