openvibers/openvibe.live Commits
Guided restream setup on Go Live; Arena judges quote complete lines only; fix stretched Arena avatar
Changes
nnew file mode 100644@@@ -0,0 +1,21 @@++/* ── Guided setup overlay on the Go Live page (public/js/broadcast-guide.js) ── */++.bg-guide { position: fixed; inset: 0; z-index: 20500; pointer-events: none; opacity: 0; transition: opacity 0.3s; }++.bg-guide.is-on { opacity: 1; }++.bg-guide-hole {++ position: fixed; border-radius: 14px; pointer-events: none;++ box-shadow: 0 0 0 9999px rgba(3, 3, 8, 0.68), 0 0 0 3px var(--accent), 0 0 30px color-mix(in srgb, var(--accent) 60%, transparent);++ transition: left 0.25s ease, top 0.25s ease, width 0.25s ease, height 0.25s ease, opacity 0.25s;++}++.bg-guide-card {++ position: fixed; pointer-events: auto; width: 380px; max-width: calc(100vw - 24px);++ padding: 16px 18px; border-radius: 16px; color: #fff;++ background: linear-gradient(160deg, #17132b, #0c0c12 60%); border: 1px solid rgba(255,255,255,0.14);++ box-shadow: 0 30px 70px rgba(0,0,0,0.6), 0 0 0 1px color-mix(in srgb, var(--accent) 35%, transparent);++ transition: top 0.25s ease, left 0.25s ease;++}++.bg-guide-step { font-size: 0.68rem; font-weight: 800; letter-spacing: 0.12em; text-transform: uppercase; color: var(--accent-light); margin-bottom: 4px; }++.bg-guide-title { margin: 0 0 6px; font-size: 1.05rem; font-weight: 900; }++.bg-guide-text { margin: 0 0 12px; font-size: 0.86rem; line-height: 1.5; color: #e2e8f0; }++.bg-guide-text b { color: #fff; }++.bg-guide-actions { display: flex; flex-wrap: wrap; gap: 8px; }++@media (prefers-reduced-motion: reduce) { .bg-guide, .bg-guide-hole, .bg-guide-card { transition: none; } }@@@ -14722,3 +14722,7 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg.arena-cam-thumb { aspect-ratio: 16/9; }}@media (prefers-reduced-motion: reduce) { .arena-mic-moment { animation: none; } }++++/* Arena "your arena" strip: the portrait is a div, so the generic `> div` flex rule was stretching it */++.arena-me-left > .arena-portrait { flex: 0 0 64px !important; width: 64px !important; height: 64px !important; display: block; }++.arena-me-left > .arena-portrait-initial { display: flex; }@@@ -62,13 +62,14 @@</script><!-- Stylesheets -->-- <link rel="stylesheet" href="/css/style.css?v=156">++ <link rel="stylesheet" href="/css/style.css?v=157"><link rel="stylesheet" href="/css/chat-shared.css?v=1"><link rel="stylesheet" href="/css/broadcast.css?v=11"><link rel="stylesheet" href="/css/cosmetics.css?v=2"><link rel="stylesheet" href="/css/messenger.css?v=1"><link rel="stylesheet" href="/css/home-fx.css?v=4"><link rel="stylesheet" href="/css/home-tour.css?v=1">++ <link rel="stylesheet" href="/css/broadcast-guide.css?v=1"><link rel="stylesheet" href="/css/i18n-star.css?v=3"><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">@@@ -5109,7 +5110,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig<script src="/js/broadcast-media-setup.js?v=1" defer></script><script src="/js/broadcast-devices.js?v=2" defer></script><script src="/js/broadcast-vods.js?v=2" defer></script>--<script src="/js/broadcast-workspace.js?v=15" defer></script>++<script src="/js/broadcast-workspace.js?v=16" defer></script><script src="/js/broadcast.js?v=27" defer></script><script src="/js/call.js?v=3" defer></script><script src="/js/voice-channels.js?v=2" defer></script>@@@ -5123,7 +5124,8 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig<script src="/js/draggable-fab.js?v=2" defer></script><script src="/js/messenger.js?v=2" defer></script><script src="/js/home-fx.js?v=2" defer></script>--<script src="/js/home-tour.js?v=1" defer></script>++<script src="/js/home-tour.js?v=2" defer></script>++<script src="/js/broadcast-guide.js?v=1" defer></script><!-- ── OpenVibe shared libs (notifications, account sync, themes) ── --><script src="/shared/theme-loader.js" defer></script>nnew file mode 100644@@@ -0,0 +1,125 @@++/**++ * broadcast-guide.js — guided setup on the Go Live page.++ *++ * /broadcast?setup=restream → walks a streamer through restreaming: create a slot if they++ * have none, open Restream Destinations, add RobotStreamer with++ * the one-click "log in & fetch robots" path, add more platforms,++ * then Go Live. A spotlight + card overlay that follows the real++ * controls on the page (nothing is faked or duplicated).++ *++ * Logged out: the intent is remembered in localStorage, the user is sent through SSO, and the++ * guide resumes on the Go Live page after login.++ */++(function () {++ 'use strict';++ const PENDING_KEY = 'ov-guide-pending';++ const esc = (s) => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));++ const sleep = (ms) => new Promise(r => setTimeout(r, ms));++ async function waitFor(fn, { timeout = 20000, every = 200 } = {}) { const t0 = Date.now(); while (Date.now() - t0 < timeout) { try { const v = fn(); if (v) return v; } catch { /* */ } await sleep(every); } return null; }++++ // ── Overlay ────────────────────────────────────────────────++ let ui = null, raf = 0;++ function ensureUi() {++ if (ui) return ui;++ const root = document.createElement('div');++ root.className = 'bg-guide';++ root.innerHTML = `<div class="bg-guide-hole"></div><div class="bg-guide-card" role="dialog" aria-live="polite"><div class="bg-guide-step"></div><h4 class="bg-guide-title"></h4><p class="bg-guide-text"></p><div class="bg-guide-actions"></div></div>`;++ document.body.appendChild(root);++ ui = { root, hole: root.querySelector('.bg-guide-hole'), card: root.querySelector('.bg-guide-card'), step: root.querySelector('.bg-guide-step'), title: root.querySelector('.bg-guide-title'), text: root.querySelector('.bg-guide-text'), actions: root.querySelector('.bg-guide-actions'), target: null };++ const track = () => { if (!ui) return; place(); raf = requestAnimationFrame(track); };++ raf = requestAnimationFrame(track);++ return ui;++ }++ function place() {++ if (!ui) return;++ const t = ui.target;++ if (!t || !t.isConnected) { ui.hole.style.opacity = '0'; ui.card.style.top = '50%'; ui.card.style.left = '50%'; ui.card.style.transform = 'translate(-50%, -50%)'; return; }++ const r = t.getBoundingClientRect();++ ui.hole.style.opacity = '1';++ ui.hole.style.left = `${r.left - 8}px`; ui.hole.style.top = `${r.top - 8}px`; ui.hole.style.width = `${r.width + 16}px`; ui.hole.style.height = `${r.height + 16}px`;++ const cw = Math.min(380, window.innerWidth - 24), ch = ui.card.offsetHeight || 180;++ let top = r.bottom + 16, left = Math.max(12, Math.min(r.left, window.innerWidth - cw - 12));++ if (top + ch > window.innerHeight - 12) top = Math.max(12, r.top - ch - 16);++ ui.card.style.transform = 'none'; ui.card.style.top = `${top}px`; ui.card.style.left = `${left}px`; ui.card.style.width = `${cw}px`;++ }++ function show({ target, step, total, title, text, actions }) {++ const u = ensureUi();++ u.target = target || null;++ if (target) { try { target.scrollIntoView({ block: 'center', behavior: 'smooth' }); } catch { /* */ } }++ u.step.textContent = `Step ${step} of ${total}`;++ u.title.textContent = title;++ u.text.innerHTML = text;++ u.actions.innerHTML = '';++ for (const a of actions) { const b = document.createElement('button'); b.type = 'button'; b.className = `btn ${a.primary ? 'btn-primary' : 'btn-outline'} btn-sm`; b.innerHTML = a.label; b.onclick = a.onClick; u.actions.appendChild(b); }++ u.root.classList.add('is-on');++ place();++ }++ function close() { if (!ui) return; cancelAnimationFrame(raf); ui.root.remove(); ui = null; }++++ // ── The restream guide ─────────────────────────────────────++ async function runRestreamGuide() {++ if (!window.currentUser) { try { localStorage.setItem(PENDING_KEY, 'restream'); } catch { /* */ } window.location.href = '/api/auth/sso/login'; return; }++ const total = 5;++ const skip = { label: 'Skip', onClick: close };++ // Wait for the workspace to initialise.++ await waitFor(() => typeof _wsState !== 'undefined' && document.getElementById('bc-ws-list'));++ await sleep(400);++++ // 1. A stream slot++ if (typeof _wsState !== 'undefined' && !(_wsState.managedStreams || []).length) {++ const btn = document.querySelector('#bc-ws-empty button') || document.getElementById('bc-ws-empty');++ show({ target: btn, step: 1, total, title: 'First, a stream slot', text: 'A slot is one stream you run — its own key, settings, VODs and restream destinations. Make one (name it anything), then we\'ll wire up the restreams.', actions: [{ label: '<i class="fa-solid fa-plus"></i> Create a slot', primary: true, onClick: () => { try { showCreateManagedStreamModal(); } catch { /* */ } } }, skip] });++ const ok = await waitFor(() => (_wsState.managedStreams || []).length > 0 && document.getElementById('bc-ws-restream-details'), { timeout: 10 * 60 * 1000, every: 400 });++ if (!ok || !ui) return;++ } else {++ await waitFor(() => document.getElementById('bc-ws-restream-details'));++ }++ if (!ui && !ensureUi()) return;++++ // 2. Restream destinations++ const details = document.getElementById('bc-ws-restream-details');++ if (details) details.open = true;++ await new Promise(r => { show({ target: details, step: 2, total, title: 'Restream destinations', text: 'This is where one stream fans out. Everything you add here gets your OpenVibe stream mirrored to it — Twitch, YouTube, Kick, any RTMP server, and RobotStreamer over its own connection. Your chat, emotes, sound commands and controls stay here.', actions: [{ label: 'Next <i class="fa-solid fa-arrow-right"></i>', primary: true, onClick: r }, skip] }); });++ if (!ui) return;++++ // 3. RobotStreamer, the easy way++ const addBtn = details ? details.querySelector('button') : null;++ await new Promise(r => { show({ target: addBtn, step: 3, total, title: 'Add RobotStreamer', text: 'Pick <b>RobotStreamer</b> as the platform, then use <b>Log in & fetch robots</b>: your RobotStreamer username and password are used once to grab your token and your robot list — nothing is stored. Pick the robot, save. Done.', actions: [{ label: '<i class="fa-solid fa-robot"></i> Add RobotStreamer now', primary: true, onClick: async () => { try { _wsAddRestreamDest(); await sleep(150); const sel = document.getElementById('ws-rs-platform'); if (sel) { sel.value = 'robotstreamer'; _wsRestreamPlatformChanged(); } const u = await waitFor(() => document.getElementById('ws-rs-rs-username'), { timeout: 3000 }); if (u) { u.focus(); if (ui) { ui.target = u.closest('.form-group') || u; ui.title.textContent = 'Log in & fetch robots'; ui.text.innerHTML = 'Enter your RobotStreamer username and password, hit <b>Log in & fetch robots</b>, pick your robot from the list, then <b>Save</b>. The guide continues when the dialog closes.'; ui.actions.innerHTML = ''; } } } catch (e) { console.warn('[Guide]', e.message); } r(); } }, { label: 'I\'ll do it later', onClick: r }, skip] }); });++ if (!ui) return;++ // Wait for the dialog (if opened) to close.++ await waitFor(() => !document.querySelector('.bc-ws-confirm-overlay'), { timeout: 15 * 60 * 1000, every: 300 });++ if (!ui) return;++++ // 4. More platforms++ await new Promise(r => { show({ target: addBtn, step: 4, total, title: 'Twitch, YouTube, Kick, RTMP', text: 'Same button, different platform: paste the stream key from each service. Every destination has its own <b>Auto-start</b> switch and its own <b>→ PowerChat</b> switch, so you decide what starts with the stream and whose chat lands on your overlay.', actions: [{ label: 'Next <i class="fa-solid fa-arrow-right"></i>', primary: true, onClick: r }, skip] }); });++ if (!ui) return;++++ // 5. Go live++ const golive = document.getElementById('bc-ws-golive-btn') || document.getElementById('bc-ws-golive-section');++ await new Promise(r => { show({ target: golive, step: 5, total, title: 'Go live', text: 'Hit Go Live from this slot. Destinations with Auto-start fire the moment you\'re up; the rest you toggle from the live controls. Your viewers on every platform see one stream, and every chat comes back here.', actions: [{ label: '<i class="fa-solid fa-check"></i> Got it', primary: true, onClick: r }] }); });++ close();++ try { localStorage.removeItem(PENDING_KEY); } catch { /* */ }++ }++++ // ── Entry points ───────────────────────────────────────────++ function wanted() { try { return new URLSearchParams(location.search).get('setup'); } catch { return null; } }++ async function boot() {++ // Resume after SSO: the intent was saved before the redirect.++ let pending = null; try { pending = localStorage.getItem(PENDING_KEY); } catch { /* */ }++ if (pending === 'restream' && !wanted()) {++ const user = await waitFor(() => window.currentUser, { timeout: 8000, every: 250 });++ if (user) { try { localStorage.removeItem(PENDING_KEY); } catch { /* */ } if (typeof navigate === 'function') navigate('/broadcast?setup=restream'); else location.href = '/broadcast?setup=restream'; }++ return;++ }++ if (wanted() === 'restream') {++ await waitFor(() => window.currentUser !== undefined && document.getElementById('page-broadcast')?.classList.contains('active'), { timeout: 8000 });++ await waitFor(() => typeof window.currentUser !== 'undefined' || window._userLoaded, { timeout: 6000 });++ runRestreamGuide().catch(e => console.warn('[Guide]', e.message));++ }++ }++ // Also react to in-app navigation to /broadcast?setup=restream (SPA pushState).++ const _push = history.pushState;++ history.pushState = function () { const r = _push.apply(this, arguments); setTimeout(() => { if (wanted() === 'restream' && !ui) boot(); }, 50); return r; };++ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot); else boot();++ window.startRestreamGuide = () => { if (typeof navigate === 'function') navigate('/broadcast?setup=restream'); else location.href = '/broadcast?setup=restream'; };++})();@@@ -787,7 +787,7 @@ function _wsRenderPanel() {</details><!-- Restream Destinations -->-- <details class="bc-ws-slot-settings" open>++ <details class="bc-ws-slot-settings" id="bc-ws-restream-details" open><summary><i class="fa-solid fa-tower-broadcast"></i> Restream Destinations</summary><div class="bc-ws-slot-settings-inner"><p class="muted" style="font-size:0.82rem;margin-bottom:10px">@@@ -46,7 +46,7 @@</div><div class="tour-actions"><a class="btn btn-primary btn-lg" ${go('/broadcast')}><i class="fa-solid fa-tower-broadcast"></i> Go live</a>-- <a class="btn btn-outline btn-lg" ${go('/docs/broadcasting')}><i class="fa-solid fa-book"></i> Restream setup</a>++ <a class="btn btn-outline btn-lg" href="/broadcast?setup=restream" onclick="event.preventDefault(); startRestreamGuide();"><i class="fa-solid fa-satellite-dish"></i> Set up restreams (guided)</a></div></div>`;}@@@ -95,7 +95,7 @@ async function backfillFighter(uid, roster, budget) {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) {++ if (j.is_trash_talk && j.quality >= L.MIC_MIN_QUALITY && !mic().isDuplicate(uid, j.best_line)) {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++;@@@ -27,8 +27,9 @@ const llm = require('../ai/llm');const TICK_MS = 15 * 1000;const JUDGE_MIN_WORDS = 20;const JUDGE_MIN_INTERVAL_MS = 30 * 1000;--const MIC_MIN_QUALITY = 4; // free talk must be at least this spicy to land in the feed--const CALLOUT_MIN_QUALITY = 5; // …and this spicy for an aimed_at name to open a beef++const MIC_MIN_QUALITY = 6; // free talk must be at least this good to land in the feed (bangers only)++const CALLOUT_MIN_QUALITY = 6; // …and this good for an aimed_at name to open a beef++const MIN_LINE_WORDS = 6; // a quotable line is a complete thought, not a fragmentconst BUFFER_MAX_CHARS = 1400;const state = new Map(); // streamId → { userId, lastOffset, lastJudgeAt, focus, mic: { lines } }@@@ -39,6 +40,14 @@ function beef() { return require('./beef'); }function mic() { return require('./mic'); }function parseJson(t, f = null) { try { return t ? JSON.parse(t) : f; } catch { return f; } }function words(t) { return String(t || '').split(/\s+/).filter(Boolean).length; }++/** Tidy a quoted line: collapse whitespace, strip transcript markers, cap length, sentence case + period. */++function cleanLine(t) {++ let s = String(t || '').replace(/^\s*(?:>>|--?)\s*/, '').replace(/\s+/g, ' ').trim().slice(0, 220);++ if (!s) return '';++ s = s.charAt(0).toUpperCase() + s.slice(1);++ if (!/[.!?…"”']$/.test(s)) s += '.';++ return s;++}// ── Aliases: who can be called out, by which names ───────────const names = require('./names');@@@ -64,7 +73,7 @@ const BEEF_SCHEMA = {aimed_at_target: { type: 'boolean', description: 'true if the speaker is trash-talking, roasting, calling out, dunking on or bragging over the target (in good fun) — requires about_target' },announcer: { type: 'string', description: 'one-line ring-announcer call of this moment, ≤ 110 chars, hype and funny (empty if not aimed at the target)' },quality: { type: 'integer', minimum: 0, maximum: 10, description: 'how good the trash talk is: spice, wit, specificity, quotability' },-- best_line: { type: 'string', description: 'the single best line, VERBATIM from the speech (empty if none)' },++ best_line: { type: 'string', description: 'the single best line: a COMPLETE sentence of at least 6 words that reads correctly on its own, words as spoken (fix casing/punctuation, drop stutters and filler, never change or add words). Empty if no clean line exists — a garbled fragment must not be quoted' },about: { type: 'string', description: '≤ 10 words: what they said about the target' },flagged: { type: 'boolean', description: 'ONLY for direct threats of violence, sexual content about minors, or doxxing — never for offensive language' },},@@@ -76,11 +85,12 @@ const MIC_SCHEMA = {name: 'arena_mic_judgement',schema: {type: 'object', additionalProperties: false,-- required: ['is_trash_talk', 'quality', 'best_line', 'about', 'aimed_at', 'announcer', 'flagged'],++ required: ['is_trash_talk', 'garbled', 'quality', 'best_line', 'about', 'aimed_at', 'announcer', 'flagged'],properties: {-- is_trash_talk: { type: 'boolean', description: 'true ONLY if the speaker is actually talking shit: roasting, calling someone out, bragging over someone, ranting AT someone (chat, a group, a person), disrespect, trolling, "come see me" energy' },-- quality: { type: 'integer', minimum: 0, maximum: 10, description: 'how good the shit talk is: savage, specific, funny, quotable = high; lazy generic = low; 0 when not trash talk' },-- best_line: { type: 'string', description: 'the single best line, VERBATIM from the speech (empty if none)' },++ is_trash_talk: { type: 'boolean', description: 'true ONLY if the speaker is actually talking shit: roasting, calling someone out, bragging over someone, ranting AT someone (chat, a group, a person), disrespect, trolling, "come see me" energy — AND there is a clean, complete, quotable line for it' },++ garbled: { type: 'boolean', description: 'true if the transcript is too mangled (misheard words, word salad, fragments) to quote a coherent line — then is_trash_talk must be false' },++ quality: { type: 'integer', minimum: 0, maximum: 10, description: 'how good the shit talk is: 8–10 = a stranger would laugh or screenshot it; 6–7 = solid, specific, quotable; ≤5 = generic, lazy or half a thought; 0 when not trash talk' },++ best_line: { type: 'string', description: 'the single best line: a COMPLETE sentence of at least 6 words that reads correctly on its own. Copy the words as spoken — you may fix casing and punctuation and drop stutters / filler (uh, um, repeated words) but never change, add or reorder words. Empty if no clean line exists' },about: { type: 'string', description: '≤ 10 words: what the shit talk was about' },aimed_at: { type: 'string', description: 'who or what it is aimed at, as said or clearly implied: a streamer name, "chat", "the mods", "twitch streamers", a game, "nobody" — ≤ 6 words, lowercase' },announcer: { type: 'string', description: 'one-line ring-announcer call of the moment, ≤ 110 chars, hype and funny (empty if not trash talk)' },@@@ -88,7 +98,7 @@ const MIC_SCHEMA = {},},};--const MIC_SYSTEM = `You judge a live streamer's raw mic chatter for BATTLE-CAM style shit talk. You get ~60 seconds of speech-to-text (expect noise). DEFAULT TO is_trash_talk=false: gameplay narration, reading chat, small talk, "um", stories, neutral opinions → false, quality 0. Say true only when they are genuinely talking shit — roasting someone, calling someone out, bragging over someone, ranting AT chat or a group, disrespect, trolling, "pull up" energy. When true: score the craft (savage, specific, petty, funny, quotable = high; generic = low), pick the best verbatim line, say who it is aimed at (a name if one is said or clearly meant, else "chat", "the mods", a group, a game, or "nobody"), summarise in ≤ 10 words, and write a one-line ring-announcer call. Offensive, provocative and taboo language — slurs included — is allowed on this platform and is NOT a reason to flag or to score low; judge the craft. Flag ONLY behavior: direct threats of violence, sexual content about minors, doxxing. Output only the JSON.`;++const MIC_SYSTEM = `You judge a live streamer's raw mic chatter for BATTLE-CAM style shit talk. You get ~60–90 seconds of speech-to-text — it is noisy: names get misheard, words get mangled, sentences get cut. DEFAULT TO is_trash_talk=false: gameplay narration, reading chat, small talk, "um", stories, neutral opinions → false, quality 0. Say true only when they are genuinely talking shit — roasting someone, calling someone out, bragging over someone, ranting AT chat or a group, disrespect, trolling, "pull up" energy — AND you can quote a clean, complete line for it. QUOTABILITY IS THE BAR: best_line must be a full sentence of at least 6 words that a reader understands with no context. If the good part is garbled or only a fragment, set garbled=true and is_trash_talk=false rather than quoting nonsense; a half-line in the feed is worse than nothing. Score the craft honestly: 8–10 only for lines a stranger would laugh at or screenshot; 6–7 solid and specific; 5 or below generic or lazy. Say who it is aimed at (a name if one is said or clearly meant, else "chat", "the mods", a group, a game, or "nobody"), summarise in ≤ 10 words, and write a one-line ring-announcer call. Offensive, provocative and taboo language — slurs included — is allowed on this platform and is NOT a reason to flag or to score low; judge the craft. Flag ONLY behavior: direct threats of violence, sexual content about minors, doxxing. Output only the JSON.`;const SPICY = /\b(clown|clowns|weak|scared|duck|ducking|ducked|trash|garbage|mid|washed|bum|bums|ratio|cook|cooked|better than|can't|cannot|never|nobody|beat|fraud|frauds|ass|bet|catch (these|this)|come see|pull up|fight me|square up|run it|talk (that|your)|cope|seethe|cry|loser|losers|bozo|bozos|goofy|fake|scam|dogshit|shit at|suck|sucks|pathetic|embarrassing|sit down|shut up|nobody cares|delusional|coward|cowards)\b/;function heuristicBeef(text, targetNames, { named = true } = {}) {@@@ -112,7 +122,7 @@ function heuristicMic(text) {const sentences = text.split(/(?<=[.!?])\s+/).filter(Boolean);const spicyLines = sentences.filter(l => SPICY.test(l.toLowerCase()));const pick = (spicyLines.length ? spicyLines : sentences).sort((a, b) => b.length - a.length)[0] || text;-- return { is_trash_talk: spicy, quality, best_line: pick.trim().slice(0, 200), about: text.split(/\s+/).slice(0, 8).join(' '), aimed_at: aimed, announcer: '', flagged: false, _fallback: true };++ return { is_trash_talk: spicy && words(pick) >= MIN_LINE_WORDS, garbled: false, quality, best_line: pick.trim().slice(0, 200), about: text.split(/\s+/).slice(0, 8).join(' '), aimed_at: aimed, announcer: '', flagged: false, _fallback: true };}async function judgeBeef(speakerId, targetId, text, roster, { context = null, named = true, how = 'exact' } = {}) {@@@ -131,7 +141,10 @@ async function judgeBeef(speakerId, targetId, text, roster, { context = null, na}if (!j) j = heuristicBeef(text, spokenForms.length ? spokenForms : targetNames.map(n => n.toLowerCase()), { named });const about = (j.about_target !== false) && !j.flagged;-- return { about_target: about, aimed_at_target: about && !!j.aimed_at_target, quality: Math.max(0, Math.min(10, Math.round(Number(j.quality) || 0))), best_line: String(j.best_line || '').slice(0, 220), about: String(j.about || '').slice(0, 80), announcer: String(j.announcer || '').slice(0, 140), flagged: !!j.flagged, fallback: !!j._fallback };++ const line = cleanLine(j.best_line);++ // A hit needs a quotable line; a beef fed by fragments reads like nonsense on the page.++ const aimed = about && !!j.aimed_at_target && words(line) >= MIN_LINE_WORDS;++ return { about_target: about, aimed_at_target: aimed, quality: aimed ? Math.max(0, Math.min(10, Math.round(Number(j.quality) || 0))) : 0, best_line: line, about: String(j.about || '').slice(0, 80), announcer: String(j.announcer || '').slice(0, 140), flagged: !!j.flagged, fallback: !!j._fallback };}async function judgeMic(speakerId, text) {@@@ -144,7 +157,9 @@ async function judgeMic(speakerId, text) {} catch (e) { console.warn('[Arena] mic judge:', e.message); }}if (!j) j = heuristicMic(text);-- return { is_trash_talk: !!j.is_trash_talk && !j.flagged, quality: Math.max(0, Math.min(10, Math.round(Number(j.quality) || 0))), best_line: String(j.best_line || '').slice(0, 220), about: String(j.about || '').slice(0, 80), aimed_at: String(j.aimed_at || '').toLowerCase().slice(0, 60), announcer: String(j.announcer || '').slice(0, 140), flagged: !!j.flagged, fallback: !!j._fallback };++ const line = cleanLine(j.best_line);++ const ok = !!j.is_trash_talk && !j.flagged && !j.garbled && words(line) >= MIN_LINE_WORDS;++ return { is_trash_talk: ok, garbled: !!j.garbled, quality: ok ? Math.max(0, Math.min(10, Math.round(Number(j.quality) || 0))) : 0, best_line: line, about: String(j.about || '').slice(0, 80), aimed_at: String(j.aimed_at || '').toLowerCase().slice(0, 60), announcer: String(j.announcer || '').slice(0, 140), flagged: !!j.flagged, fallback: !!j._fallback };}// ── Tick ─────────────────────────────────────────────────────@@@ -219,6 +234,7 @@ async function judgeFreeTalk(stream, roster, st, events) {st.lastMicJudgement = { at: new Date().toISOString(), ...j };if (!j.is_trash_talk || j.quality < MIC_MIN_QUALITY) { events.push({ kind: 'mic_miss', streamId: stream.id, speakerId: stream.user_id, about: j.about }); return; }const ref = lineRefFor(lines, j.best_line);++ if (mic().isDuplicate(stream.user_id, j.best_line)) { events.push({ kind: 'mic_dupe', streamId: stream.id, speakerId: stream.user_id }); return; }// "aimed_at" that resolves to a roster fighter = a callout → it feeds a beef exactly like a name-drop.const target = j.aimed_at ? mentionsDetailed(j.aimed_at, stream.user_id, roster)[0] : null;if (target && j.quality >= CALLOUT_MIN_QUALITY) {@@@ -310,4 +326,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, SPICY, _mentionsIn: mentionsIn, _mentionsDetailed: mentionsDetailed, _aliases: aliases, _heuristicBeef: heuristicBeef, _heuristicMic: heuristicMic, _judgeBeef: judgeBeef, _judgeMic: judgeMic, _state: state };++module.exports = { start, stop, tick, consoleState, TICK_MS, JUDGE_MIN_WORDS, JUDGE_MIN_INTERVAL_MS, MIC_MIN_QUALITY, CALLOUT_MIN_QUALITY, MIN_LINE_WORDS, cleanLine, 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 };@@@ -172,6 +172,15 @@ function addMoment({ userId, streamId = null, vodId = null, sec = null, kind = 'return db.get('SELECT * FROM arena_mic_moments WHERE id = ?', [id]);}++/** Same line (or near enough) from the same fighter in the last 6 h → don't file it twice. */++function isDuplicate(userId, text) {++ ensureTables();++ const norm = String(text || '').toLowerCase().replace(/[^a-z0-9 ]/g, '').replace(/\s+/g, ' ').trim();++ if (!norm) return false;++ const rows = db.all(`SELECT text FROM arena_mic_moments WHERE user_id = ? AND created_at >= datetime('now', '-6 hours') ORDER BY id DESC LIMIT 40`, [userId]);++ return rows.some(r => { const t = String(r.text || '').toLowerCase().replace(/[^a-z0-9 ]/g, '').replace(/\s+/g, ' ').trim(); return t === norm || (t.length > 30 && (t.includes(norm) || norm.includes(t))); });++}++function momentView(m, roster) {const brief = fighterBrief(m.user_id, roster);return {@@@ -187,7 +196,8 @@ function feed({ limit = 40, since = null, userId = null } = {}) {ensureTables();const roster = arena().loadRoster();const params = [];-- let where = '1 = 1';++ // The feed shows bangers only; weak lines still count for stats/XP but never headline the page.++ let where = 'quality >= 5';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)));@@@ -229,6 +239,6 @@ function micStats(userId, days = 30) {module.exports = {ensureTables, addXp, recentXp, levelView, levelFor, levelRow, levelsLeaderboard, nameOf, fighterBrief,-- addMoment, momentView, feed, momentsFor, bestLines, latestFor, micStats,++ addMoment, momentView, feed, momentsFor, bestLines, latestFor, micStats, isDuplicate,XP_PER_LEVEL, XP_MOMENT, XP_HYPE,};
new file mode 100644
new file mode 100644
@@ -0,0 +1,21 @@
@@ -0,0 +1,21 @@
+/* ── Guided setup overlay on the Go Live page (public/js/broadcast-guide.js) ── */
+.bg-guide { position: fixed; inset: 0; z-index: 20500; pointer-events: none; opacity: 0; transition: opacity 0.3s; }
+.bg-guide.is-on { opacity: 1; }
+.bg-guide-hole {
+ position: fixed; border-radius: 14px; pointer-events: none;
+ box-shadow: 0 0 0 9999px rgba(3, 3, 8, 0.68), 0 0 0 3px var(--accent), 0 0 30px color-mix(in srgb, var(--accent) 60%, transparent);
+ transition: left 0.25s ease, top 0.25s ease, width 0.25s ease, height 0.25s ease, opacity 0.25s;
+}
+.bg-guide-card {
+ position: fixed; pointer-events: auto; width: 380px; max-width: calc(100vw - 24px);
+ padding: 16px 18px; border-radius: 16px; color: #fff;
+ background: linear-gradient(160deg, #17132b, #0c0c12 60%); border: 1px solid rgba(255,255,255,0.14);
+ box-shadow: 0 30px 70px rgba(0,0,0,0.6), 0 0 0 1px color-mix(in srgb, var(--accent) 35%, transparent);
+ transition: top 0.25s ease, left 0.25s ease;
+}
+.bg-guide-step { font-size: 0.68rem; font-weight: 800; letter-spacing: 0.12em; text-transform: uppercase; color: var(--accent-light); margin-bottom: 4px; }
+.bg-guide-title { margin: 0 0 6px; font-size: 1.05rem; font-weight: 900; }
+.bg-guide-text { margin: 0 0 12px; font-size: 0.86rem; line-height: 1.5; color: #e2e8f0; }
+.bg-guide-text b { color: #fff; }
+.bg-guide-actions { display: flex; flex-wrap: wrap; gap: 8px; }
+@media (prefers-reduced-motion: reduce) { .bg-guide, .bg-guide-hole, .bg-guide-card { transition: none; } }
@@ -14722,3 +14722,7 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg
@@ -14722,3 +14722,7 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg
.arena-cam-thumb { aspect-ratio: 16/9; }
.arena-cam-thumb { aspect-ratio: 16/9; }
}
}
@media (prefers-reduced-motion: reduce) { .arena-mic-moment { animation: none; } }
@media (prefers-reduced-motion: reduce) { .arena-mic-moment { animation: none; } }
+
+/* Arena "your arena" strip: the portrait is a div, so the generic `> div` flex rule was stretching it */
+.arena-me-left > .arena-portrait { flex: 0 0 64px !important; width: 64px !important; height: 64px !important; display: block; }
+.arena-me-left > .arena-portrait-initial { display: flex; }
@@ -62,13 +62,14 @@
@@ -62,13 +62,14 @@
</script>
</script>
<!-- Stylesheets -->
<!-- Stylesheets -->
- <link rel="stylesheet" href="/css/style.css?v=156">
+ <link rel="stylesheet" href="/css/style.css?v=157">
<link rel="stylesheet" href="/css/chat-shared.css?v=1">
<link rel="stylesheet" href="/css/chat-shared.css?v=1">
<link rel="stylesheet" href="/css/broadcast.css?v=11">
<link rel="stylesheet" href="/css/broadcast.css?v=11">
<link rel="stylesheet" href="/css/cosmetics.css?v=2">
<link rel="stylesheet" href="/css/cosmetics.css?v=2">
<link rel="stylesheet" href="/css/messenger.css?v=1">
<link rel="stylesheet" href="/css/messenger.css?v=1">
<link rel="stylesheet" href="/css/home-fx.css?v=4">
<link rel="stylesheet" href="/css/home-fx.css?v=4">
<link rel="stylesheet" href="/css/home-tour.css?v=1">
<link rel="stylesheet" href="/css/home-tour.css?v=1">
+ <link rel="stylesheet" href="/css/broadcast-guide.css?v=1">
<link rel="stylesheet" href="/css/i18n-star.css?v=3">
<link rel="stylesheet" href="/css/i18n-star.css?v=3">
<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">
@@ -5109,7 +5110,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
@@ -5109,7 +5110,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<script src="/js/broadcast-media-setup.js?v=1" defer></script>
<script src="/js/broadcast-media-setup.js?v=1" defer></script>
<script src="/js/broadcast-devices.js?v=2" defer></script>
<script src="/js/broadcast-devices.js?v=2" defer></script>
<script src="/js/broadcast-vods.js?v=2" defer></script>
<script src="/js/broadcast-vods.js?v=2" defer></script>
-<script src="/js/broadcast-workspace.js?v=15" defer></script>
+<script src="/js/broadcast-workspace.js?v=16" defer></script>
<script src="/js/broadcast.js?v=27" defer></script>
<script src="/js/broadcast.js?v=27" defer></script>
<script src="/js/call.js?v=3" defer></script>
<script src="/js/call.js?v=3" defer></script>
<script src="/js/voice-channels.js?v=2" defer></script>
<script src="/js/voice-channels.js?v=2" defer></script>
@@ -5123,7 +5124,8 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
@@ -5123,7 +5124,8 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<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/home-fx.js?v=2" defer></script>
<script src="/js/home-fx.js?v=2" defer></script>
-<script src="/js/home-tour.js?v=1" defer></script>
+<script src="/js/home-tour.js?v=2" defer></script>
+<script src="/js/broadcast-guide.js?v=1" 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>
new file mode 100644
new file mode 100644
@@ -0,0 +1,125 @@
@@ -0,0 +1,125 @@
+/**
+ * broadcast-guide.js — guided setup on the Go Live page.
+ *
+ * /broadcast?setup=restream → walks a streamer through restreaming: create a slot if they
+ * have none, open Restream Destinations, add RobotStreamer with
+ * the one-click "log in & fetch robots" path, add more platforms,
+ * then Go Live. A spotlight + card overlay that follows the real
+ * controls on the page (nothing is faked or duplicated).
+ *
+ * Logged out: the intent is remembered in localStorage, the user is sent through SSO, and the
+ * guide resumes on the Go Live page after login.
+ */
+(function () {
+ 'use strict';
+ const PENDING_KEY = 'ov-guide-pending';
+ const esc = (s) => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
+ const sleep = (ms) => new Promise(r => setTimeout(r, ms));
+ async function waitFor(fn, { timeout = 20000, every = 200 } = {}) { const t0 = Date.now(); while (Date.now() - t0 < timeout) { try { const v = fn(); if (v) return v; } catch { /* */ } await sleep(every); } return null; }
+
+ // ── Overlay ────────────────────────────────────────────────
+ let ui = null, raf = 0;
+ function ensureUi() {
+ if (ui) return ui;
+ const root = document.createElement('div');
+ root.className = 'bg-guide';
+ root.innerHTML = `<div class="bg-guide-hole"></div><div class="bg-guide-card" role="dialog" aria-live="polite"><div class="bg-guide-step"></div><h4 class="bg-guide-title"></h4><p class="bg-guide-text"></p><div class="bg-guide-actions"></div></div>`;
+ document.body.appendChild(root);
+ ui = { root, hole: root.querySelector('.bg-guide-hole'), card: root.querySelector('.bg-guide-card'), step: root.querySelector('.bg-guide-step'), title: root.querySelector('.bg-guide-title'), text: root.querySelector('.bg-guide-text'), actions: root.querySelector('.bg-guide-actions'), target: null };
+ const track = () => { if (!ui) return; place(); raf = requestAnimationFrame(track); };
+ raf = requestAnimationFrame(track);
+ return ui;
+ }
+ function place() {
+ if (!ui) return;
+ const t = ui.target;
+ if (!t || !t.isConnected) { ui.hole.style.opacity = '0'; ui.card.style.top = '50%'; ui.card.style.left = '50%'; ui.card.style.transform = 'translate(-50%, -50%)'; return; }
+ const r = t.getBoundingClientRect();
+ ui.hole.style.opacity = '1';
+ ui.hole.style.left = `${r.left - 8}px`; ui.hole.style.top = `${r.top - 8}px`; ui.hole.style.width = `${r.width + 16}px`; ui.hole.style.height = `${r.height + 16}px`;
+ const cw = Math.min(380, window.innerWidth - 24), ch = ui.card.offsetHeight || 180;
+ let top = r.bottom + 16, left = Math.max(12, Math.min(r.left, window.innerWidth - cw - 12));
+ if (top + ch > window.innerHeight - 12) top = Math.max(12, r.top - ch - 16);
+ ui.card.style.transform = 'none'; ui.card.style.top = `${top}px`; ui.card.style.left = `${left}px`; ui.card.style.width = `${cw}px`;
+ }
+ function show({ target, step, total, title, text, actions }) {
+ const u = ensureUi();
+ u.target = target || null;
+ if (target) { try { target.scrollIntoView({ block: 'center', behavior: 'smooth' }); } catch { /* */ } }
+ u.step.textContent = `Step ${step} of ${total}`;
+ u.title.textContent = title;
+ u.text.innerHTML = text;
+ u.actions.innerHTML = '';
+ for (const a of actions) { const b = document.createElement('button'); b.type = 'button'; b.className = `btn ${a.primary ? 'btn-primary' : 'btn-outline'} btn-sm`; b.innerHTML = a.label; b.onclick = a.onClick; u.actions.appendChild(b); }
+ u.root.classList.add('is-on');
+ place();
+ }
+ function close() { if (!ui) return; cancelAnimationFrame(raf); ui.root.remove(); ui = null; }
+
+ // ── The restream guide ─────────────────────────────────────
+ async function runRestreamGuide() {
+ if (!window.currentUser) { try { localStorage.setItem(PENDING_KEY, 'restream'); } catch { /* */ } window.location.href = '/api/auth/sso/login'; return; }
+ const total = 5;
+ const skip = { label: 'Skip', onClick: close };
+ // Wait for the workspace to initialise.
+ await waitFor(() => typeof _wsState !== 'undefined' && document.getElementById('bc-ws-list'));
+ await sleep(400);
+
+ // 1. A stream slot
+ if (typeof _wsState !== 'undefined' && !(_wsState.managedStreams || []).length) {
+ const btn = document.querySelector('#bc-ws-empty button') || document.getElementById('bc-ws-empty');
+ show({ target: btn, step: 1, total, title: 'First, a stream slot', text: 'A slot is one stream you run — its own key, settings, VODs and restream destinations. Make one (name it anything), then we\'ll wire up the restreams.', actions: [{ label: '<i class="fa-solid fa-plus"></i> Create a slot', primary: true, onClick: () => { try { showCreateManagedStreamModal(); } catch { /* */ } } }, skip] });
+ const ok = await waitFor(() => (_wsState.managedStreams || []).length > 0 && document.getElementById('bc-ws-restream-details'), { timeout: 10 * 60 * 1000, every: 400 });
+ if (!ok || !ui) return;
+ } else {
+ await waitFor(() => document.getElementById('bc-ws-restream-details'));
+ }
+ if (!ui && !ensureUi()) return;
+
+ // 2. Restream destinations
+ const details = document.getElementById('bc-ws-restream-details');
+ if (details) details.open = true;
+ await new Promise(r => { show({ target: details, step: 2, total, title: 'Restream destinations', text: 'This is where one stream fans out. Everything you add here gets your OpenVibe stream mirrored to it — Twitch, YouTube, Kick, any RTMP server, and RobotStreamer over its own connection. Your chat, emotes, sound commands and controls stay here.', actions: [{ label: 'Next <i class="fa-solid fa-arrow-right"></i>', primary: true, onClick: r }, skip] }); });
+ if (!ui) return;
+
+ // 3. RobotStreamer, the easy way
+ const addBtn = details ? details.querySelector('button') : null;
+ await new Promise(r => { show({ target: addBtn, step: 3, total, title: 'Add RobotStreamer', text: 'Pick <b>RobotStreamer</b> as the platform, then use <b>Log in & fetch robots</b>: your RobotStreamer username and password are used once to grab your token and your robot list — nothing is stored. Pick the robot, save. Done.', actions: [{ label: '<i class="fa-solid fa-robot"></i> Add RobotStreamer now', primary: true, onClick: async () => { try { _wsAddRestreamDest(); await sleep(150); const sel = document.getElementById('ws-rs-platform'); if (sel) { sel.value = 'robotstreamer'; _wsRestreamPlatformChanged(); } const u = await waitFor(() => document.getElementById('ws-rs-rs-username'), { timeout: 3000 }); if (u) { u.focus(); if (ui) { ui.target = u.closest('.form-group') || u; ui.title.textContent = 'Log in & fetch robots'; ui.text.innerHTML = 'Enter your RobotStreamer username and password, hit <b>Log in & fetch robots</b>, pick your robot from the list, then <b>Save</b>. The guide continues when the dialog closes.'; ui.actions.innerHTML = ''; } } } catch (e) { console.warn('[Guide]', e.message); } r(); } }, { label: 'I\'ll do it later', onClick: r }, skip] }); });
+ if (!ui) return;
+ // Wait for the dialog (if opened) to close.
+ await waitFor(() => !document.querySelector('.bc-ws-confirm-overlay'), { timeout: 15 * 60 * 1000, every: 300 });
+ if (!ui) return;
+
+ // 4. More platforms
+ await new Promise(r => { show({ target: addBtn, step: 4, total, title: 'Twitch, YouTube, Kick, RTMP', text: 'Same button, different platform: paste the stream key from each service. Every destination has its own <b>Auto-start</b> switch and its own <b>→ PowerChat</b> switch, so you decide what starts with the stream and whose chat lands on your overlay.', actions: [{ label: 'Next <i class="fa-solid fa-arrow-right"></i>', primary: true, onClick: r }, skip] }); });
+ if (!ui) return;
+
+ // 5. Go live
+ const golive = document.getElementById('bc-ws-golive-btn') || document.getElementById('bc-ws-golive-section');
+ await new Promise(r => { show({ target: golive, step: 5, total, title: 'Go live', text: 'Hit Go Live from this slot. Destinations with Auto-start fire the moment you\'re up; the rest you toggle from the live controls. Your viewers on every platform see one stream, and every chat comes back here.', actions: [{ label: '<i class="fa-solid fa-check"></i> Got it', primary: true, onClick: r }] }); });
+ close();
+ try { localStorage.removeItem(PENDING_KEY); } catch { /* */ }
+ }
+
+ // ── Entry points ───────────────────────────────────────────
+ function wanted() { try { return new URLSearchParams(location.search).get('setup'); } catch { return null; } }
+ async function boot() {
+ // Resume after SSO: the intent was saved before the redirect.
+ let pending = null; try { pending = localStorage.getItem(PENDING_KEY); } catch { /* */ }
+ if (pending === 'restream' && !wanted()) {
+ const user = await waitFor(() => window.currentUser, { timeout: 8000, every: 250 });
+ if (user) { try { localStorage.removeItem(PENDING_KEY); } catch { /* */ } if (typeof navigate === 'function') navigate('/broadcast?setup=restream'); else location.href = '/broadcast?setup=restream'; }
+ return;
+ }
+ if (wanted() === 'restream') {
+ await waitFor(() => window.currentUser !== undefined && document.getElementById('page-broadcast')?.classList.contains('active'), { timeout: 8000 });
+ await waitFor(() => typeof window.currentUser !== 'undefined' || window._userLoaded, { timeout: 6000 });
+ runRestreamGuide().catch(e => console.warn('[Guide]', e.message));
+ }
+ }
+ // Also react to in-app navigation to /broadcast?setup=restream (SPA pushState).
+ const _push = history.pushState;
+ history.pushState = function () { const r = _push.apply(this, arguments); setTimeout(() => { if (wanted() === 'restream' && !ui) boot(); }, 50); return r; };
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot); else boot();
+ window.startRestreamGuide = () => { if (typeof navigate === 'function') navigate('/broadcast?setup=restream'); else location.href = '/broadcast?setup=restream'; };
+})();
@@ -787,7 +787,7 @@ function _wsRenderPanel() {
@@ -787,7 +787,7 @@ function _wsRenderPanel() {
</details>
</details>
<!-- Restream Destinations -->
<!-- Restream Destinations -->
- <details class="bc-ws-slot-settings" open>
+ <details class="bc-ws-slot-settings" id="bc-ws-restream-details" open>
<summary><i class="fa-solid fa-tower-broadcast"></i> Restream Destinations</summary>
<summary><i class="fa-solid fa-tower-broadcast"></i> Restream Destinations</summary>
<div class="bc-ws-slot-settings-inner">
<div class="bc-ws-slot-settings-inner">
<p class="muted" style="font-size:0.82rem;margin-bottom:10px">
<p class="muted" style="font-size:0.82rem;margin-bottom:10px">
@@ -46,7 +46,7 @@
@@ -46,7 +46,7 @@
</div>
</div>
<div class="tour-actions">
<div class="tour-actions">
<a class="btn btn-primary btn-lg" ${go('/broadcast')}><i class="fa-solid fa-tower-broadcast"></i> Go live</a>
<a class="btn btn-primary btn-lg" ${go('/broadcast')}><i class="fa-solid fa-tower-broadcast"></i> Go live</a>
- <a class="btn btn-outline btn-lg" ${go('/docs/broadcasting')}><i class="fa-solid fa-book"></i> Restream setup</a>
+ <a class="btn btn-outline btn-lg" href="/broadcast?setup=restream" onclick="event.preventDefault(); startRestreamGuide();"><i class="fa-solid fa-satellite-dish"></i> Set up restreams (guided)</a>
</div>
</div>
</div>`;
</div>`;
}
}
@@ -95,7 +95,7 @@ async function backfillFighter(uid, roster, budget) {
@@ -95,7 +95,7 @@ async function backfillFighter(uid, roster, budget) {
if (j.about_target) continue; // neutral mention — not shit talk, and not free talk either
if (j.about_target) continue; // neutral mention — not shit talk, and not free talk either
}
}
const j = await L._judgeMic(uid, text);
const j = await L._judgeMic(uid, text);
- if (j.is_trash_talk && j.quality >= L.MIC_MIN_QUALITY) {
+ if (j.is_trash_talk && j.quality >= L.MIC_MIN_QUALITY && !mic().isDuplicate(uid, j.best_line)) {
const r = ref(j.best_line);
const r = ref(j.best_line);
const target = j.aimed_at ? L._mentionsDetailed(j.aimed_at, uid, roster)[0] : null;
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++;
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++;
@@ -27,8 +27,9 @@ const llm = require('../ai/llm');
@@ -27,8 +27,9 @@ const llm = require('../ai/llm');
const TICK_MS = 15 * 1000;
const TICK_MS = 15 * 1000;
const JUDGE_MIN_WORDS = 20;
const JUDGE_MIN_WORDS = 20;
const JUDGE_MIN_INTERVAL_MS = 30 * 1000;
const JUDGE_MIN_INTERVAL_MS = 30 * 1000;
-const MIC_MIN_QUALITY = 4; // free talk must be at least this spicy to land in the feed
-const CALLOUT_MIN_QUALITY = 5; // …and this spicy for an aimed_at name to open a beef
+const MIC_MIN_QUALITY = 6; // free talk must be at least this good to land in the feed (bangers only)
+const CALLOUT_MIN_QUALITY = 6; // …and this good for an aimed_at name to open a beef
+const MIN_LINE_WORDS = 6; // a quotable line is a complete thought, not a fragment
const BUFFER_MAX_CHARS = 1400;
const BUFFER_MAX_CHARS = 1400;
const state = new Map(); // streamId → { userId, lastOffset, lastJudgeAt, focus, mic: { lines } }
const state = new Map(); // streamId → { userId, lastOffset, lastJudgeAt, focus, mic: { lines } }
@@ -39,6 +40,14 @@ function beef() { return require('./beef'); }
@@ -39,6 +40,14 @@ function beef() { return require('./beef'); }
function mic() { return require('./mic'); }
function mic() { return require('./mic'); }
function parseJson(t, f = null) { try { return t ? JSON.parse(t) : f; } catch { return f; } }
function parseJson(t, f = null) { try { return t ? JSON.parse(t) : f; } catch { return f; } }
function words(t) { return String(t || '').split(/\s+/).filter(Boolean).length; }
function words(t) { return String(t || '').split(/\s+/).filter(Boolean).length; }
+/** Tidy a quoted line: collapse whitespace, strip transcript markers, cap length, sentence case + period. */
+function cleanLine(t) {
+ let s = String(t || '').replace(/^\s*(?:>>|--?)\s*/, '').replace(/\s+/g, ' ').trim().slice(0, 220);
+ if (!s) return '';
+ s = s.charAt(0).toUpperCase() + s.slice(1);
+ if (!/[.!?…"”']$/.test(s)) s += '.';
+ return s;
+}
// ── Aliases: who can be called out, by which names ───────────
// ── Aliases: who can be called out, by which names ───────────
const names = require('./names');
const names = require('./names');
@@ -64,7 +73,7 @@ const BEEF_SCHEMA = {
@@ -64,7 +73,7 @@ const BEEF_SCHEMA = {
aimed_at_target: { type: 'boolean', description: 'true if the speaker is trash-talking, roasting, calling out, dunking on or bragging over the target (in good fun) — requires about_target' },
aimed_at_target: { type: 'boolean', description: 'true if the speaker is trash-talking, roasting, calling out, dunking on or bragging over the target (in good fun) — requires about_target' },
announcer: { type: 'string', description: 'one-line ring-announcer call of this moment, ≤ 110 chars, hype and funny (empty if not aimed at the target)' },
announcer: { type: 'string', description: 'one-line ring-announcer call of this moment, ≤ 110 chars, hype and funny (empty if not aimed at the target)' },
quality: { type: 'integer', minimum: 0, maximum: 10, description: 'how good the trash talk is: spice, wit, specificity, quotability' },
quality: { type: 'integer', minimum: 0, maximum: 10, description: 'how good the trash talk is: spice, wit, specificity, quotability' },
- best_line: { type: 'string', description: 'the single best line, VERBATIM from the speech (empty if none)' },
+ best_line: { type: 'string', description: 'the single best line: a COMPLETE sentence of at least 6 words that reads correctly on its own, words as spoken (fix casing/punctuation, drop stutters and filler, never change or add words). Empty if no clean line exists — a garbled fragment must not be quoted' },
about: { type: 'string', description: '≤ 10 words: what they said about the target' },
about: { type: 'string', description: '≤ 10 words: what they said about the target' },
flagged: { type: 'boolean', description: 'ONLY for direct threats of violence, sexual content about minors, or doxxing — never for offensive language' },
flagged: { type: 'boolean', description: 'ONLY for direct threats of violence, sexual content about minors, or doxxing — never for offensive language' },
},
},
@@ -76,11 +85,12 @@ const MIC_SCHEMA = {
@@ -76,11 +85,12 @@ const MIC_SCHEMA = {
name: 'arena_mic_judgement',
name: 'arena_mic_judgement',
schema: {
schema: {
type: 'object', additionalProperties: false,
type: 'object', additionalProperties: false,
- required: ['is_trash_talk', 'quality', 'best_line', 'about', 'aimed_at', 'announcer', 'flagged'],
+ required: ['is_trash_talk', 'garbled', 'quality', 'best_line', 'about', 'aimed_at', 'announcer', 'flagged'],
properties: {
properties: {
- is_trash_talk: { type: 'boolean', description: 'true ONLY if the speaker is actually talking shit: roasting, calling someone out, bragging over someone, ranting AT someone (chat, a group, a person), disrespect, trolling, "come see me" energy' },
- quality: { type: 'integer', minimum: 0, maximum: 10, description: 'how good the shit talk is: savage, specific, funny, quotable = high; lazy generic = low; 0 when not trash talk' },
- best_line: { type: 'string', description: 'the single best line, VERBATIM from the speech (empty if none)' },
+ is_trash_talk: { type: 'boolean', description: 'true ONLY if the speaker is actually talking shit: roasting, calling someone out, bragging over someone, ranting AT someone (chat, a group, a person), disrespect, trolling, "come see me" energy — AND there is a clean, complete, quotable line for it' },
+ garbled: { type: 'boolean', description: 'true if the transcript is too mangled (misheard words, word salad, fragments) to quote a coherent line — then is_trash_talk must be false' },
+ quality: { type: 'integer', minimum: 0, maximum: 10, description: 'how good the shit talk is: 8–10 = a stranger would laugh or screenshot it; 6–7 = solid, specific, quotable; ≤5 = generic, lazy or half a thought; 0 when not trash talk' },
+ best_line: { type: 'string', description: 'the single best line: a COMPLETE sentence of at least 6 words that reads correctly on its own. Copy the words as spoken — you may fix casing and punctuation and drop stutters / filler (uh, um, repeated words) but never change, add or reorder words. Empty if no clean line exists' },
about: { type: 'string', description: '≤ 10 words: what the shit talk was about' },
about: { type: 'string', description: '≤ 10 words: what the shit talk was about' },
aimed_at: { type: 'string', description: 'who or what it is aimed at, as said or clearly implied: a streamer name, "chat", "the mods", "twitch streamers", a game, "nobody" — ≤ 6 words, lowercase' },
aimed_at: { type: 'string', description: 'who or what it is aimed at, as said or clearly implied: a streamer name, "chat", "the mods", "twitch streamers", a game, "nobody" — ≤ 6 words, lowercase' },
announcer: { type: 'string', description: 'one-line ring-announcer call of the moment, ≤ 110 chars, hype and funny (empty if not trash talk)' },
announcer: { type: 'string', description: 'one-line ring-announcer call of the moment, ≤ 110 chars, hype and funny (empty if not trash talk)' },
@@ -88,7 +98,7 @@ const MIC_SCHEMA = {
@@ -88,7 +98,7 @@ const MIC_SCHEMA = {
},
},
},
},
};
};
-const MIC_SYSTEM = `You judge a live streamer's raw mic chatter for BATTLE-CAM style shit talk. You get ~60 seconds of speech-to-text (expect noise). DEFAULT TO is_trash_talk=false: gameplay narration, reading chat, small talk, "um", stories, neutral opinions → false, quality 0. Say true only when they are genuinely talking shit — roasting someone, calling someone out, bragging over someone, ranting AT chat or a group, disrespect, trolling, "pull up" energy. When true: score the craft (savage, specific, petty, funny, quotable = high; generic = low), pick the best verbatim line, say who it is aimed at (a name if one is said or clearly meant, else "chat", "the mods", a group, a game, or "nobody"), summarise in ≤ 10 words, and write a one-line ring-announcer call. Offensive, provocative and taboo language — slurs included — is allowed on this platform and is NOT a reason to flag or to score low; judge the craft. Flag ONLY behavior: direct threats of violence, sexual content about minors, doxxing. Output only the JSON.`;
+const MIC_SYSTEM = `You judge a live streamer's raw mic chatter for BATTLE-CAM style shit talk. You get ~60–90 seconds of speech-to-text — it is noisy: names get misheard, words get mangled, sentences get cut. DEFAULT TO is_trash_talk=false: gameplay narration, reading chat, small talk, "um", stories, neutral opinions → false, quality 0. Say true only when they are genuinely talking shit — roasting someone, calling someone out, bragging over someone, ranting AT chat or a group, disrespect, trolling, "pull up" energy — AND you can quote a clean, complete line for it. QUOTABILITY IS THE BAR: best_line must be a full sentence of at least 6 words that a reader understands with no context. If the good part is garbled or only a fragment, set garbled=true and is_trash_talk=false rather than quoting nonsense; a half-line in the feed is worse than nothing. Score the craft honestly: 8–10 only for lines a stranger would laugh at or screenshot; 6–7 solid and specific; 5 or below generic or lazy. Say who it is aimed at (a name if one is said or clearly meant, else "chat", "the mods", a group, a game, or "nobody"), summarise in ≤ 10 words, and write a one-line ring-announcer call. Offensive, provocative and taboo language — slurs included — is allowed on this platform and is NOT a reason to flag or to score low; judge the craft. Flag ONLY behavior: direct threats of violence, sexual content about minors, doxxing. Output only the JSON.`;
const SPICY = /\b(clown|clowns|weak|scared|duck|ducking|ducked|trash|garbage|mid|washed|bum|bums|ratio|cook|cooked|better than|can't|cannot|never|nobody|beat|fraud|frauds|ass|bet|catch (these|this)|come see|pull up|fight me|square up|run it|talk (that|your)|cope|seethe|cry|loser|losers|bozo|bozos|goofy|fake|scam|dogshit|shit at|suck|sucks|pathetic|embarrassing|sit down|shut up|nobody cares|delusional|coward|cowards)\b/;
const SPICY = /\b(clown|clowns|weak|scared|duck|ducking|ducked|trash|garbage|mid|washed|bum|bums|ratio|cook|cooked|better than|can't|cannot|never|nobody|beat|fraud|frauds|ass|bet|catch (these|this)|come see|pull up|fight me|square up|run it|talk (that|your)|cope|seethe|cry|loser|losers|bozo|bozos|goofy|fake|scam|dogshit|shit at|suck|sucks|pathetic|embarrassing|sit down|shut up|nobody cares|delusional|coward|cowards)\b/;
function heuristicBeef(text, targetNames, { named = true } = {}) {
function heuristicBeef(text, targetNames, { named = true } = {}) {
@@ -112,7 +122,7 @@ function heuristicMic(text) {
@@ -112,7 +122,7 @@ function heuristicMic(text) {
const sentences = text.split(/(?<=[.!?])\s+/).filter(Boolean);
const sentences = text.split(/(?<=[.!?])\s+/).filter(Boolean);
const spicyLines = sentences.filter(l => SPICY.test(l.toLowerCase()));
const spicyLines = sentences.filter(l => SPICY.test(l.toLowerCase()));
const pick = (spicyLines.length ? spicyLines : sentences).sort((a, b) => b.length - a.length)[0] || text;
const pick = (spicyLines.length ? spicyLines : sentences).sort((a, b) => b.length - a.length)[0] || text;
- return { is_trash_talk: spicy, quality, best_line: pick.trim().slice(0, 200), about: text.split(/\s+/).slice(0, 8).join(' '), aimed_at: aimed, announcer: '', flagged: false, _fallback: true };
+ return { is_trash_talk: spicy && words(pick) >= MIN_LINE_WORDS, garbled: false, quality, best_line: pick.trim().slice(0, 200), about: text.split(/\s+/).slice(0, 8).join(' '), aimed_at: aimed, announcer: '', flagged: false, _fallback: true };
}
}
async function judgeBeef(speakerId, targetId, text, roster, { context = null, named = true, how = 'exact' } = {}) {
async function judgeBeef(speakerId, targetId, text, roster, { context = null, named = true, how = 'exact' } = {}) {
@@ -131,7 +141,10 @@ async function judgeBeef(speakerId, targetId, text, roster, { context = null, na
@@ -131,7 +141,10 @@ async function judgeBeef(speakerId, targetId, text, roster, { context = null, na
}
}
if (!j) j = heuristicBeef(text, spokenForms.length ? spokenForms : targetNames.map(n => n.toLowerCase()), { named });
if (!j) j = heuristicBeef(text, spokenForms.length ? spokenForms : targetNames.map(n => n.toLowerCase()), { named });
const about = (j.about_target !== false) && !j.flagged;
const about = (j.about_target !== false) && !j.flagged;
- return { about_target: about, aimed_at_target: about && !!j.aimed_at_target, quality: Math.max(0, Math.min(10, Math.round(Number(j.quality) || 0))), best_line: String(j.best_line || '').slice(0, 220), about: String(j.about || '').slice(0, 80), announcer: String(j.announcer || '').slice(0, 140), flagged: !!j.flagged, fallback: !!j._fallback };
+ const line = cleanLine(j.best_line);
+ // A hit needs a quotable line; a beef fed by fragments reads like nonsense on the page.
+ const aimed = about && !!j.aimed_at_target && words(line) >= MIN_LINE_WORDS;
+ return { about_target: about, aimed_at_target: aimed, quality: aimed ? Math.max(0, Math.min(10, Math.round(Number(j.quality) || 0))) : 0, best_line: line, about: String(j.about || '').slice(0, 80), announcer: String(j.announcer || '').slice(0, 140), flagged: !!j.flagged, fallback: !!j._fallback };
}
}
async function judgeMic(speakerId, text) {
async function judgeMic(speakerId, text) {
@@ -144,7 +157,9 @@ async function judgeMic(speakerId, text) {
@@ -144,7 +157,9 @@ async function judgeMic(speakerId, text) {
} catch (e) { console.warn('[Arena] mic judge:', e.message); }
} catch (e) { console.warn('[Arena] mic judge:', e.message); }
}
}
if (!j) j = heuristicMic(text);
if (!j) j = heuristicMic(text);
- return { is_trash_talk: !!j.is_trash_talk && !j.flagged, quality: Math.max(0, Math.min(10, Math.round(Number(j.quality) || 0))), best_line: String(j.best_line || '').slice(0, 220), about: String(j.about || '').slice(0, 80), aimed_at: String(j.aimed_at || '').toLowerCase().slice(0, 60), announcer: String(j.announcer || '').slice(0, 140), flagged: !!j.flagged, fallback: !!j._fallback };
+ const line = cleanLine(j.best_line);
+ const ok = !!j.is_trash_talk && !j.flagged && !j.garbled && words(line) >= MIN_LINE_WORDS;
+ return { is_trash_talk: ok, garbled: !!j.garbled, quality: ok ? Math.max(0, Math.min(10, Math.round(Number(j.quality) || 0))) : 0, best_line: line, about: String(j.about || '').slice(0, 80), aimed_at: String(j.aimed_at || '').toLowerCase().slice(0, 60), announcer: String(j.announcer || '').slice(0, 140), flagged: !!j.flagged, fallback: !!j._fallback };
}
}
// ── Tick ─────────────────────────────────────────────────────
// ── Tick ─────────────────────────────────────────────────────
@@ -219,6 +234,7 @@ async function judgeFreeTalk(stream, roster, st, events) {
@@ -219,6 +234,7 @@ async function judgeFreeTalk(stream, roster, st, events) {
st.lastMicJudgement = { at: new Date().toISOString(), ...j };
st.lastMicJudgement = { at: new Date().toISOString(), ...j };
if (!j.is_trash_talk || j.quality < MIC_MIN_QUALITY) { events.push({ kind: 'mic_miss', streamId: stream.id, speakerId: stream.user_id, about: j.about }); return; }
if (!j.is_trash_talk || j.quality < MIC_MIN_QUALITY) { events.push({ kind: 'mic_miss', streamId: stream.id, speakerId: stream.user_id, about: j.about }); return; }
const ref = lineRefFor(lines, j.best_line);
const ref = lineRefFor(lines, j.best_line);
+ if (mic().isDuplicate(stream.user_id, j.best_line)) { events.push({ kind: 'mic_dupe', streamId: stream.id, speakerId: stream.user_id }); return; }
// "aimed_at" that resolves to a roster fighter = a callout → it feeds a beef exactly like a name-drop.
// "aimed_at" that resolves to a roster fighter = a callout → it feeds a beef exactly like a name-drop.
const target = j.aimed_at ? mentionsDetailed(j.aimed_at, stream.user_id, roster)[0] : null;
const target = j.aimed_at ? mentionsDetailed(j.aimed_at, stream.user_id, roster)[0] : null;
if (target && j.quality >= CALLOUT_MIN_QUALITY) {
if (target && j.quality >= CALLOUT_MIN_QUALITY) {
@@ -310,4 +326,4 @@ function start() {
@@ -310,4 +326,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, SPICY, _mentionsIn: mentionsIn, _mentionsDetailed: mentionsDetailed, _aliases: aliases, _heuristicBeef: heuristicBeef, _heuristicMic: heuristicMic, _judgeBeef: judgeBeef, _judgeMic: judgeMic, _state: state };
+module.exports = { start, stop, tick, consoleState, TICK_MS, JUDGE_MIN_WORDS, JUDGE_MIN_INTERVAL_MS, MIC_MIN_QUALITY, CALLOUT_MIN_QUALITY, MIN_LINE_WORDS, cleanLine, 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 };
@@ -172,6 +172,15 @@ function addMoment({ userId, streamId = null, vodId = null, sec = null, kind = '
@@ -172,6 +172,15 @@ function addMoment({ userId, streamId = null, vodId = null, sec = null, kind = '
return db.get('SELECT * FROM arena_mic_moments WHERE id = ?', [id]);
return db.get('SELECT * FROM arena_mic_moments WHERE id = ?', [id]);
}
}
+/** Same line (or near enough) from the same fighter in the last 6 h → don't file it twice. */
+function isDuplicate(userId, text) {
+ ensureTables();
+ const norm = String(text || '').toLowerCase().replace(/[^a-z0-9 ]/g, '').replace(/\s+/g, ' ').trim();
+ if (!norm) return false;
+ const rows = db.all(`SELECT text FROM arena_mic_moments WHERE user_id = ? AND created_at >= datetime('now', '-6 hours') ORDER BY id DESC LIMIT 40`, [userId]);
+ return rows.some(r => { const t = String(r.text || '').toLowerCase().replace(/[^a-z0-9 ]/g, '').replace(/\s+/g, ' ').trim(); return t === norm || (t.length > 30 && (t.includes(norm) || norm.includes(t))); });
+}
+
function momentView(m, roster) {
function momentView(m, roster) {
const brief = fighterBrief(m.user_id, roster);
const brief = fighterBrief(m.user_id, roster);
return {
return {
@@ -187,7 +196,8 @@ function feed({ limit = 40, since = null, userId = null } = {}) {
@@ -187,7 +196,8 @@ function feed({ limit = 40, since = null, userId = null } = {}) {
ensureTables();
ensureTables();
const roster = arena().loadRoster();
const roster = arena().loadRoster();
const params = [];
const params = [];
- let where = '1 = 1';
+ // The feed shows bangers only; weak lines still count for stats/XP but never headline the page.
+ let where = 'quality >= 5';
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)));
@@ -229,6 +239,6 @@ function micStats(userId, days = 30) {
@@ -229,6 +239,6 @@ function micStats(userId, days = 30) {
module.exports = {
module.exports = {
ensureTables, addXp, recentXp, levelView, levelFor, levelRow, levelsLeaderboard, nameOf, fighterBrief,
ensureTables, addXp, recentXp, levelView, levelFor, levelRow, levelsLeaderboard, nameOf, fighterBrief,
- addMoment, momentView, feed, momentsFor, bestLines, latestFor, micStats,
+ addMoment, momentView, feed, momentsFor, bestLines, latestFor, micStats, isDuplicate,
XP_PER_LEVEL, XP_MOMENT, XP_HYPE,
XP_PER_LEVEL, XP_MOMENT, XP_HYPE,
};
};