openvibers/openvibe.live Commits

RobotStreamer switch bonus, language layer for non-English streamers, Arena v3 Battle Cam (pure mic)

OP OpenVibers committed 11 Sept 2026 · 4ea1a7cf19 · 37 files, +3208 −2879

Promo (public/js/rs-promo.js, public/css/rs-promo.css, /api/promo/robotstreamer):
- one-time takeover splash (aurora, synthwave grid, money rain, count-up), marquee ticker on
  every page, 3D-tilt hero bounty card, claim modal (Zelle/PayPal, copyable message, confetti)
- huge collapsible alert on every channel page (live + offline layouts)
- offer: $25–$50 for quitting RobotStreamer for good + streaming only here; features built on
  request; referral payout for existing users; open-source-vs-closed trust message
- RS_PROMO_* env: enabled, amount/min, referral, owner, discord, github

Language layer (server/i18n/translate.js, routes.js):
- channel language (explicit channels.chat_language or detected from the bio)
- chat auto-translation both ways (chat_translation ws event, persisted in metadata)
- on-demand "translate to my language" button on every chat line (POST /api/i18n/translate)
- bio translation (/channel/:username/bio-en), live captions panel (/chat-ai/live-captions)
- whisper decodes in the channel's language when WHISPER_MODEL_MULTI is installed; timeline
  rows carry lang/text_en (migration now runs after the table exists)
- home "Star of OpenVibe" spotlight (/api/home/star, STAR_STREAMER / star_streamer)

Arena v3 — Battle Cam (docs/arena.md):
- pure mic: board/topics/lore, yappers/chat XP, bounties, check-ins, achievements removed
- mic.js ledger (judged moments, Trash Level XP, mic stats, live feed)
- listener: name-drop → beef judge; free talk → mic judge (aimed_at can open a beef)
- roster = anyone heard in 45 days; seven mic-only ratings HEAT/AIM/KILLS/MOUTH/CLAPBACK/
  STAMINA/PACE; personas written from transcripts
- chat can only !hype; frontend rebuilt (live cams, feed, beefs, ladder)
- tests: arena.test.js rewritten, arena-mic.test.js added

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

Changes

ddiff --git a/.env.example b/.env.example
iindex 99746356..bdb94d7a 100644
---- a/.env.example
++++ b/.env.example
@@@ -6,6 +6,25 @@
ADMIN_USERNAME=admin
ADMIN_PASSWORD=changeme123
++# RobotStreamer switch bonus promo (site-wide takeover + ticker + hero card + claim modal)
++# RS_PROMO_ENABLED=true
++# 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_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
++
++# Home-page "Star of OpenVibe" spotlight — a username. The `star_streamer` site setting
++# (admin settings API) overrides this. Non-English streamers get their bio auto-translated.
++# STAR_STREAMER=JapaneseOldGuy
++
++# Multilingual whisper.cpp model for non-English streamers (chat + live captions + timeline).
++# The default *.en models can only emit English. Download e.g. ggml-base.bin / ggml-small.bin
++# (no ".en") into ~/whisper.cpp/models and point this at it. Without it, non-English
++# channels keep using the English model and live captions stay hidden.
++# WHISPER_MODEL_MULTI=/home/openvibe/whisper.cpp/models/ggml-small.bin
++
# Server
PORT=3000
HOST=0.0.0.0
ddiff --git a/docs/arena.md b/docs/arena.md
iindex 6cc3c602..3d5a1980 100644
---- a/docs/arena.md
++++ b/docs/arena.md
@@@ -1,137 +1,107 @@
--# Arena — beefs, the board, and the ears
++# Arena — Battle Cam (pure mic)
--The **Arena** tab (`/arena`) is where the platform's culture gets weaponised into entertainment. Nobody clicks "fight". Everything runs off what streamers *say* on mic (the continuous audio transcription) and what viewers *do* in chat:
++The **Arena** tab (`/arena`) is streamer-vs-streamer shit talk with exactly one input: **what fighters say into the microphone**. The continuous audio transcription of every live cam (`stream_timeline_events`, see the AI timeline) is read every 15 seconds and judged. Nobody clicks "fight", nobody votes, nothing typed in chat counts. There is no board, no topics, no chat levels, no bounties, no check-ins — those were removed in v3 (2026-09-10) because they were not mic.
--- **Beefs** — a streamer says another fighter's name while talking shit → a beef opens by itself, the target goes on the clock, silence is a forfeit.
--- **The board** — living lore profiles of the subjects the site is actually on about, discovered from global chat + live transcripts; every chat line or on-mic line about a subject becomes a moment, the AI rewrites the lore as it escalates. Streamers talk on a subject → judged moments → XP → **Trash Level**. No voting anywhere.
--- **Fighters** — the roster is every streamer active in the last 45 days, rated on 7 stats (HYPE, GRIND, CHAT, LOYALTY, CLUTCH, VIBE, **MIC**) as percentiles across the roster, plus an AI persona and portrait. Recent Trash Level XP and beef wins add a *mouth bonus* (≤ +12) to POWER.
++- **Callouts → beefs** — a fighter says another fighter's name while talking shit → the ears lock on, the judge scores it, the beef opens by itself, the target goes on the clock; silence is a forfeit. The mic judge can also open a beef without the name being in the transcript, when it decides the shit talk is clearly *aimed at* a roster fighter.
++- **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: template headlines, fallback angles, keyword judges.
++With AI off everything still works: keyword judges, template headlines, fallback personas.
## Speech policy
--The platform does not censor language. Offensive, provocative, taboo words — slurs included — are allowed, are never filtered, and are never a reason for the judge to score low. The only lines that don't count (`server/arena/arena-service.js` `isBannedText`, plus the judge's `flagged`) are **behaviour**: direct threats of violence (`kys`, "I'll kill you"), sexual content about minors, and doxxing (addresses, phone numbers). Those lines are dropped from quotes, feeds and scoring; nothing else is.
++The platform does not censor language. Offensive, provocative, taboo words — slurs included — are allowed, are never filtered, and are never a reason for the judge to score low. The only lines that don't count (`server/arena/arena-service.js` `isBannedText`, plus the judge's `flagged`) are **behaviour**: direct threats of violence (`kys`, "I'll kill you"), sexual content about minors, and doxxing (addresses, phone numbers). Those lines are dropped from the feed, quotes and scoring; nothing else is.
--## Beefs (`server/arena/beef.js`)
++## The roster + ratings (`server/arena/arena-service.js`)
--| Rule | Value |
--|---|---|
--| Opens when | the listener judges ≥ 20 words in the 45 s after a fighter's name was said as *aimed at them* (roast, callout, disrespect, brag over them) |
--| Clock | the other side must answer on their own stream within **15 min if live**, **24 h if offline** (an offline clock is tightened to 15 min the moment they go live) |
--| Answer | any judged hit from the side on the clock; every hit flips the clock to the other side |
--| Forfeit | clock runs out → the silent side loses |
--| Hard end | 24 h after opening → higher **total** wins (`total = Σ hit quality (0–10, judged) + crowd hype (max 10)`), equal = draw |
--| Upset | winner ranked ≥ 4 places below the loser (+20 XP) |
--| Rematch | same pair again → flagged, with the rivalry's record and **receipts** (best lines from earlier beefs) |
--| XP | +5 open · +quality per hit (×2 while a bounty is on the target) · +40 win |
--| Crowd | `!hype` / Hype button: one per person per side (no voting) |
++The roster is **whoever has been heard**: every streamer with transcribed speech in the last 45 days. A streamer with 9,000 viewers and no transcription is not a fighter. Ratings are 40–99 percentiles across the roster (a roster of one is a flat 70):
++
++| Stat | From | Weight |
++|---|---|---|
++| **Heat** | average judge score of their mic moments (30 d) | 0.22 |
++| **Aim** | callouts + beef hits per hour on mic (30 d) | 0.18 |
++| **Kills** | beefs won | 0.16 |
++| **Mouth** | share of stream time spent talking | 0.14 |
++| **Clapback** | beefs answered on the clock ÷ beefs they were called out in | 0.12 |
++| **Stamina** | minutes of speech heard (90 d) | 0.10 |
++| **Pace** | words per minute on mic | 0.08 |
--Headlines (open + result) are AI-written (`chat` role) with templates as fallback; the announcer's one-liner per hit comes from the judge. Streaks, upsets and the head-to-head history are shown on every beef card.
++POWER = weighted sum + a **mouth bonus** (≤ +12: recent XP ÷ 25 + 3 per beef win in 7 days). Audience numbers (viewers, chat volume, followers, clips, tips) do not exist anywhere in the Arena.
--## The board (`server/arena/board.js`) — living lore
++**Persona** (`chat` role, 24 h TTL): fighter name, title, class, element, moves, weakness, `taunt` + three `taunts` (ragebait in *their speaking voice*), `typing_style` (how they talk), `spoken_as` (nicknames/mishearings, fed to name detection), six `custom_stats` unique to them and `stat_quips` — written from their transcript lines, their best judged shit talk, who they call out, who calls them out and what they rant at. **Quotes**: AI-picked lines from the transcripts, VOD-linked. **Portraits** (optional, `ai_image_enabled`): drawn from their own stream frames.
--No voting anywhere. The board is a list of **subjects** the community is actually on about, each a lore profile that writes itself:
++## The mic ledger (`server/arena/mic.js`)
--- **Discovery** — every 5 min (only when ≥ 8 new lines exist, so a dead room costs nothing; window `arena_discover_window_min`, default 30) the AI reads global + stream chat and live transcripts and returns the **umbrella subjects** people are really on about (1–4 words: a person, a group, a joke, a drama…) each with 2–4 **threads** — the specific angles being argued (name + keywords + hint) — a short headline and tagline in the community's own voice, the keywords/slang people use, and optionally one **bounty** on a streamer the community keeps naming. Voice rules: write like a regular of this chat (its casing, slang, rhythm), no tabloid adjective soup, no invented words; the prompt gets recent chat lines, the global chat AI overview and the chat AI's profiles of the people involved. Without AI, a word ≥ 3 people said ≥ 4 times becomes a subject. New subjects are **backfilled** with every matching line from the last 2 h so they never start empty.
--- **Moments** — every minute new `chat_messages` are scanned; any line matching a subject's keywords becomes a chat **moment** on it and is filed into the first thread whose keywords match (`thread_id`). The listener does the same for transcript lines (an on-mic moment with a VOD deep link, one per stream per 45 s) and **auto-joins** the fighter to the subject. Judged chunks (≥ 20 words, AI quality 0–10) are moments with a score → XP (quality × 0.8) → Trash Level.
--- **One story = one subject** — discovery hands the model the open subjects (ids, keywords, threads) and it returns `merge_into_id` for anything that is the same drama; on top of that `mergeCandidate` folds a new subject into an existing one when they share a roster name, ≥ 2 keywords / a phrase keyword, or ≥ 50 % of their words. Folding = the would-be subject and its threads become threads of the existing subject (threads that are themselves another open subject are skipped) and its keywords widen the match. User submissions fold the same way (the reply says so). Lore rewrites never add a thread that is another open subject.
--- **Nobody joins anything** — the ears detect who is on what: a fighter saying a subject on mic is auto-filed as a member; there are no join/leave endpoints or buttons.
--- **Lore** — when a subject has ≥ 3 new moments (and ≥ 8 min since the last rewrite) the AI rewrites its short (≤ 450 chars) story so far and returns the updated threads (existing kept, new angles added, ≤ 6 per subject; unfiled moments are re-filed): who started it, who said what (verbatim, with usernames), who is on which end, escalations. Templated when AI is off. Lore stays in the archive after the subject cools off (36 h idle).
--- **User subjects** — signed-in only, **one per person and one per IP per 24 h**. The AI rewrites what was typed into a subject + headline + keywords (`submitTopic`, one small call) and backfills moments. `!topic <text>` in chat does the same.
--- **Heat** = on-mic moments ×3 + chat moments + hype + fighters talking (last hour); ≥ 12 is HOT; the hottest subject is featured at the top.
--- **Ladders** — Trash Level (XP) and **yappers** (chatters with the most moments in 7 days).
++`arena_mic_moments` — one row per judged line: `kind` (`trash` for free-standing shit talk, `beef_hit` when it fed a beef), `target_user_id` + `beef_id` for callouts, `aimed_at` (free text: "chat", "the mods", a name…), `text`, `about`, `quality`, `announcer`, VOD id + second, `said_at`. `feed()` is the live feed; `micStats(userId)` is what the ratings read.
++
++**XP → Trash Level** (`arena_trash_levels`, level = 1 + XP ÷ 50): `trash` moment = quality × 0.8 · beef hit = quality · beef open +5 · beef win +40 (+20 upset) · hype received +1. Nothing else pays XP. The best line on record (highest quality) sticks to the profile.
## The ears (`server/arena/listener.js`)
Every 15 s, for every roster stream that is live **and** has a transcript line in the last 30 min:
--1. New `stream_timeline_events` speech lines are read.
--2. **Name detection** (`server/arena/names.js`) — every roster name (username, display name, fighter name, plus the persona's AI-written `spoken_as` nicknames/mishearings) is expanded into the forms a transcriber produces: camelCase/snake/kebab split (`JapaneseOldGuy` → "japanese old guy"), digits and `_tv`/`xX_` decorations dropped (`lofi_dan99` → "lofi dan"), leet undone, glued forms. Lines are matched exact → **fuzzy** (edit distance scaled by length, same word count only: "Matticus", "japanese old gai") → **phonetic** (compact metaphone key: "goose lee", "Goosley", "mattie cuss", "pixel queen"). Single common words can never be aliases, prefixes don't count, the speaker never matches themselves.
--3. **Focus lock** — a name-drop locks the ears on that fighter for 2 min; everything said afterwards goes to the beef judge together with what was already said about them, and the judge decides `about_target` (still on them via "he", "her chat", "that guy", the same story) and `aimed_at_target`. Every hit extends the lock 3 min (hard cap 20 min without a fresh name-drop); neutral talk keeps it a bit; two chunks about something else drop it. A different name-drop switches targets (the pending chunk is judged first). The console (`/arena/live/<user>`) shows the lock, its context and whether the last hit was a continuation.
--4. Lines matching a board subject's keywords become on-mic moments (and auto-join the fighter). Other lines pool for the **subject judge** against the streamer's active subject — or the one they just brought up (≥ 20 words, ≥ 30 s between calls): `on_topic`, `quality`, `best_line`, `about`.
--5. One judge call per stream per tick; keyword heuristics when AI is off (pronouns + spice count as a continuation).
++1. New speech lines are read.
++2. **Name detection** (`server/arena/names.js`) — every roster name (username, display name, fighter name, the persona's `spoken_as`) in the forms a transcriber produces (camelCase/snake split, digits/decorations dropped, leet undone, glued), matched exact → fuzzy → phonetic. The speaker never matches themselves.
++3. **Focus lock** — a name-drop locks the ears on that fighter for 2 min; everything said afterwards goes to the **beef judge** with the context of what was already said (`about_target`, `aimed_at_target`, quality, best verbatim line, announcer call). A hit → `beef.recordHit()`; every hit extends the lock 3 min (cap 20 min without a fresh name-drop); two off-target chunks drop it; a different name switches targets.
++4. **Free talk** — lines said while not locked pool for the **mic judge** (≥ 20 words, ≥ 30 s between calls per stream): `is_trash_talk`, quality, best line, `aimed_at`, announcer. Quality ≥ 4 → a `trash` moment in the feed. If `aimed_at` resolves to a roster fighter and quality ≥ 5 → it is a **callout** and feeds a beef exactly like a name-drop.
++5. One judge call per stream per tick; keyword heuristics when AI is off.
++
++`/arena/live/<username>` shows exactly what the ears hear: hot mic lines, the lock and its context, the last beef and mic judgements, their recent lines in the feed, open beefs.
++
++## Beefs (`server/arena/beef.js`)
++
++| Rule | Value |
++|---|---|
++| Opens when | the beef judge confirms ≥ 20 words aimed at a fighter after a name-drop, or the mic judge's `aimed_at` resolves to a fighter (quality ≥ 5) |
++| Clock | the other side must answer on their own cam within **15 min if live**, **24 h if offline** (an offline clock tightens to 15 min the moment they go live) |
++| Answer | any judged hit from the side on the clock; every hit flips the clock |
++| Forfeit | clock runs out → the silent side loses |
++| Hard end | 24 h after opening → higher **total** wins (`total = Σ hit quality + crowd hype (max 10)`), equal = draw |
++| Upset | winner ranked ≥ 4 places below the loser (+20 XP) |
++| Rematch | same pair again → flagged, with the rivalry's record and **receipts** (best lines from earlier beefs) |
++| Crowd | `!hype` / Hype button: one per person per side — the only thing chat can do |
--`/arena/live/<username>` shows exactly what the ears hear for a fighter: hot mic lines, the last judgements, active topic progress, open beefs and any bounty on them (auto-refreshes; a level-up flashes).
++Every hit is also written to the mic ledger as a `beef_hit` moment. Headlines (open + result) are AI-written with templates as fallback; the announcer's one-liner per hit comes from the judge.
## Pages
--`/arena` — pulse · on the mic now · open + settled beefs · the board (subject cards: headline, subject + tagline, thread chips with counts, latest line, mention counts, fighters; first card featured; cooled-off list) · Trash Level ladder · yappers · power ladder (rows expand; compact on phones) · rules.
--`/arena/beef/<id>` — tale of the tape, tug-of-war, live clock, ringside feed (every judged hit with ▶ to the VOD second and 🔊), receipts from earlier beefs, hype/side buttons.
--`/arena/topic/<id>` — headline, subject + tagline, counts, story so far, **thread tabs** filtering the moment timeline (chat + on-mic with ▶ to the VOD second), fighters heard on it with their best line, loudest chatters with yap levels.
--`/arena/<username>` — stats drill-down, voice + quotes, Trash Level card, rivalries with receipts, beef history.
++`/arena` — Battle Cam: your beefs on the clock · **live cams** (thumbnail, lock indicator, last judged line, open beefs, pending words) · open + settled beefs · **the feed** (every judged line, newest first, aimed-at chips, ▶ to the VOD second, 🔊 in their voice) · the ladder (rows expand: the seven mic stats radar, taunts, quips) · rules.
++`/arena/beef/<id>` — tale of the tape, tug-of-war, live clock, ringside feed, receipts, hype buttons.
++`/arena/live/<username>` — the ears.
++`/arena/<username>` — POWER, Trash Level card (judged lines, avg score, bangers, answered-when-called-out), the seven mic stats with drill-downs, their lines on record, characteristics radar, rap sheet, ragebait bubbles, on the mic (voice meters + quotes), rivalries, beefs.
++
++Old `/arena/topic/*` and `/arena/chatter/*` links show a "this part is gone" note.
## Chat commands (`server/arena/arena-chat.js`)
--`!topic <text>` (signed in; 1 per person + per IP per 24 h; AI rewrites it) · `!bounty <user>` (1 per hour, same 24 h rule) · `!hype` (the streamer's newest open beef, else their active subject) · `!beef` · `!board` · `!arena [user]`. One command per person every 4 s; replies are private system lines, milestones go to the room.
++`!hype` (the streamer's newest open beef) · `!beef` · `!arena [user]`. One command per person every 4 s. `!topic`, `!bounty` and `!board` no longer exist.
## API (`/api/arena`)
| Method | Path | Notes |
|---|---|---|
--| GET | `/status` · `/fighters` · `/fighters/:user` · `/fighters/:user/stat/:stat` · `/live` | roster, card (+ `beefs`, `rivalries`, `level`, `active_topic`), drill-down, live fighters (hot mic, active topic, open beefs) |
++| GET | `/status` · `/fighters` · `/fighters/:user` · `/fighters/:user/stat/:stat` · `/live` | roster (`stats`, `stat_meta`, per-fighter `mic`, `last_line`), card (+ `beefs`, `rivalries`, `moments`, `best_lines`, `mic`), drill-down, live cams (`ears`, `last_moment`, `open_beefs`) |
++| GET | `/feed?limit&since` | the shit-talk feed |
| POST | `/fighters/:user/refresh` | admin — regenerate persona (+ portrait) |
--| GET | `/console/:user` | the ears: listener state, hot mic, active topic + my progress, open beefs, bounty |
--| GET | `/board` | `open` (by heat, each with lore/keywords/mentions/fighters/last + best moment), `archive`, `pulse`, `levels`, `yappers` |
--| POST | `/board/topics {text}` · `/board/bounty {username}` | auth; 1 per person + per IP per 24 h; the AI rewrites the text |
--| GET | `/board/topics/:id` | detail incl. `moments` (80), `best_lines`, `top_chatters`, fighters with their best moment |
--| POST | `/board/topics/:id/join` · `/leave` (auth, roster) · `/hype {user_id}` | |
--| POST | `/pulse/refresh` · `/board/topics/:id/lore` | admin — force discovery + lore sweep / rebuild one subject's lore |
--| GET | `/beefs` · `/beefs/:id` | |
--| POST | `/beefs/:id/hype {side}` | one per person per side; anonymous by hashed IP |
--| GET | `/levels` · `/yappers` | ladders |
++| GET | `/console/:user` | the ears: listener state, hot mic, level, mic stats, open beefs, recent moments |
++| GET | `/beefs` · `/beefs/:id` · POST `/beefs/:id/hype {side}` | one hype per person per side; anonymous by hashed IP |
++| GET | `/levels` | Trash Level ladder |
++| GET | `/me` | signed in: fighter brief, level, record, mic stats, open beefs, beefs where you are on the clock, recent moments |
++| GET | `/voice/:user?t=<text>` | the line in that user's chat TTS voice (cached on disk + a week in the browser) |
## Settings
| Key | Default | Effect |
|---|---|---|
| `arena_enabled` | `true` | `false` → API 404s |
++| `ai_timeline_enabled` | `false` | the audio transcription the whole Arena runs on — must be on |
| `ai_image_enabled` / `ai_image_model` / `ai_image_quality` / `ai_image_cost_usd` | `false` / `gpt-image-1` / `low` / `0.011` | portraits |
--| `arena_vote_salt` | `JWT_SECRET` | salt for anonymous voter hashing |
--
--Background job (`server/arena/arena-job.js`): personas/portraits every 20 min (bounded, budget-aware) · listener every 15 s · clocks + bounty expiries + chat scan every 60 s · discovery ≤ 1 AI call / 5 min and only with new material · lore rewrites only for subjects with ≥ 3 new moments (≤ 3 per minute).
--
--Personas: the voice comes from their **chat history first** — their own chat lines with the room they typed in, the chat AI's profile of them as a chatter (overview, long-term memory, timeline), then the streaming profile as flavor. Output: `taunt` + three more `taunts` (ragebait in that exact typing style), `typing_style`, `spoken_as` (how people say/mishear the name — fed to name detection) and `custom_stats` (six stats unique to them, e.g. "Alt Accounts 91", drawn as their own radar on the ladder row and profile).
--
--Tests: `node test/arena.test.js` (roster, ratings, filter, quotes) · `node test/arena-beef-board.test.js` (beefs, board, listener tick, chat commands, API) — both on a temp DB, no AI needed.
--
--## Voice + portraits
--
--- **Hear it in their voice** — every 🔊 in the Arena calls `GET /api/arena/voice/<username>?t=<line>` (`server/arena/voice.js`): the line is synthesized in the streamer's **equipped cosmetic chat voice** (else the same per-identity auto voice chat gives `user:<username>`), written once to `data/tts-cache/<sha(voice+text)>.<mp3|wav>` and served with `Cache-Control: max-age=604800` — a repeat click never re-synthesizes (server cache + browser cache). `announcer` reads headlines/lore in `arena_announcer_voice` (default `tts_default_voice`). Cloud engines are metered into `ai_usage` (kind `tts`); `arena_voice_daily_max` (2000) caps fresh syntheses per day and per-IP limits are 8/min anonymous, 30/min signed in. Browser speech is only the fallback when the engine is down.
--- **Portraits from their own frames** — `generateImage` now feeds up to 3 real frames (live thumbnail, AI-moment frames the vision job persisted, VOD thumbnails — frames whose description mentions a person first) to `images/edits`, asking for a character-select caricature that keeps their setup, gear, lighting, silhouette and the vibe of their scene; text-only generation is only the fallback for streamers with no frames yet. Custom stats and the persona shape the costume/props.
--
--## Yappers — profiles for everyone who chats (`server/arena/chatters.js`)
--
--One polymorphic key per chatter — `user:<id>`, `anon:<N>` (the site's IP-derived `anon<N>` identity) or `relay:<platform>:<name>` (chat relayed from Twitch/Kick/YouTube/RobotStreamer) — so anonymous viewers and relayed chatters get profiles too. No extra AI calls for XP: it falls out of the board pipeline.
--
--| XP | for |
--|---|---|
--| +3 / +6 | a chat line that lands as a moment on a subject (double when the subject is HOT) |
--| +4 | the first time you touch a subject |
--| +15 | starting a subject (`!topic` / the page) |
--| +10 | being quoted in a subject's lore (name appears in the rewritten lore) |
--| +1 | hyping a fighter on a subject |
--| +5 × streak (≤ 7) | the first moment of each day; streak = consecutive days |
--
--Level = `1 + floor(√(XP / 25))` (the game's curve). Titles: Lurker → Chatter (2) → Yapper (3) → Instigator (5) → Ragebaiter (7) → Menace (9) → Main Character (12) → Community Consciousness (15) → Final Boss (20). **OpenVibe accounts bank `level × 10` OpenCoins on every level-up** through the network wallet (idempotent key `live:arena_yap_level:<user>:<level>`; unlinked accounts are simply skipped); anons/relay chatters keep the level and the title. Level-ups are announced in the room the moment happened in.
--
--**Yap card** — at level ≥ 3, at most once a day and only when ≥ 25 XP changed, the AI writes a card (title, blurb, catchphrase in their typing style, "known for", archetype) from the chat AI's existing profile of them (`chat_ai_summaries` user/anon/relay) + their best lines and subjects; ≤ 4 cards per housekeeping minute.
--
--Where it shows: the Yappers block on `/arena` (podium with level rings, streak flames, cards; weekly movers), `/arena/chatter/<key>` (card, XP bar, titles ladder, moments, subjects, what the chat AI has on them), and the chat context menu (click a name → yap level + title + bar, linking to the page). API: `GET /api/arena/yappers`, `GET /api/arena/chatter/:key`, `GET /api/arena/chatter/by-user/:username`, admin `POST /api/arena/chatter/:key/card`.
--
--## Coming back: your arena, check-ins, notifications
++| `arena_vote_salt` | `JWT_SECRET` | salt for anonymous hype hashing |
--- `GET /api/arena/me` (signed in) → your yap profile, XP today, coins banked from levels, fighter card if on the roster, open beefs where **you** are on the clock, subjects you're in, what's HOT. Rendered as the "Your Arena" strip at the top of `/arena`.
--- `POST /api/arena/checkin` → +5 XP once a day (+ the streak bonus) — the page calls it on the first visit of the day.
--- Arena events go to the site notification bell (`server/arena/notify.js` → `utils/notify.pushNotification`, deduped 10 min per user/type/key; users without a linked network account are skipped like everywhere else): a beef opened on you (with the clock), answers/hits while a beef is open, beef over (win/loss/draw), yap level-ups (+coins), getting quoted in lore, a bounty on you, your subject going HOT, your yap card being written.
--- Bots are never chatters: AI viewers (`source_platform='ai'`), channel AI bots (`channel_ai_bots`) and the admin list `arena_bot_usernames` (comma-separated) are excluded from moments, discovery input, voice samples and yapper XP.
--- Moments store `said_at` (the chat timestamp / stream start + offset) and every view uses it — never the time the line was filed.
++Background job (`server/arena/arena-job.js`): personas/portraits every 20 min (bounded, budget-aware) · listener every 15 s · beef clocks every 60 s.
--## Growth: tiers, achievements, history (`server/arena/progress.js`)
++Tests: `node test/arena.test.js` (roster, mic ratings, filter, quotes) · `node test/arena-mic.test.js` (ledger, beefs, listener tick, chat, API) · `node test/arena-voice.test.js` — all on a temp DB, no AI needed.
--- **Tiers** from all-time XP (never go down): Bronze 0 · Silver 150 · Gold 400 · Platinum 900 · Diamond 1800 · Mythic 3500. Fighters use their Trash Level XP, chatters their yap XP (an account's `user:<id>` key is the same for both, so one bar builds). Tier-ups are paid once (`arena_tier_paid`): accounts get `tier × 50` OpenCoins, a history entry and a notification.
--- **Achievements** (`ACHIEVEMENTS`, checked from existing data after every XP change — no AI): fighters — First Blood, Not Ducking, W, Problem (5 wins), Untouchable (3 streak), Giant Killer (upset), Run It Back (rematch win), On The Board, Loudmouth (25 judged moments), Bounty Hunter, Main Character (heard on a subject that went HOT — `arena_topics.peak_heat`), Crowd Favorite (25 hypes); chatters — Said Something, Yapping (10), Cannot Be Stopped (50), Everywhere (3 subjects), Instigator (started one), In The Lore, Historian's Nightmare (quoted 5×), Hot Take, Three Days, Regular (7 days), Instigator Rank (yap 5), Menace Rank (yap 10). Each pays XP; accounts also get OpenCoins (idempotent `live:arena_ach:<user>:<id>`), a bell notification and a history entry.
--- **History** (`arena_events`): beefs started/answered/over, level-ups, tier-ups, achievements, subjects started, being quoted — the timeline on every fighter/chatter page.
--- **Weekly**: XP gained in 7 days per key; `fighters_week` / `yappers_week` crowns on `/arena`.
--- API: `GET /api/arena/progress/:key`; `progress` is embedded in fighter cards, chatter profiles and `/api/arena/me`.
++## Voice
--Fighter page order: hero (portrait, POWER, tier, record, taunt) → progress panel (tier bar, achievements grid, history) → Trash Level card → characteristics radar + quips → rap sheet (lore, signature/special/weakness, walk-out, catchphrase, typing style) → ragebait bubbles (🔊 in their chat voice) → on the mic (quotes) → the numbers (bars) → rivalries → beefs.
++Every 🔊 in the Arena calls `GET /api/arena/voice/<username>?t=<line>` (`server/arena/voice.js`): synthesized once in the streamer's equipped cosmetic chat voice (else their per-identity chat voice), cached on disk and for a week in the browser. `announcer` reads headlines in `arena_announcer_voice`. `arena_voice_daily_max` (2000) caps fresh syntheses per day; per-IP limits are 8/min anonymous, 30/min signed in.
ddiff --git a/docs/chat-system.md b/docs/chat-system.md
iindex ac4ffcb0..8f9f0836 100644
---- a/docs/chat-system.md
++++ b/docs/chat-system.md
@@@ -92,3 +92,34 @@ wss://openvibe.live/ws/chat?token=JWT_OR_API_TOKEN&streamId=123
### TTS playback hardening (2026-08-31)
`media-src` now includes `data:` (the mod voice preview plays a `data:audio/…` URL; without it the element dies with "no supported source"). The admin **Test Voice** (`POST /api/tts/admin/test`) and mod preview (`POST /api/mod/tts-voice/preview`) additionally return a same-origin `url` — the clip is parked in the shared TTS cache (`data/tts-cache`) and streamed from `GET /api/tts/audio/<hash>.<wav|mp3>` (strict filename, no traversal) — so playback works even where a browser or shield refuses `blob:`/`data:` audio. Chat/broadcast TTS players retry once with a `data:` URL when the element rejects the blob URL. Note: `tts-audio` WS payloads carry base64 (`audio` + `mimeType`), not a `url`.
++
++
++## Auto-translation (non-English streamers)
++
++Every channel has a **language** it lives in: the streamer's explicit `channels.chat_language`
++(`PUT /api/streams/channel` with `chat_language: 'ja'` / `'auto'` …), or, when `auto`, the
++language detected from any non-English line in their bio or name (`server/i18n/translate.js`).
++`GET /api/streams/channel/:username` returns it as `language: { code, name, flag, explicit, translate }`,
++and the chat `auth` frame carries `channel_language` so the composer can show the
++"Auto-translated ↔ Japanese" pill.
++
++When AI is enabled (`ai_enabled` + a key; the `chat_translate_enabled` site setting defaults to on),
++each chat line is translated a beat after it is broadcast and delivered to the same rooms as
++
++```json
++{ "type": "chat_translation", "id": 123, "from": "ja", "to": "en", "text": "…" }
++```
++
++Direction rules: any non-English message → English; an English message in a non-English
++channel → the channel's language (so a Japanese streamer reads his chat without anyone typing
++Japanese). Emote-only / command / link-only lines are skipped. Translations are cached by
++content hash in the `translations` table and persisted into `chat_messages.metadata.translation`,
++so history renders them too. Calls go through `ai/llm.js` (metered, budgeted, max 3 in flight;
++overflow lines simply stay untranslated).
++
++**Any line, your language.** Every chat message also has a hover 🌐 button that asks `POST /api/i18n/translate { text, to }` for the line in the viewer's browser language (rate-limited per IP: 15/min anonymous, 40/min signed in; cached like the automatic translations). This is the third direction — a Korean viewer reading a Japanese streamer, a Japanese viewer on an English stream — so nobody is left out, English users included.
++
++Related surfaces: `GET /api/streams/channel/:username/bio-en` (bio in English),
++`GET /api/chat-ai/live-captions/:username` (English rendering of live speech — needs the AI
++timeline on and a multilingual whisper model, see `WHISPER_MODEL_MULTI`), and the home-page
++"Star of OpenVibe" spotlight (`GET /api/home/star`, `star_streamer` setting / `STAR_STREAMER` env).
ddiff --git a/public/css/i18n-star.css b/public/css/i18n-star.css
nnew file mode 100644
iindex 00000000..f370761c
---- /dev/null
++++ b/public/css/i18n-star.css
@@@ -0,0 +1,212 @@
++/* ═══════════════════════════════════════════════════════════════
++ I18N + STAR — chat auto-translation, live captions, bio
++ translation, channel language chip, and the home-page
++ "Star of OpenVibe" spotlight. Server side: server/i18n/translate.js
++ ═══════════════════════════════════════════════════════════════ */
++
++/* ── Chat: translation line under a message ─────────────────── */
++.chat-translation {
++ display: flex; align-items: flex-start; gap: 6px;
++ margin: 2px 0 1px 12px; padding: 3px 8px;
++ border-left: 2px solid color-mix(in srgb, var(--accent) 60%, transparent);
++ border-radius: 0 8px 8px 0;
++ background: color-mix(in srgb, var(--accent) 8%, transparent);
++ color: var(--text-secondary); font-size: 0.82rem; line-height: 1.35;
++ animation: i18nSlideIn 0.35s ease both;
++}
++.chat-translation i { color: var(--accent-light); margin-top: 2px; font-size: 0.8rem; }
++.chat-translation-text { color: var(--text-primary); opacity: 0.92; }
++@keyframes i18nSlideIn { from { opacity: 0; transform: translateY(-3px); } to { opacity: 1; transform: none; } }
++
++/* ── Chat: "auto-translated ↔ Japanese" pill in the tools row ── */
++.chat-translate-hint {
++ display: inline-flex; align-items: center; gap: 5px;
++ margin-left: auto; padding: 2px 9px; border-radius: 999px;
++ font-size: 0.7rem; font-weight: 600; letter-spacing: 0.02em; white-space: nowrap;
++ color: var(--accent-light);
++ background: color-mix(in srgb, var(--accent) 14%, transparent);
++ border: 1px solid color-mix(in srgb, var(--accent) 40%, transparent);
++ cursor: help; animation: i18nPulse 3s ease-in-out infinite;
++}
++@keyframes i18nPulse { 0%,100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 0%, transparent); } 50% { box-shadow: 0 0 12px color-mix(in srgb, var(--accent) 45%, transparent); } }
++
++/* ── Channel: language chip in the info bar ─────────────────── */
++.ch-lang-chip {
++ display: inline-flex; align-items: center; gap: 5px;
++ padding: 2px 10px; border-radius: 999px; font-size: 0.72rem; font-weight: 700;
++ color: #fde68a; background: linear-gradient(135deg, rgba(190,24,93,0.35), rgba(139,92,246,0.3));
++ border: 1px solid rgba(253,230,138,0.45); white-space: nowrap; cursor: help;
++}
++.ch-lang-chip i { color: #fff; }
++
++/* ── Channel: live captions panel (under the player) ─────────── */
++.ch-captions {
++ margin: 10px 0 0; border-radius: 14px; overflow: hidden;
++ background: linear-gradient(160deg, color-mix(in srgb, var(--accent) 10%, var(--bg-card)), var(--bg-card));
++ border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--border));
++ box-shadow: 0 10px 30px rgba(0,0,0,0.25);
++ animation: i18nSlideIn 0.4s ease both;
++}
++.ch-captions-head {
++ display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
++ padding: 8px 12px; border-bottom: 1px solid color-mix(in srgb, var(--accent) 25%, var(--border));
++}
++.ch-captions-title { display: inline-flex; align-items: center; gap: 7px; font-weight: 800; font-size: 0.88rem; color: var(--text-primary); }
++.ch-captions-title i { color: var(--accent-light); }
++.ch-captions-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--live-red); animation: i18nBlink 1.4s ease-in-out infinite; }
++@keyframes i18nBlink { 0%,100% { opacity: 1; box-shadow: 0 0 0 0 rgba(231,76,60,0.6); } 50% { opacity: 0.55; box-shadow: 0 0 0 5px rgba(231,76,60,0); } }
++.ch-captions-sub { color: var(--text-secondary); font-size: 0.76rem; }
++.ch-captions-toggle { margin-left: auto; background: none; border: none; color: var(--text-muted); cursor: pointer; padding: 4px 6px; border-radius: 6px; }
++.ch-captions-toggle:hover { color: var(--text-primary); background: var(--bg-hover); }
++.ch-captions-lines { max-height: 190px; overflow-y: auto; padding: 8px 12px; display: grid; gap: 6px; scroll-behavior: smooth; }
++.ch-captions.collapsed .ch-captions-lines { display: none; }
++.ch-captions.collapsed .ch-captions-toggle i { transform: rotate(180deg); }
++.ch-captions-wait { color: var(--text-muted); font-size: 0.8rem; font-style: italic; }
++.ch-caption { animation: i18nSlideIn 0.35s ease both; padding: 4px 0; border-bottom: 1px dashed color-mix(in srgb, var(--border) 70%, transparent); }
++.ch-caption:last-child { border-bottom: none; }
++.ch-caption-en { font-size: 0.95rem; color: var(--text-primary); line-height: 1.35; }
++.ch-caption-src { font-size: 0.78rem; color: var(--text-secondary); margin-top: 2px; }
++.ch-caption:last-child .ch-caption-en { font-weight: 600; }
++
++/* ── About tab: bio translation ─────────────────────────────── */
++.ch-about-bio-translated {
++ margin: 8px 0 12px; padding: 10px 12px; border-radius: 10px;
++ background: color-mix(in srgb, var(--accent) 8%, transparent);
++ border-left: 3px solid var(--accent); white-space: pre-wrap; line-height: 1.5;
++ animation: i18nSlideIn 0.4s ease both;
++}
++.ch-about-bio-translated-label { font-size: 0.72rem; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: var(--accent-light); margin-bottom: 4px; }
++
++/* ═══════════════════════════════════════════════════════════════
++ STAR OF OPENVIBE — home spotlight
++ ═══════════════════════════════════════════════════════════════ */
++.home-star-section { margin: 4px 0 28px; }
++.star-card-border {
++ position: relative; border-radius: 22px; padding: 2px; overflow: hidden; isolation: isolate;
++ animation: starCardIn 0.8s cubic-bezier(.2,1.3,.3,1) both;
++}
++.star-card-border::before {
++ content: ''; position: absolute; inset: -60%; z-index: -1;
++ background: conic-gradient(from 0deg, #fbbf24, #f472b6, var(--accent), #fbbf24);
++ animation: starSpin 7s linear infinite;
++}
++@keyframes starSpin { to { transform: rotate(360deg); } }
++@keyframes starCardIn { from { opacity: 0; transform: translateY(24px) scale(0.97); } to { opacity: 1; transform: none; } }
++.star-card {
++ position: relative; border-radius: 20px; overflow: hidden;
++ display: grid; grid-template-columns: auto 1fr; gap: 22px; align-items: start;
++ padding: 22px 24px;
++ background:
++ radial-gradient(60% 80% at 100% 0%, color-mix(in srgb, #f472b6 18%, transparent), transparent 60%),
++ radial-gradient(50% 70% at 0% 100%, color-mix(in srgb, var(--accent) 22%, transparent), transparent 60%),
++ var(--bg-card);
++ color: var(--text-primary);
++}
++.star-card.is-live { grid-template-columns: auto 1fr minmax(260px, 340px); }
++.star-card > * { position: relative; z-index: 1; }
++
++/* Drifting sakura petals */
++.star-petals { position: absolute; inset: 0; z-index: 0; pointer-events: none; overflow: hidden; }
++.star-petals span {
++ position: absolute; top: -20px; font-size: 1.1rem; opacity: 0;
++ animation: starPetal 9s linear infinite;
++ filter: drop-shadow(0 2px 3px rgba(0,0,0,0.3));
++}
++.star-petals span:nth-child(1) { left: 6%; animation-delay: 0s; }
++.star-petals span:nth-child(2) { left: 22%; animation-delay: -2s; font-size: 0.9rem; }
++.star-petals span:nth-child(3) { left: 41%; animation-delay: -5s; }
++.star-petals span:nth-child(4) { left: 58%; animation-delay: -1s; font-size: 1.3rem; }
++.star-petals span:nth-child(5) { left: 74%; animation-delay: -7s; }
++.star-petals span:nth-child(6) { left: 90%; animation-delay: -3.5s; font-size: 0.85rem; }
++@keyframes starPetal {
++ 0% { transform: translate(0, -20px) rotate(0deg); opacity: 0; }
++ 10% { opacity: 0.8; }
++ 100% { transform: translate(-60px, 320px) rotate(300deg); opacity: 0; }
++}
++
++.star-avatar-wrap { position: relative; width: 112px; height: 112px; flex: none; }
++.star-avatar-ring {
++ position: absolute; inset: -6px; border-radius: 50%;
++ background: conic-gradient(from 0deg, #fbbf24, #f472b6, var(--accent), #fbbf24);
++ animation: starSpin 5s linear infinite;
++ -webkit-mask: radial-gradient(farthest-side, transparent calc(100% - 5px), #000 calc(100% - 4px));
++ mask: radial-gradient(farthest-side, transparent calc(100% - 5px), #000 calc(100% - 4px));
++}
++.star-avatar-glow { position: absolute; inset: -14px; border-radius: 50%; background: radial-gradient(circle, rgba(251,191,36,0.35), transparent 65%); animation: starGlow 2.6s ease-in-out infinite; }
++@keyframes starGlow { 0%,100% { transform: scale(0.9); opacity: 0.6; } 50% { transform: scale(1.15); opacity: 1; } }
++.star-avatar {
++ position: relative; width: 100%; height: 100%; border-radius: 50%; object-fit: cover;
++ background: var(--bg-tertiary); display: grid; place-items: center;
++ font-size: 2.4rem; font-weight: 900; color: #fff; overflow: hidden;
++}
++.star-avatar img { width: 100%; height: 100%; object-fit: cover; display: block; }
++.star-badge {
++ position: absolute; bottom: -6px; left: 50%; transform: translateX(-50%);
++ padding: 2px 10px; border-radius: 999px; white-space: nowrap;
++ background: linear-gradient(135deg, #fde68a, #fbbf24); color: #1c1002;
++ font-size: 0.66rem; font-weight: 900; letter-spacing: 0.1em; text-transform: uppercase;
++ box-shadow: 0 4px 14px rgba(251,191,36,0.45);
++}
++.star-live-pill {
++ position: absolute; top: -4px; right: -6px; padding: 2px 8px; border-radius: 999px;
++ background: var(--live-red); color: #fff; font-size: 0.64rem; font-weight: 900; letter-spacing: 0.08em;
++ animation: i18nBlink 1.4s ease-in-out infinite;
++}
++
++.star-body { min-width: 0; }
++.star-kicker { display: inline-flex; align-items: center; gap: 7px; font-size: 0.72rem; font-weight: 800; letter-spacing: 0.12em; text-transform: uppercase; color: #fbbf24; margin-bottom: 4px; }
++.star-kicker i { animation: starTwinkle 1.8s ease-in-out infinite; }
++@keyframes starTwinkle { 0%,100% { transform: scale(1) rotate(0); opacity: 1; } 50% { transform: scale(1.35) rotate(20deg); opacity: 0.7; } }
++.star-name { margin: 0 0 6px; font-size: clamp(1.4rem, 3vw, 2rem); font-weight: 900; line-height: 1.1; letter-spacing: -0.01em; }
++.star-name a { color: inherit; text-decoration: none; }
++.star-name a:hover { color: var(--accent-light); }
++.star-name .star-jp { display: block; font-size: 0.55em; font-weight: 600; color: var(--text-secondary); margin-top: 2px; }
++.star-chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 6px 0 10px; }
++.star-chip {
++ display: inline-flex; align-items: center; gap: 5px; padding: 3px 10px; border-radius: 999px;
++ font-size: 0.74rem; font-weight: 700; color: var(--text-primary);
++ background: color-mix(in srgb, var(--accent) 12%, transparent); border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent);
++}
++.star-chip.gold { color: #fde68a; background: rgba(251,191,36,0.12); border-color: rgba(251,191,36,0.45); }
++.star-chip.pink { color: #fbcfe8; background: rgba(244,114,182,0.12); border-color: rgba(244,114,182,0.45); }
++.star-bio { margin: 0 0 4px; color: var(--text-primary); line-height: 1.5; white-space: pre-wrap; font-size: 0.95rem; }
++.star-bio-src { margin: 0 0 8px; color: var(--text-secondary); font-size: 0.82rem; white-space: pre-wrap; line-height: 1.45; }
++.star-bio-label { font-size: 0.68rem; font-weight: 800; letter-spacing: 0.08em; text-transform: uppercase; color: var(--accent-light); margin: 6px 0 2px; }
++.star-overview { color: var(--text-secondary); font-size: 0.84rem; line-height: 1.45; margin: 8px 0 0; }
++.star-actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 14px; }
++.star-actions .btn { font-weight: 700; }
++.star-btn-watch {
++ background: linear-gradient(135deg, #fde68a, #fbbf24 50%, #f59e0b); color: #1c1002; border: none;
++ box-shadow: 0 8px 24px rgba(251,191,36,0.4); position: relative; overflow: hidden;
++}
++.star-btn-watch::after { content: ''; position: absolute; inset: 0; background: linear-gradient(115deg, transparent 35%, rgba(255,255,255,0.6) 50%, transparent 65%); background-size: 250% 100%; animation: starSheen 2.6s linear infinite; pointer-events: none; }
++@keyframes starSheen { 0% { background-position: 120% 0; } 100% { background-position: -120% 0; } }
++.star-live-card { align-self: stretch; }
++.star-live-card .stream-card { height: 100%; }
++.star-offline { color: var(--text-muted); font-size: 0.8rem; margin-top: 8px; }
++
++@media (max-width: 860px) {
++ .star-card, .star-card.is-live { grid-template-columns: 1fr; text-align: center; gap: 14px; padding: 20px 16px; }
++ .star-avatar-wrap { margin: 0 auto; }
++ .star-chips, .star-actions { justify-content: center; }
++ .star-live-card { max-width: 420px; width: 100%; margin: 0 auto; }
++}
++
++@media (prefers-reduced-motion: reduce) {
++ .chat-translation, .chat-translate-hint, .ch-captions, .ch-caption, .ch-captions-dot, .ch-about-bio-translated,
++ .star-card-border, .star-card-border::before, .star-petals span, .star-avatar-ring, .star-avatar-glow, .star-live-pill,
++ .star-kicker i, .star-btn-watch::after { animation: none !important; }
++ .star-petals { display: none; }
++ .chat-translation, .ch-caption, .star-card-border { opacity: 1; transform: none; }
++}
++
++/* ── Chat: "translate to my language" hover button (sits left of the reply button) ── */
++.chat-msg-hoverable .chat-translate-btn {
++ display: none; position: absolute; right: 30px; top: 2px;
++ padding: 2px 6px; font-size: 0.65rem; line-height: 1; z-index: 5; cursor: pointer;
++ background: var(--bg-card, #1e1e2e); border: 1px solid var(--border, #333); border-radius: var(--radius-sm, 4px);
++ color: var(--text-muted, #888); transition: color 0.15s, border-color 0.15s;
++}
++.chat-msg-hoverable:hover .chat-translate-btn { display: inline-flex; }
++.chat-msg-hoverable .chat-translate-btn:hover { color: var(--accent-light); border-color: var(--accent); }
++.chat-translation[hidden] { display: none; }
ddiff --git a/public/css/rs-promo.css b/public/css/rs-promo.css
nnew file mode 100644
iindex 00000000..bcdb407b
---- /dev/null
++++ b/public/css/rs-promo.css
@@@ -0,0 +1,600 @@
++/* ═══════════════════════════════════════════════════════════════
++ RS-PROMO — RobotStreamer switch bonus ($50 Zelle / PayPal, amount is server-driven)
++ Site-wide takeover, marquee ticker, home hero bounty card, claim
++ modal, confetti. Mounted by /js/rs-promo.js; toggled server-side
++ via RS_PROMO_ENABLED (see /api/promo/robotstreamer).
++ ═══════════════════════════════════════════════════════════════ */
++
++:root {
++ --rs-ticker-h: 36px;
++ --rs-blue: #7dd3fc; /* RobotStreamer relay colour used across chat */
++ --rs-blue-deep: #0ea5e9;
++ --rs-gold: #fbbf24;
++ --rs-gold-deep: #d97706;
++ --rs-green: #22c55e;
++ --rs-green-deep: #15803d;
++ --rs-ink: #06060a;
++}
++
++/* ── Shared money text: gold gradient with a travelling shine ── */
++.rs-money {
++ display: inline-block;
++ font-weight: 900;
++ letter-spacing: -0.02em;
++ background: linear-gradient(100deg, #fde68a 0%, var(--rs-gold) 30%, #fff7d6 50%, var(--rs-gold) 70%, var(--rs-gold-deep) 100%);
++ background-size: 240% 100%;
++ -webkit-background-clip: text; background-clip: text;
++ -webkit-text-fill-color: transparent; color: var(--rs-gold);
++ animation: rsShine 3.2s ease-in-out infinite;
++ filter: drop-shadow(0 2px 0 rgba(0,0,0,0.25)) drop-shadow(0 0 14px rgba(251,191,36,0.35));
++}
++@keyframes rsShine { 0% { background-position: 120% 0; } 100% { background-position: -120% 0; } }
++
++/* ═══════════════════════════════════════════════════════════════
++ TICKER — sits directly under the fixed navbar on every page
++ ═══════════════════════════════════════════════════════════════ */
++.rs-ticker {
++ position: fixed; top: var(--navbar-h); left: 0; right: 0;
++ height: var(--rs-ticker-h); z-index: 999;
++ display: flex; align-items: center;
++ color: #fff; font-size: 0.86rem; font-weight: 600;
++ background: linear-gradient(90deg, #4c1d95, #1d4ed8, #0e7490, #7c3aed, #4c1d95);
++ background-size: 400% 100%;
++ animation: rsTickerBg 14s linear infinite, rsTickerDrop 0.6s cubic-bezier(.2,1.2,.3,1) both;
++ box-shadow: 0 8px 24px rgba(0,0,0,0.35), inset 0 -1px 0 rgba(255,255,255,0.08);
++ overflow: hidden; cursor: pointer;
++ user-select: none; -webkit-user-select: none;
++}
++.rs-ticker::before { /* moving diagonal sheen */
++ content: ''; position: absolute; inset: 0; pointer-events: none;
++ background: linear-gradient(115deg, transparent 40%, rgba(255,255,255,0.14) 50%, transparent 60%);
++ background-size: 220% 100%;
++ animation: rsShine 4.5s linear infinite;
++}
++.rs-ticker:hover { filter: brightness(1.12); }
++@keyframes rsTickerBg { 0% { background-position: 0 0; } 100% { background-position: 400% 0; } }
++@keyframes rsTickerDrop { from { transform: translateY(-100%); opacity: 0; } to { transform: none; opacity: 1; } }
++
++.rs-ticker-track {
++ display: flex; align-items: center; width: max-content;
++ animation: rsMarquee 32s linear infinite;
++ will-change: transform;
++}
++.rs-ticker:hover .rs-ticker-track { animation-play-state: paused; }
++@keyframes rsMarquee { to { transform: translateX(-50%); } }
++.rs-ticker-item {
++ display: inline-flex; align-items: center; gap: 8px;
++ padding: 0 26px; white-space: nowrap;
++}
++.rs-ticker-item .rs-money { font-size: 1.05rem; }
++.rs-ticker-item i.fa-robot { color: var(--rs-blue); animation: rsBob 1.6s ease-in-out infinite; }
++.rs-ticker-item .rs-ticker-sep { opacity: 0.45; }
++.rs-ticker-item .rs-ticker-cta {
++ padding: 2px 10px; border-radius: 999px;
++ background: rgba(255,255,255,0.16); border: 1px solid rgba(255,255,255,0.35);
++ font-size: 0.78rem; letter-spacing: 0.04em; text-transform: uppercase;
++ animation: rsPulseRing 2.2s ease-out infinite;
++}
++@keyframes rsBob { 0%,100% { transform: translateY(0) rotate(-6deg); } 50% { transform: translateY(-2px) rotate(6deg); } }
++@keyframes rsPulseRing {
++ 0% { box-shadow: 0 0 0 0 rgba(255,255,255,0.55); }
++ 70% { box-shadow: 0 0 0 9px rgba(255,255,255,0); }
++ 100% { box-shadow: 0 0 0 0 rgba(255,255,255,0); }
++}
++.rs-ticker-close {
++ position: absolute; right: 0; top: 0; bottom: 0;
++ width: 48px; border: none; cursor: pointer; color: #fff;
++ background: linear-gradient(90deg, transparent, rgba(20,10,50,0.85) 40%);
++ font-size: 1rem; display: flex; align-items: center; justify-content: center;
++}
++.rs-ticker-close:hover { color: var(--rs-gold); }
++
++/* Push page content down while the ticker is showing */
++body.rs-ticker-on .page { padding-top: calc(var(--navbar-h) + var(--rs-ticker-h)); }
++body.rs-ticker-on #page-home .hero { padding-top: calc(var(--navbar-h) + var(--rs-ticker-h) + clamp(20px, 4vw, 44px)); }
++body.rs-ticker-on .rs-modal, body.rs-ticker-on .rs-takeover { --rs-ticker-h: 0px; }
++
++/* ═══════════════════════════════════════════════════════════════
++ TAKEOVER — one-time full-screen splash
++ ═══════════════════════════════════════════════════════════════ */
++.rs-takeover {
++ position: fixed; inset: 0; z-index: 20000;
++ display: flex; align-items: center; justify-content: center;
++ padding: 24px 16px; overflow: hidden;
++ background: radial-gradient(120% 90% at 50% 110%, #1e1b4b 0%, var(--rs-ink) 60%);
++ color: #fff;
++ animation: rsFadeIn 0.5s ease both;
++ perspective: 900px;
++}
++.rs-takeover.leaving { animation: rsTakeoverOut 0.45s ease forwards; pointer-events: none; }
++@keyframes rsFadeIn { from { opacity: 0; } to { opacity: 1; } }
++@keyframes rsTakeoverOut { to { opacity: 0; transform: scale(1.06); filter: blur(10px); } }
++
++/* Aurora blobs drifting behind everything */
++.rs-tk-aurora { position: absolute; inset: -20%; pointer-events: none; filter: blur(60px) saturate(1.3); opacity: 0.85; }
++.rs-tk-aurora span {
++ position: absolute; width: 55vmax; height: 55vmax; border-radius: 50%;
++ mix-blend-mode: screen; animation: rsAurora 18s ease-in-out infinite alternate;
++}
++.rs-tk-aurora span:nth-child(1) { left: -10%; top: -10%; background: radial-gradient(circle, rgba(139,92,246,0.9), transparent 62%); }
++.rs-tk-aurora span:nth-child(2) { right: -15%; top: 5%; background: radial-gradient(circle, rgba(14,165,233,0.85), transparent 62%); animation-delay: -6s; animation-duration: 22s; }
++.rs-tk-aurora span:nth-child(3) { left: 20%; bottom: -25%; background: radial-gradient(circle, rgba(34,197,94,0.75), transparent 62%); animation-delay: -12s; animation-duration: 26s; }
++.rs-tk-aurora span:nth-child(4) { right: 15%; bottom: -15%; background: radial-gradient(circle, rgba(251,191,36,0.7), transparent 62%); animation-delay: -3s; animation-duration: 20s; }
++@keyframes rsAurora {
++ 0% { transform: translate(0,0) scale(1); }
++ 50% { transform: translate(8vw,-6vh) scale(1.18); }
++ 100% { transform: translate(-6vw,8vh) scale(0.95); }
++}
++
++/* Synthwave floor grid */
++.rs-tk-grid {
++ position: absolute; left: -50%; right: -50%; bottom: -10%; height: 60%;
++ pointer-events: none; transform-origin: 50% 100%;
++ transform: perspective(600px) rotateX(64deg);
++ background:
++ linear-gradient(to top, rgba(139,92,246,0.55), transparent 70%),
++ repeating-linear-gradient(90deg, rgba(167,139,250,0.35) 0 2px, transparent 2px 80px),
++ repeating-linear-gradient(0deg, rgba(125,211,252,0.35) 0 2px, transparent 2px 80px);
++ background-size: 100% 100%, 100% 100%, 100% 80px;
++ animation: rsGridScroll 1.6s linear infinite;
++ mask-image: linear-gradient(to top, #000 30%, transparent);
++ -webkit-mask-image: linear-gradient(to top, #000 30%, transparent);
++}
++@keyframes rsGridScroll { to { background-position: 0 0, 0 0, 0 80px; } }
++
++.rs-tk-canvas { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; }
++
++.rs-tk-close {
++ position: absolute; top: 14px; right: 16px; z-index: 3;
++ width: 42px; height: 42px; border-radius: 50%;
++ border: 1px solid rgba(255,255,255,0.25); background: rgba(0,0,0,0.35);
++ color: #fff; font-size: 1.2rem; cursor: pointer; backdrop-filter: blur(6px);
++ transition: transform 0.2s, background 0.2s;
++}
++.rs-tk-close:hover { transform: rotate(90deg) scale(1.08); background: rgba(255,255,255,0.15); }
++
++.rs-tk-content {
++ position: relative; z-index: 2; text-align: center;
++ max-width: 780px; width: 100%;
++ animation: rsPopIn 0.8s cubic-bezier(.2,1.4,.3,1) 0.15s both;
++ transform-style: preserve-3d;
++}
++@keyframes rsPopIn { from { opacity: 0; transform: scale(0.72) translateY(40px); } to { opacity: 1; transform: none; } }
++
++.rs-tk-eyebrow {
++ display: inline-flex; align-items: center; gap: 8px;
++ padding: 6px 16px; border-radius: 999px; margin-bottom: 18px;
++ background: rgba(125,211,252,0.12); border: 1px solid rgba(125,211,252,0.45);
++ color: var(--rs-blue); font-weight: 700; font-size: 0.85rem; letter-spacing: 0.08em; text-transform: uppercase;
++ animation: rsGlowBlue 2.4s ease-in-out infinite;
++}
++@keyframes rsGlowBlue { 0%,100% { box-shadow: 0 0 0 rgba(125,211,252,0); } 50% { box-shadow: 0 0 26px rgba(125,211,252,0.45); } }
++.rs-tk-eyebrow i { animation: rsBob 1.6s ease-in-out infinite; }
++
++.rs-tk-title {
++ font-size: clamp(1.7rem, 5.2vw, 3.2rem); line-height: 1.08; font-weight: 900;
++ margin: 0 0 8px; letter-spacing: -0.02em;
++ text-shadow: 0 6px 30px rgba(0,0,0,0.5);
++}
++.rs-tk-title .rs-w { display: inline-block; opacity: 0; animation: rsWordIn 0.6s cubic-bezier(.2,1.3,.3,1) forwards; }
++@keyframes rsWordIn { from { opacity: 0; transform: translateY(26px) rotate(-4deg) scale(0.8); } to { opacity: 1; transform: none; } }
++
++.rs-tk-amount {
++ font-size: clamp(5.5rem, 22vw, 11rem); line-height: 0.95; margin: 6px 0 4px;
++ animation: rsAmountFloat 4s ease-in-out infinite;
++}
++.rs-tk-amount .rs-money { filter: drop-shadow(0 8px 0 rgba(120,53,15,0.6)) drop-shadow(0 0 40px rgba(251,191,36,0.55)); }
++.rs-tk-amount.pop .rs-money { animation: rsShine 3.2s ease-in-out infinite, rsAmountPop 0.7s cubic-bezier(.2,1.6,.3,1); }
++@keyframes rsAmountFloat { 0%,100% { transform: translateY(0) rotate(-1deg); } 50% { transform: translateY(-10px) rotate(1deg); } }
++@keyframes rsAmountPop { 0% { transform: scale(0.6); } 100% { transform: scale(1); } }
++
++.rs-tk-sub { font-size: clamp(1rem, 2.4vw, 1.3rem); color: #e2e8f0; margin: 0 0 20px; font-weight: 500; }
++.rs-tk-sub b { color: #fff; }
++
++/* Payment chips */
++.rs-chips { display: flex; gap: 10px; justify-content: center; flex-wrap: wrap; margin: 0 0 24px; }
++.rs-chip {
++ display: inline-flex; align-items: center; gap: 8px;
++ padding: 8px 16px; border-radius: 999px; font-weight: 800; font-size: 0.95rem;
++ border: 1px solid rgba(255,255,255,0.22); background: rgba(255,255,255,0.08);
++ backdrop-filter: blur(8px); transition: transform 0.2s;
++ animation: rsChipIn 0.6s cubic-bezier(.2,1.4,.3,1) both;
++}
++.rs-chip:hover { transform: translateY(-3px) scale(1.04); }
++.rs-chip:nth-child(2) { animation-delay: 0.1s; }
++.rs-chip:nth-child(3) { animation-delay: 0.2s; }
++@keyframes rsChipIn { from { opacity: 0; transform: translateY(14px) scale(0.8); } to { opacity: 1; transform: none; } }
++.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-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; }
++.rs-chip-oss { border-color: rgba(167,139,250,0.8); background: linear-gradient(135deg, rgba(76,29,149,0.55), rgba(139,92,246,0.25)); }
++.rs-chip-oss i { color: #fff; }
++.rs-tk-trust { max-width: 640px; margin: -6px auto 22px; padding: 10px 14px; border-radius: 12px; font-size: 0.86rem; line-height: 1.5; color: #e2e8f0;
++ background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.14); text-align: left; }
++.rs-tk-trust a, .rs-card-sub a, .rs-sa-sub a { color: #c4b5fd; font-weight: 700; }
++.rs-tk-trust i { color: #c4b5fd; margin-right: 4px; }
++.rs-trust { display: grid; gap: 8px; margin: 0 0 16px; }
++.rs-trust-row { display: grid; grid-template-columns: 22px 1fr; gap: 10px; align-items: start; padding: 10px 12px; border-radius: 12px; font-size: 0.84rem; line-height: 1.45; color: #e2e8f0;
++ background: rgba(139,92,246,0.08); border: 1px solid rgba(139,92,246,0.3); }
++.rs-trust-row i { color: #c4b5fd; font-size: 1rem; margin-top: 2px; }
++.rs-trust-row a { color: #c4b5fd; font-weight: 700; }
++.rs-zelle-mark {
++ display: inline-grid; place-items: center; width: 20px; height: 20px; border-radius: 5px;
++ background: #6d1ed4; color: #fff; font-weight: 900; font-size: 0.8rem; font-family: var(--font);
++}
++
++/* RS → OpenVibe migration flow */
++.rs-flow { display: flex; align-items: center; justify-content: center; gap: 14px; margin: 0 auto 26px; flex-wrap: wrap; }
++.rs-flow-node {
++ display: inline-flex; align-items: center; gap: 8px;
++ padding: 10px 18px; border-radius: 14px; font-weight: 800;
++ background: rgba(255,255,255,0.07); border: 1px solid rgba(255,255,255,0.18);
++ backdrop-filter: blur(8px);
++}
++.rs-flow-node.rs-from { color: var(--rs-blue); }
++.rs-flow-node.rs-to { color: #fff; border-color: rgba(167,139,250,0.7); background: linear-gradient(135deg, rgba(139,92,246,0.45), rgba(109,40,217,0.2)); animation: rsGlowViolet 2s ease-in-out infinite; }
++@keyframes rsGlowViolet { 0%,100% { box-shadow: 0 0 0 rgba(139,92,246,0); } 50% { box-shadow: 0 0 30px rgba(139,92,246,0.55); } }
++.rs-flow-arrow {
++ position: relative; width: clamp(80px, 18vw, 160px); height: 26px;
++ background: repeating-linear-gradient(90deg, rgba(255,255,255,0.55) 0 10px, transparent 10px 20px) 0 50% / 100% 3px no-repeat;
++ animation: rsDash 0.7s linear infinite;
++}
++.rs-flow-arrow::after { content: '▶'; position: absolute; right: -6px; top: 50%; transform: translateY(-50%); color: #fff; font-size: 0.9rem; }
++@keyframes rsDash { to { background-position: 20px 50%; } }
++.rs-walker {
++ position: absolute; top: -18px; left: 0; font-size: 1.5rem; line-height: 1;
++ animation: rsWalk 2.6s ease-in-out infinite;
++}
++@keyframes rsWalk {
++ 0% { left: -4%; transform: translateY(0) rotate(0); }
++ 25% { transform: translateY(-6px) rotate(8deg); }
++ 50% { transform: translateY(0) rotate(0); }
++ 75% { transform: translateY(-6px) rotate(-8deg); }
++ 100% { left: 92%; transform: translateY(0) rotate(0); }
++}
++
++.rs-tk-actions { display: flex; gap: 12px; justify-content: center; flex-wrap: wrap; }
++.rs-btn {
++ display: inline-flex; align-items: center; justify-content: center; gap: 10px;
++ padding: 14px 28px; border-radius: 14px; border: 1px solid transparent;
++ font-size: 1.05rem; font-weight: 800; cursor: pointer; text-decoration: none;
++ transition: transform 0.2s cubic-bezier(.2,1.4,.3,1), box-shadow 0.2s, filter 0.2s;
++ color: #fff; font-family: var(--font);
++}
++.rs-btn:hover { transform: translateY(-3px) scale(1.03); }
++.rs-btn:active { transform: translateY(0) scale(0.98); }
++.rs-btn-gold {
++ color: #1c1002;
++ background: linear-gradient(135deg, #fde68a, var(--rs-gold) 45%, #f59e0b);
++ box-shadow: 0 10px 30px rgba(245,158,11,0.45), inset 0 -3px 0 rgba(0,0,0,0.15);
++ animation: rsGoldGlow 2s ease-in-out infinite;
++ position: relative; overflow: hidden;
++}
++.rs-btn-gold::after {
++ content: ''; position: absolute; inset: 0;
++ background: linear-gradient(115deg, transparent 35%, rgba(255,255,255,0.7) 50%, transparent 65%);
++ background-size: 250% 100%; animation: rsShine 2.4s linear infinite; pointer-events: none;
++}
++@keyframes rsGoldGlow { 0%,100% { box-shadow: 0 10px 30px rgba(245,158,11,0.35); } 50% { box-shadow: 0 10px 44px rgba(245,158,11,0.75); } }
++.rs-btn-ghost { background: rgba(255,255,255,0.08); border-color: rgba(255,255,255,0.3); backdrop-filter: blur(6px); }
++.rs-btn-ghost:hover { background: rgba(255,255,255,0.16); }
++.rs-btn-violet { background: linear-gradient(135deg, var(--accent), var(--accent-dark)); box-shadow: 0 10px 30px rgba(139,92,246,0.4); }
++
++.rs-tk-foot { margin-top: 18px; font-size: 0.82rem; color: #94a3b8; }
++.rs-tk-foot i { color: var(--rs-gold); }
++
++/* ═══════════════════════════════════════════════════════════════
++ HERO BOUNTY CARD — lives in the home hero, under the CTA row
++ ═══════════════════════════════════════════════════════════════ */
++.rs-hero-mount { margin: 22px auto 0; max-width: 820px; position: relative; }
++.rs-bills { position: absolute; inset: -30px -10px; pointer-events: none; z-index: 0; }
++.rs-bills span {
++ position: absolute; font-size: 1.4rem; opacity: 0.55;
++ animation: rsBillFloat 6s ease-in-out infinite;
++ filter: drop-shadow(0 4px 8px rgba(0,0,0,0.4));
++}
++.rs-bills span:nth-child(1) { left: -2%; top: 10%; animation-delay: 0s; }
++.rs-bills span:nth-child(2) { left: 12%; top: 80%; animation-delay: -1.5s; font-size: 1.1rem; }
++.rs-bills span:nth-child(3) { right: 8%; top: 0%; animation-delay: -3s; }
++.rs-bills span:nth-child(4) { right: -3%; top: 70%; animation-delay: -4.5s; font-size: 1.7rem; }
++.rs-bills span:nth-child(5) { left: 48%; top: -12%; animation-delay: -2.2s; font-size: 1rem; }
++@keyframes rsBillFloat {
++ 0%,100% { transform: translateY(0) rotate(-12deg); }
++ 50% { transform: translateY(-14px) rotate(12deg); }
++}
++
++.rs-card-border {
++ position: relative; z-index: 1; border-radius: 22px; padding: 2px; overflow: hidden;
++ isolation: isolate;
++ animation: rsCardIn 0.9s cubic-bezier(.2,1.3,.3,1) 0.3s both;
++}
++.rs-card-border::before {
++ content: ''; position: absolute; inset: -60%; z-index: -1;
++ background: conic-gradient(from 0deg, var(--rs-gold), var(--accent), var(--rs-blue), var(--rs-green), var(--rs-gold));
++ animation: rsSpin 5s linear infinite;
++}
++@keyframes rsSpin { to { transform: rotate(360deg); } }
++@keyframes rsCardIn { from { opacity: 0; transform: translateY(30px) scale(0.94); } to { opacity: 1; transform: none; } }
++
++.rs-hero-card {
++ position: relative; border-radius: 20px; overflow: hidden;
++ display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 20px;
++ padding: 18px 22px; text-align: left;
++ background: linear-gradient(135deg, rgba(20,16,40,0.94), rgba(10,10,16,0.96));
++ color: #fff;
++ transform: rotateX(var(--rs-rx, 0deg)) rotateY(var(--rs-ry, 0deg));
++ transition: transform 0.12s ease-out;
++ transform-style: preserve-3d;
++ box-shadow: 0 30px 60px rgba(0,0,0,0.5);
++}
++.rs-card-border { perspective: 900px; }
++.rs-card-glare {
++ position: absolute; inset: 0; pointer-events: none; z-index: 0;
++ background: radial-gradient(circle at var(--rs-mx, 50%) var(--rs-my, 50%), rgba(255,255,255,0.16), transparent 42%);
++ opacity: var(--rs-glare, 0); transition: opacity 0.3s;
++}
++.rs-hero-card > :not(.rs-card-glare) { position: relative; z-index: 1; }
++
++/* 3D spinning coin */
++.rs-coin-wrap { width: 84px; height: 84px; perspective: 500px; flex: none; }
++.rs-coin {
++ position: relative; width: 100%; height: 100%; transform-style: preserve-3d;
++ animation: rsCoinSpin 4s linear infinite;
++}
++.rs-coin-face {
++ position: absolute; inset: 0; border-radius: 50%;
++ display: grid; place-items: center;
++ font-weight: 900; font-size: 2rem; color: #7c2d12;
++ background: radial-gradient(circle at 35% 30%, #fff3c4, var(--rs-gold) 45%, #b45309 100%);
++ box-shadow: inset 0 0 0 5px rgba(180,83,9,0.55), inset 0 0 0 7px rgba(255,243,196,0.5), 0 0 24px rgba(251,191,36,0.55);
++ backface-visibility: hidden;
++}
++.rs-coin-face.back { transform: rotateY(180deg); }
++.rs-coin-face.back i { font-size: 1.7rem; }
++.rs-coin-edge { position: absolute; inset: 0; border-radius: 50%; transform: translateZ(-3px); background: #b45309; }
++@keyframes rsCoinSpin { to { transform: rotateY(360deg); } }
++
++.rs-card-body { min-width: 0; }
++.rs-card-kicker {
++ display: inline-flex; align-items: center; gap: 7px;
++ color: var(--rs-blue); font-weight: 800; font-size: 0.72rem; letter-spacing: 0.12em; text-transform: uppercase; margin-bottom: 4px;
++}
++.rs-card-kicker i { animation: rsBob 1.6s ease-in-out infinite; }
++.rs-card-title { font-size: clamp(1.05rem, 2.2vw, 1.4rem); font-weight: 800; line-height: 1.2; margin: 0 0 6px; }
++.rs-card-title .rs-money { font-size: 1.5em; vertical-align: -0.08em; }
++.rs-card-sub { color: #cbd5e1; font-size: 0.86rem; margin: 0 0 10px; }
++.rs-card-chips { display: flex; gap: 8px; flex-wrap: wrap; }
++.rs-card-chips .rs-chip { padding: 4px 10px; font-size: 0.78rem; animation: none; }
++
++.rs-card-action { display: flex; flex-direction: column; align-items: center; gap: 8px; flex: none; }
++.rs-card-action .rs-btn { padding: 12px 20px; font-size: 0.95rem; white-space: nowrap; }
++.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; }
++
++/* ═══════════════════════════════════════════════════════════════
++ STREAM-PAGE ALERT — under the player on every channel page
++ ═══════════════════════════════════════════════════════════════ */
++.rs-stream-alert-mount { margin: 10px 0 0; }
++.rs-stream-alert {
++ position: relative; border-radius: 16px; overflow: hidden; color: #fff;
++ background: linear-gradient(120deg, #2e1065, #1e1b4b 40%, #0c4a6e);
++ border: 2px solid transparent;
++ background-clip: padding-box;
++ box-shadow: 0 16px 40px rgba(0,0,0,0.45), 0 0 0 2px rgba(251,191,36,0.55), 0 0 32px rgba(251,191,36,0.25);
++ animation: rsAlertGlow 2.4s ease-in-out infinite, rsCardIn 0.7s cubic-bezier(.2,1.3,.3,1) both;
++}
++@keyframes rsAlertGlow {
++ 0%,100% { box-shadow: 0 16px 40px rgba(0,0,0,0.45), 0 0 0 2px rgba(251,191,36,0.55), 0 0 22px rgba(251,191,36,0.2); }
++ 50% { box-shadow: 0 16px 40px rgba(0,0,0,0.45), 0 0 0 3px rgba(251,191,36,0.95), 0 0 48px rgba(251,191,36,0.5); }
++}
++.rs-sa-stripe {
++ position: absolute; inset: 0 0 auto 0; height: 6px;
++ background: repeating-linear-gradient(135deg, #fbbf24 0 14px, #0b0d10 14px 28px);
++ background-size: 40px 6px; animation: rsStripe 0.8s linear infinite;
++}
++@keyframes rsStripe { to { background-position: 40px 0; } }
++.rs-sa-body { display: grid; grid-template-columns: auto 1fr auto; gap: 18px; align-items: center; padding: 20px 20px 16px; }
++.rs-sa-siren {
++ width: 64px; height: 64px; border-radius: 50%; display: grid; place-items: center; font-size: 1.8rem; color: var(--rs-blue);
++ background: rgba(125,211,252,0.12); border: 2px solid rgba(125,211,252,0.5);
++ animation: rsSiren 1.2s ease-in-out infinite;
++}
++@keyframes rsSiren {
++ 0%,100% { box-shadow: 0 0 0 0 rgba(231,76,60,0.6), 0 0 18px rgba(125,211,252,0.4); transform: rotate(-4deg); }
++ 50% { box-shadow: 0 0 0 14px rgba(231,76,60,0), 0 0 30px rgba(125,211,252,0.7); transform: rotate(4deg); }
++}
++.rs-sa-text { min-width: 0; }
++.rs-sa-head { font-size: clamp(1.05rem, 2vw, 1.45rem); font-weight: 900; line-height: 1.2; letter-spacing: -0.01em; }
++.rs-sa-head .rs-money { font-size: 1.35em; vertical-align: -0.06em; }
++.rs-sa-sub { color: #e2e8f0; font-size: 0.9rem; margin: 6px 0 10px; line-height: 1.45; }
++.rs-sa-sub b { color: #fff; }
++.rs-sa-chips { display: flex; flex-wrap: wrap; gap: 6px; }
++.rs-sa-chips .rs-chip { padding: 4px 10px; font-size: 0.76rem; animation: none; }
++.rs-sa-actions { display: flex; flex-direction: column; gap: 8px; flex: none; }
++.rs-sa-actions .rs-btn { padding: 12px 18px; font-size: 0.95rem; white-space: nowrap; }
++.rs-sa-actions .rs-btn-ghost { padding: 9px 14px; font-size: 0.85rem; }
++.rs-sa-mini {
++ display: none; align-items: center; gap: 10px; padding: 9px 46px 9px 16px; font-weight: 700; font-size: 0.86rem; cursor: pointer;
++}
++.rs-sa-mini i.fa-robot { color: var(--rs-blue); animation: rsBob 1.6s ease-in-out infinite; }
++.rs-sa-mini .rs-money { font-size: 1.05rem; }
++.rs-sa-mini .rs-ticker-cta { margin-left: auto; padding: 2px 10px; border-radius: 999px; background: rgba(255,255,255,0.16); border: 1px solid rgba(255,255,255,0.35); font-size: 0.74rem; text-transform: uppercase; }
++.rs-sa-toggle {
++ position: absolute; top: 12px; right: 10px; width: 30px; height: 30px; border-radius: 50%;
++ background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.2); color: #fff; cursor: pointer; transition: transform 0.25s;
++}
++.rs-sa-toggle:hover { background: rgba(255,255,255,0.18); }
++.rs-stream-alert.minimized .rs-sa-body, .rs-stream-alert.minimized .rs-sa-stripe { display: none; }
++.rs-stream-alert.minimized .rs-sa-mini { display: flex; }
++.rs-stream-alert.minimized { animation: none; box-shadow: 0 6px 20px rgba(0,0,0,0.35), 0 0 0 1px rgba(251,191,36,0.5); }
++.rs-stream-alert.minimized .rs-sa-toggle { top: 50%; transform: translateY(-50%) rotate(180deg); }
++@media (max-width: 720px) {
++ .rs-sa-body { grid-template-columns: 1fr; text-align: center; gap: 12px; padding: 18px 14px 14px; }
++ .rs-sa-siren { margin: 0 auto; width: 54px; height: 54px; font-size: 1.5rem; }
++ .rs-sa-chips { justify-content: center; }
++ .rs-sa-actions { width: 100%; }
++ .rs-sa-actions .rs-btn { width: 100%; }
++ .rs-sa-mini { font-size: 0.78rem; padding-right: 40px; }
++ .rs-sa-mini span:first-of-type { white-space: normal; }
++}
++
++/* ═══════════════════════════════════════════════════════════════
++ CLAIM MODAL
++ ═══════════════════════════════════════════════════════════════ */
++.rs-modal {
++ position: fixed; inset: 0; z-index: 20100;
++ display: flex; align-items: center; justify-content: center; padding: 16px;
++ background: rgba(4,4,10,0.72); backdrop-filter: blur(10px);
++ animation: rsFadeIn 0.3s ease both;
++}
++.rs-modal.leaving { animation: rsFadeOut 0.25s ease forwards; pointer-events: none; }
++@keyframes rsFadeOut { to { opacity: 0; } }
++.rs-modal-card {
++ position: relative; z-index: 1; width: 100%; max-width: 560px; max-height: calc(100vh - 32px); overflow-y: auto;
++ border-radius: 22px; padding: 26px 26px 22px;
++ background: linear-gradient(160deg, #17132b, #0c0c12 60%);
++ border: 1px solid rgba(255,255,255,0.12);
++ box-shadow: 0 40px 90px rgba(0,0,0,0.65), 0 0 0 1px rgba(139,92,246,0.25), 0 0 60px rgba(139,92,246,0.25);
++ color: #fff;
++ animation: rsPopIn 0.55s cubic-bezier(.2,1.4,.3,1) both;
++}
++.rs-modal-card::before {
++ content: ''; position: absolute; inset: 0 0 auto 0; height: 4px;
++ background: linear-gradient(90deg, var(--rs-gold), var(--accent), var(--rs-blue), var(--rs-green), var(--rs-gold));
++ background-size: 300% 100%; animation: rsTickerBg 6s linear infinite;
++}
++.rs-modal-close {
++ position: absolute; top: 12px; right: 14px; width: 36px; height: 36px; border-radius: 50%;
++ background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.15); color: #fff; cursor: pointer; font-size: 1.1rem;
++ transition: transform 0.2s;
++}
++.rs-modal-close:hover { transform: rotate(90deg); }
++
++.rs-modal-head { display: flex; align-items: center; gap: 16px; margin-bottom: 16px; }
++.rs-modal-head .rs-coin-wrap { width: 64px; height: 64px; }
++.rs-modal-head .rs-coin-face { font-size: 1.5rem; }
++.rs-modal-head h3 { margin: 0; font-size: 1.35rem; font-weight: 900; line-height: 1.15; }
++.rs-modal-head h3 .rs-money { font-size: 1.35em; vertical-align: -0.1em; }
++.rs-modal-head p { margin: 4px 0 0; color: #cbd5e1; font-size: 0.86rem; }
++
++/* Zelle / PayPal toggle */
++.rs-toggle {
++ position: relative; display: grid; grid-template-columns: 1fr 1fr;
++ background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.12);
++ border-radius: 14px; padding: 4px; margin-bottom: 18px;
++}
++.rs-toggle-thumb {
++ position: absolute; top: 4px; bottom: 4px; left: 4px; width: calc(50% - 4px);
++ border-radius: 11px; background: linear-gradient(135deg, #6d1ed4, #8b5cf6);
++ transition: transform 0.35s cubic-bezier(.2,1.4,.3,1), background 0.35s;
++ box-shadow: 0 6px 18px rgba(109,30,212,0.5);
++}
++.rs-toggle[data-method="paypal"] .rs-toggle-thumb { transform: translateX(100%); background: linear-gradient(135deg, #003087, #009cde); box-shadow: 0 6px 18px rgba(0,156,222,0.45); }
++.rs-toggle button {
++ position: relative; z-index: 1; background: none; border: none; color: #cbd5e1; cursor: pointer;
++ padding: 10px 8px; font-weight: 800; font-size: 0.95rem; font-family: var(--font);
++ display: inline-flex; align-items: center; justify-content: center; gap: 8px;
++ transition: color 0.2s;
++}
++.rs-toggle button[aria-pressed="true"] { color: #fff; }
++
++.rs-steps { list-style: none; margin: 0 0 16px; padding: 0; display: grid; gap: 8px; counter-reset: rs; }
++.rs-step {
++ display: grid; grid-template-columns: 34px 1fr; gap: 12px; align-items: start;
++ padding: 10px 12px; border-radius: 12px;
++ background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.08);
++ animation: rsStepIn 0.5s cubic-bezier(.2,1.3,.3,1) both;
++}
++.rs-step:nth-child(1) { animation-delay: 0.05s; } .rs-step:nth-child(2) { animation-delay: 0.15s; }
++.rs-step:nth-child(3) { animation-delay: 0.25s; } .rs-step:nth-child(4) { animation-delay: 0.35s; }
++@keyframes rsStepIn { from { opacity: 0; transform: translateX(-18px); } to { opacity: 1; transform: none; } }
++.rs-step-num {
++ counter-increment: rs; width: 34px; height: 34px; border-radius: 10px; display: grid; place-items: center;
++ font-weight: 900; color: #1c1002;
++ background: linear-gradient(135deg, #fde68a, var(--rs-gold));
++ box-shadow: 0 4px 14px rgba(251,191,36,0.35);
++}
++.rs-step-num::before { content: counter(rs); }
++.rs-step.done .rs-step-num { background: linear-gradient(135deg, #86efac, var(--rs-green)); }
++.rs-step.done .rs-step-num::before { content: '✓'; }
++.rs-step div > strong { display: block; font-size: 0.95rem; }
++.rs-step div > span { display: block; color: #cbd5e1; font-size: 0.82rem; line-height: 1.4; }
++.rs-step a { color: var(--rs-blue); }
++
++.rs-msg {
++ position: relative; margin: 0 0 16px; padding: 12px 44px 12px 14px; border-radius: 12px;
++ background: rgba(125,211,252,0.08); border: 1px dashed rgba(125,211,252,0.45);
++ color: #e2e8f0; font-size: 0.86rem; line-height: 1.45; font-family: var(--font-mono);
++}
++.rs-msg-copy {
++ position: absolute; top: 8px; right: 8px; width: 30px; height: 30px; border-radius: 8px;
++ background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.15); color: #fff; cursor: pointer;
++}
++.rs-msg-copy:hover { background: rgba(255,255,255,0.18); }
++.rs-msg-copy.copied { background: var(--rs-green); border-color: var(--rs-green); color: #052e16; }
++
++.rs-modal-actions { display: grid; gap: 10px; }
++.rs-modal-actions .rs-btn { width: 100%; padding: 13px 18px; font-size: 1rem; }
++.rs-modal-actions .rs-row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
++.rs-modal-actions .rs-btn-ghost { font-size: 0.92rem; padding: 11px 14px; }
++.rs-modal-fine { margin: 14px 0 0; color: #94a3b8; font-size: 0.76rem; text-align: center; line-height: 1.5; }
++.rs-modal-fine i { color: #f472b6; animation: rsHeart 1.4s ease-in-out infinite; }
++@keyframes rsHeart { 0%,100% { transform: scale(1); } 30% { transform: scale(1.3); } }
++
++/* Confetti canvas (burst on claim open) */
++.rs-confetti { position: absolute; inset: 0; z-index: 0; pointer-events: none; width: 100%; height: 100%; }
++
++/* Toast-y "copied" wiggle */
++@keyframes rsWiggle { 0%,100% { transform: rotate(0); } 25% { transform: rotate(-6deg); } 75% { transform: rotate(6deg); } }
++.rs-wiggle { animation: rsWiggle 0.4s ease; }
++
++/* ═══════════════════════════════════════════════════════════════
++ RESPONSIVE
++ ═══════════════════════════════════════════════════════════════ */
++@media (max-width: 720px) {
++ :root { --rs-ticker-h: 32px; }
++ .rs-ticker { font-size: 0.78rem; }
++ .rs-ticker-item { padding: 0 16px; }
++ .rs-hero-card { grid-template-columns: 1fr; text-align: center; gap: 12px; padding: 18px 16px; }
++ .rs-coin-wrap { margin: 0 auto; width: 70px; height: 70px; }
++ .rs-card-chips { justify-content: center; }
++ .rs-card-action { width: 100%; }
++ .rs-card-action .rs-btn { width: 100%; }
++ .rs-tk-content { padding: 0 4px; }
++ .rs-flow-arrow { width: 60px; }
++ .rs-tk-actions .rs-btn { width: 100%; }
++ .rs-modal-card { padding: 22px 18px 18px; }
++ .rs-modal-actions .rs-row { grid-template-columns: 1fr; }
++ .rs-tk-grid { height: 45%; }
++ .rs-takeover { align-items: flex-start; overflow-y: auto; padding-top: 58px; }
++ .rs-tk-close { top: 10px; right: 10px; width: 38px; height: 38px; }
++}
++@media (max-height: 640px) {
++ .rs-tk-amount { font-size: clamp(3.5rem, 16vh, 7rem); }
++ .rs-tk-title { font-size: clamp(1.3rem, 4vh, 2.2rem); }
++ .rs-flow { margin-bottom: 14px; }
++}
++
++/* ═══════════════════════════════════════════════════════════════
++ REDUCED MOTION — everything static, nothing lost
++ ═══════════════════════════════════════════════════════════════ */
++@media (prefers-reduced-motion: reduce) {
++ .rs-money, .rs-ticker, .rs-ticker::before, .rs-ticker-track, .rs-ticker-item i.fa-robot, .rs-ticker-item .rs-ticker-cta,
++ .rs-takeover, .rs-tk-aurora span, .rs-tk-grid, .rs-tk-content, .rs-tk-eyebrow, .rs-tk-eyebrow i, .rs-tk-title .rs-w,
++ .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 {
++ animation: none !important;
++ }
++ .rs-tk-title .rs-w, .rs-tk-content, .rs-card-border, .rs-step, .rs-chip { opacity: 1; transform: none; }
++ .rs-ticker-track { width: 100%; justify-content: center; overflow: hidden; }
++ .rs-ticker-track .rs-ticker-copy:nth-child(2) { display: none; }
++ .rs-hero-card { transform: none !important; }
++ .rs-card-glare { display: none; }
++ .rs-tk-canvas, .rs-confetti { display: none; }
++ .rs-money { background: none; -webkit-text-fill-color: var(--rs-gold); color: var(--rs-gold); }
++}
ddiff --git a/public/css/style.css b/public/css/style.css
iindex 69baab7c..3210bcf3 100644
---- a/public/css/style.css
++++ b/public/css/style.css
@@@ -14680,3 +14680,45 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg
.arena-bubble-who { grid-area: who; }
.arena-bubble-text { grid-area: text; }
.arena-bubble-speak { grid-area: speak; align-self: center; }
++
++/* ── Arena: Battle Cam mode (pure mic) — live cams, the feed, callouts ── */
++.arena-hero-cam h1 i { color: var(--live-red); }
++.arena-cam-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 14px; }
++.arena-cam { position: relative; display: grid; grid-template-columns: 132px 1fr; gap: 12px; padding: 10px; border-radius: 14px; background: var(--bg-card); border: 1px solid var(--border); overflow: hidden; transition: border-color 0.2s, box-shadow 0.2s; }
++.arena-cam.is-locked { border-color: color-mix(in srgb, var(--live-red) 60%, var(--border)); box-shadow: 0 0 0 1px color-mix(in srgb, var(--live-red) 40%, transparent), 0 0 24px color-mix(in srgb, var(--live-red) 25%, transparent); }
++.arena-cam.is-quiet { opacity: 0.75; }
++.arena-cam-thumb { position: relative; display: block; border-radius: 10px; overflow: hidden; aspect-ratio: 16/10; background: var(--bg-tertiary); }
++.arena-cam-thumb img.arena-live-thumb { width: 100%; height: 100%; object-fit: cover; display: block; }
++.arena-cam-thumb .arena-portrait { width: 100%; height: 100%; }
++.arena-cam-rec { position: absolute; left: 6px; top: 6px; display: inline-flex; align-items: center; gap: 5px; padding: 2px 7px; border-radius: 999px; background: rgba(0,0,0,0.65); color: #fff; font-size: 0.66rem; font-weight: 800; letter-spacing: 0.06em; }
++.arena-cam-body { min-width: 0; display: flex; flex-direction: column; gap: 6px; }
++.arena-cam-lock { display: inline-flex; align-items: center; gap: 6px; padding: 3px 9px; border-radius: 999px; font-size: 0.74rem; font-weight: 700; color: #fecaca; background: color-mix(in srgb, var(--live-red) 18%, transparent); border: 1px solid color-mix(in srgb, var(--live-red) 45%, transparent); width: fit-content; }
++.arena-cam-lock i { color: var(--live-red); }
++.arena-cam-last q { display: block; font-size: 0.9rem; color: var(--text-primary); line-height: 1.35; quotes: '“' '”'; }
++.arena-cam-last small { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; color: var(--text-secondary); font-size: 0.74rem; margin-top: 3px; }
++.arena-q { display: inline-flex; align-items: center; padding: 1px 7px; border-radius: 999px; font-size: 0.7rem; font-weight: 800; background: var(--bg-tertiary); color: var(--text-secondary); border: 1px solid var(--border); }
++.arena-q.is-hot { color: #fde68a; border-color: rgba(251,191,36,0.5); background: rgba(251,191,36,0.12); }
++.arena-q.is-fire { color: #fff; border-color: var(--live-red); background: color-mix(in srgb, var(--live-red) 55%, transparent); }
++.arena-aim { display: inline-flex; align-items: center; gap: 4px; padding: 1px 8px; border-radius: 999px; font-size: 0.7rem; font-weight: 700; color: var(--text-secondary); background: var(--bg-tertiary); border: 1px solid var(--border); text-decoration: none; }
++.arena-aim.is-fighter { color: #fecaca; border-color: color-mix(in srgb, var(--live-red) 50%, transparent); background: color-mix(in srgb, var(--live-red) 15%, transparent); }
++.arena-aim.is-fighter:hover { background: color-mix(in srgb, var(--live-red) 30%, transparent); }
++.arena-feed-section h2 small .arena-fresh { color: var(--success); }
++.arena-mic-feed-list { display: grid; gap: 8px; }
++.arena-mic-feed-list.is-compact .arena-mic-moment { padding: 8px; }
++.arena-mic-moment { display: grid; grid-template-columns: auto 1fr; gap: 10px; padding: 10px 12px; border-radius: 12px; background: var(--bg-card); border: 1px solid var(--border); border-left: 3px solid var(--fc, var(--accent)); animation: arenaMomentIn 0.4s ease both; }
++.arena-mic-moment.is-beef { border-left-color: var(--live-red); background: linear-gradient(90deg, color-mix(in srgb, var(--live-red) 8%, var(--bg-card)), var(--bg-card)); }
++.arena-mic-moment.is-fire { box-shadow: 0 0 18px color-mix(in srgb, var(--live-red) 22%, transparent); }
++@keyframes arenaMomentIn { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } }
++.arena-mic-moment-body { min-width: 0; }
++.arena-mic-moment-head { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; font-size: 0.82rem; margin-bottom: 3px; }
++.arena-mic-moment-head a { color: var(--text-primary); text-decoration: none; }
++.arena-mic-moment-head small { color: var(--text-muted); margin-left: auto; }
++.arena-mic-moment q { display: block; font-size: 0.98rem; line-height: 1.4; color: var(--text-primary); quotes: '“' '”'; }
++.arena-mic-moment-foot { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; color: var(--text-secondary); font-size: 0.74rem; margin-top: 4px; }
++.arena-receipts-section { margin: 14px 0; }
++.arena-receipts-section h3 { margin: 0 0 8px; }
++@media (max-width: 720px) {
++ .arena-cam { grid-template-columns: 1fr; }
++ .arena-cam-thumb { aspect-ratio: 16/9; }
++}
++@media (prefers-reduced-motion: reduce) { .arena-mic-moment { animation: none; } }
ddiff --git a/public/index.html b/public/index.html
iindex 8501eb4b..4292b3c2 100644
---- a/public/index.html
++++ b/public/index.html
@@@ -62,11 +62,13 @@
</script>
<!-- Stylesheets -->
-- <link rel="stylesheet" href="/css/style.css?v=155">
++ <link rel="stylesheet" href="/css/style.css?v=156">
<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/rs-promo.css?v=3">
++ <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">
<!-- JSON-LD Structured Data -->
@@@ -246,6 +248,8 @@
<i class="fa-solid fa-wand-magic-sparkles"></i> Vibe Coders
</a>
</div>
++ <!-- RobotStreamer switch bonus — bounty card mounted by /js/rs-promo.js (server-driven amount) -->
++ <div class="rs-hero-mount" id="rs-hero-mount"></div>
<!-- Newest shipped commit — built in the open, receipts included. -->
<a class="hero-latest" id="hero-latest" href="/updates" onclick="return handleLinkClick(event, '/updates')" style="display:none"></a>
<div class="hero-philosophy-links">
@@@ -265,6 +269,9 @@
<!-- "While you were away" — returning users' followed-channel digest -->
<div class="home-digest" id="home-digest" style="display:none"></div>
++ <!-- Star of OpenVibe — featured streamer spotlight (star_streamer setting / STAR_STREAMER env) -->
++ <div class="home-star-section" id="home-star-section" style="display:none"></div>
++
<!-- Live Streams -->
<div class="section-header">
<h2><i class="fa-solid fa-circle live-dot"></i> Live Now</h2>
@@@ -795,6 +802,10 @@
<div class="controls-grid" id="controls-grid"></div>
</div>
</div>
++ <!-- RobotStreamer switch bonus — big alert on every stream page (filled by /js/rs-promo.js) -->
++ <div class="rs-stream-alert-mount" id="rs-stream-alert"></div>
++ <!-- Live translation captions for non-English streamers (filled by _startCaptions in app.js) -->
++ <div class="ch-captions" id="ch-captions" style="display:none"></div>
<!-- Streamer Info Bar (below video, Kick/Twitch style) -->
<div class="ch-info-bar" id="ch-info-bar">
<div class="ch-info-bar-left">
@@@ -926,6 +937,8 @@
<div class="stream-layout">
<div class="stream-main">
<!-- Offline header with streamer info -->
++ <!-- RobotStreamer switch bonus — the same big alert on OFFLINE channel pages (filled by /js/rs-promo.js) -->
++ <div class="rs-stream-alert-mount" id="rs-stream-alert-offline"></div>
<div class="ch-offline-header" id="ch-offline-header">
<div class="ch-avatar ch-avatar-lg" id="ch-avatar-offline">?</div>
<div class="ch-offline-header-info">
@@@ -5035,13 +5048,13 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<!-- Scripts -->
<script src="/js/theme-engine.js?v=4"></script>
<script src="/live-notify.js?v=3" defer></script>
--<script src="/js/app.js?v=133" defer></script>
++<script src="/js/app.js?v=134" defer></script>
<script src="/js/stream-player.js?v=21" defer></script>
<script src="/js/stream-pip.js?v=1" defer></script>
<script src="/js/chat-shared.js?v=3" defer></script>
<script src="/js/emotes.js?v=8" defer></script>
<script src="/js/gif-picker.js?v=1" defer></script>
--<script src="/js/chat.js?v=79" defer></script>
++<script src="/js/chat.js?v=80" defer></script>
<script src="/js/chat-autocomplete.js?v=1" defer></script>
<script src="/js/channel-uploads.js?v=12" defer></script>
<script src="/js/controls.js?v=8" defer></script>
@@@ -5107,11 +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=16" defer></script>
++<script src="/js/arena.js?v=17" 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>
<!-- ── OpenVibe shared libs (notifications, account sync, themes) ── -->
<script src="/shared/theme-loader.js" defer></script>
ddiff --git a/public/js/app.js b/public/js/app.js
iindex 10606bcb..b8447626 100644
---- a/public/js/app.js
++++ b/public/js/app.js
@@@ -1774,6 +1774,7 @@ async function loadHome() {
loadHomeRecentOnline();
void loadHomePulse(); // happening-now rail + weekly leaders + AI moments + latest update
void loadHomeDigest(); // "while you were away" for returning logged-in users
++ void loadHomeStar(); // "Star of OpenVibe" spotlight (star_streamer setting)
// Load recent VODs
loadHomeRecentVods();
@@@ -3409,6 +3410,7 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionI
// Reset the channel tabs + render the About tab (About is default/first when set).
// Weather is rendered on demand inside weather panels (see _fillWeatherPanels).
_renderChannelAbout(ch);
++ _applyChannelLanguage(data.language, ch, liveStreams.length > 0);
_resetChannelTabs(ch);
_applyChannelTabMeta(data);
_applyChannelHashTab(); // deep-link: #ai-timeline / #about / #videos … opens that tab
@@@ -5123,11 +5125,94 @@ function _renderAboutView() {
return;
}
html += aiHtml; // AI overview card leads the About tab
-- if (_aboutBio) html += `<div class="ch-about-bio">${_linkify(esc(_aboutBio))}</div>`;
++ if (_aboutBio) html += `<div class="ch-about-bio">${_linkify(esc(_aboutBio))}</div><div class="ch-about-bio-en" id="ch-about-bio-en"></div>`;
html += '<div class="ch-about-panels">' + _aboutPanels.map((p, i) => _aboutPanelViewHTML(p, i)).join('') + '</div>';
host.innerHTML = html;
_fillWeatherPanels();
++ void _fillBioTranslation();
++}
++
++// Non-English bio → an auto-translated English copy right under it (cached server-side).
++const _NON_LATIN_RE = /[\u3040-\u30ff\u4e00-\u9fff\uac00-\ud7af\u0400-\u04ff\u0600-\u06ff\u0e00-\u0e7f\u0590-\u05ff\u0370-\u03ff\u0900-\u097f]/;
++async function _fillBioTranslation() {
++ const host = document.getElementById('ch-about-bio-en');
++ if (!host || !_aboutBio || !_NON_LATIN_RE.test(_aboutBio)) return;
++ const uname = currentChannelUsername;
++ let d = null;
++ try { d = await api(`/streams/channel/${encodeURIComponent(uname)}/bio-en`); } catch { d = null; }
++ if (!d || !d.text || uname !== currentChannelUsername) return;
++ const still = document.getElementById('ch-about-bio-en');
++ if (!still) return;
++ still.innerHTML = `<div class="ch-about-bio-translated"><div class="ch-about-bio-translated-label"><i class="fa-solid fa-language"></i> English · auto-translated from ${esc(d.from_name || d.from || '')}</div>${_linkify(esc(d.text))}</div>`;
++}
++
++/* ── Channel language: info-bar chip + live captions panel ───────────────────────────
++ `language` comes from /api/streams/channel/:username ({ code, name, flag, translate }).
++ Non-English channels get a chip ("🇯🇵 Japanese stream · chat auto-translated") and,
++ while live, a captions panel under the player fed by /api/chat-ai/live-captions. */
++let _chLang = null, _captionsTimer = null, _captionsAfter = 0;
++function _applyChannelLanguage(language, ch, isLive) {
++ _chLang = language || null;
++ // One chip in the live info bar, one in the offline header — whichever is showing.
++ const targets = [
++ ['ch-lang-chip', document.querySelector('#ch-info-bar .ch-info-bar-top')],
++ ['ch-lang-chip-offline', document.querySelector('#ch-offline-header .ch-meta')],
++ ];
++ const off = !_chLang || !_chLang.code || _chLang.code === 'en';
++ for (const [id, host] of targets) {
++ let chip = document.getElementById(id);
++ if (off) { if (chip) chip.remove(); continue; }
++ if (!chip && host) { chip = document.createElement('span'); chip.id = id; chip.className = 'ch-lang-chip'; host.appendChild(chip); }
++ if (!chip) continue;
++ chip.innerHTML = `${esc(_chLang.flag || '🌐')} ${esc(_chLang.name || _chLang.code)} stream${_chLang.translate ? ' · <i class="fa-solid fa-language"></i> chat auto-translated' : ''}`;
++ chip.title = `${_chLang.name}-speaking streamer. Chat is translated both ways automatically — type in your own language.`;
++ }
++ if (off) { _stopCaptions(); const p = document.getElementById('ch-captions'); if (p) p.style.display = 'none'; return; }
++ _startCaptions(ch.username, ch.display_name || ch.username, isLive);
++}
++function _startCaptions(username, displayName, isLive) {
++ _stopCaptions();
++ const panel = document.getElementById('ch-captions');
++ if (!panel) return;
++ if (!isLive) { panel.style.display = 'none'; return; }
++ _captionsAfter = 0;
++ panel.classList.remove('collapsed');
++ panel.innerHTML = `<div class="ch-captions-head">
++ <span class="ch-captions-title"><span class="ch-captions-dot"></span> <i class="fa-solid fa-closed-captioning"></i> Live translation</span>
++ <span class="ch-captions-sub">${esc(_chLang.flag || '')} ${esc(_chLang.name || '')} → 🇺🇸 English · what ${esc(displayName)} is saying</span>
++ <button class="ch-captions-toggle" type="button" onclick="toggleCaptionsPanel()" title="Collapse / expand"><i class="fa-solid fa-chevron-up"></i></button>
++ </div>
++ <div class="ch-captions-lines" id="ch-captions-lines"><div class="ch-captions-wait">Listening… lines land a few seconds behind live.</div></div>`;
++ panel.style.display = 'none'; // revealed once the server says captions are available
++ const tick = async () => {
++ const onPage = document.getElementById('page-channel')?.classList.contains('active');
++ if (!onPage || currentChannelUsername !== username) { _stopCaptions(); return; }
++ let d = null;
++ try { d = await api(`/chat-ai/live-captions/${encodeURIComponent(username)}?after=${_captionsAfter}`); } catch { return; }
++ if (!d || !d.available || !d.live) { panel.style.display = 'none'; return; }
++ panel.style.display = '';
++ const lines = d.lines || [];
++ if (!lines.length) return;
++ const box = document.getElementById('ch-captions-lines');
++ if (!box) return;
++ box.querySelector('.ch-captions-wait')?.remove();
++ for (const l of lines) {
++ _captionsAfter = Math.max(_captionsAfter, Number(l.id) || 0);
++ const row = document.createElement('div');
++ row.className = 'ch-caption';
++ row.innerHTML = l.text_en
++ ? `<div class="ch-caption-en">${esc(l.text_en)}</div><div class="ch-caption-src">${esc(l.text)}</div>`
++ : `<div class="ch-caption-en">${esc(l.text)}</div>`;
++ box.appendChild(row);
++ }
++ while (box.children.length > 14) box.removeChild(box.firstChild);
++ box.scrollTop = box.scrollHeight;
++ };
++ void tick();
++ _captionsTimer = setInterval(tick, 6000);
}
++function _stopCaptions() { if (_captionsTimer) { clearInterval(_captionsTimer); _captionsTimer = null; } }
++function toggleCaptionsPanel() { document.getElementById('ch-captions')?.classList.toggle('collapsed'); }
function _aboutPanelViewHTML(p, i) {
const w = ABOUT_WIDTHS.includes(p.width) ? p.width : 'md';
@@@ -8840,3 +8925,74 @@ function copyDocSection() {
if (!active) return;
_docsCopy(_docsToMarkdown(active), 'docs-copy-section-btn', 'Copied!');
}
++
++
++/* ── Star of OpenVibe — home spotlight for the featured streamer ─────────────────────
++ GET /api/home/star → { star: { username, display_name, avatar_url, bio, bio_en, language,
++ live, ai_overview, follower_count, last_live_at } } (null when no star is configured). */
++async function loadHomeStar() {
++ const sec = document.getElementById('home-star-section');
++ if (!sec) return;
++ let data = null;
++ try { data = await api('/home/star'); } catch { data = null; }
++ const s = data && data.star;
++ if (!s) { sec.style.display = 'none'; sec.innerHTML = ''; return; }
++ const lang = s.language || { code: 'en', name: 'English', flag: '' };
++ const foreign = lang.code && lang.code !== 'en';
++ const name = esc(s.display_name || s.username);
++ const path = channelPath(s.username);
++ const initial = esc(String(s.display_name || s.username || '?').charAt(0).toUpperCase());
++ const avatar = s.avatar_url
++ ? `<img src="${esc(s.avatar_url)}" alt="${name}" loading="lazy">`
++ : initial;
++ const bioSrc = (s.bio || '').trim();
++ const bioEn = (s.bio_en || '').trim();
++ let bioHtml = '';
++ if (foreign && bioEn) {
++ bioHtml = `<div class="star-bio-label"><i class="fa-solid fa-language"></i> English · auto-translated</div><p class="star-bio">${_linkify(esc(bioEn))}</p>
++ <div class="star-bio-label">${esc(lang.flag)} ${esc(lang.name)} · original</div><p class="star-bio-src">${_linkify(esc(bioSrc))}</p>`;
++ } else if (bioSrc) {
++ bioHtml = `<p class="star-bio">${_linkify(esc(bioSrc))}</p>`;
++ }
++ const overview = s.ai_overview ? `<p class="star-overview"><i class="fa-solid fa-wand-magic-sparkles"></i> ${esc(String(s.ai_overview).slice(0, 260))}${String(s.ai_overview).length > 260 ? '…' : ''}</p>` : '';
++ const chips = [
++ foreign ? `<span class="star-chip pink">${esc(lang.flag)} Streams in ${esc(lang.name)}</span>` : '',
++ foreign ? `<span class="star-chip"><i class="fa-solid fa-language"></i> Chat auto-translated both ways</span>` : '',
++ s.category ? `<span class="star-chip">${esc(_capTag(s.category))}</span>` : '',
++ (s.follower_count > 0) ? `<span class="star-chip gold"><i class="fa-solid fa-heart"></i> ${esc(String(s.follower_count))} follower${s.follower_count === 1 ? '' : 's'}</span>` : '',
++ ].filter(Boolean).join('');
++ const isLive = !!s.live;
++ const offline = !isLive && s.last_live_at ? `<div class="star-offline"><i class="fa-regular fa-clock"></i> Last live ${esc(timeAgo(s.last_live_at))} — follow to get pinged next time.</div>` : '';
++ const liveCard = isLive ? `<div class="star-live-card">${streamCardHTML(s.live, true)}</div>` : '';
++ sec.innerHTML = `
++ <div class="section-header">
++ <h2><i class="fa-solid fa-star" style="color:#fbbf24"></i> Star of OpenVibe</h2>
++ <span class="muted" style="font-size:0.8rem">the streamer we're rolling out the red carpet for</span>
++ </div>
++ <div class="star-card-border">
++ <div class="star-card${isLive ? ' is-live' : ''}">
++ <div class="star-petals" aria-hidden="true"><span>🌸</span><span>🌸</span><span>✨</span><span>🌸</span><span>✨</span><span>🌸</span></div>
++ <div class="star-avatar-wrap">
++ <div class="star-avatar-glow" aria-hidden="true"></div>
++ <div class="star-avatar-ring" aria-hidden="true"></div>
++ <a class="star-avatar" href="${esc(path)}" onclick="return handleLinkClick(event, '${esc(path)}')" style="${s.profile_color ? `background:${esc(s.profile_color)}` : ''}">${avatar}</a>
++ ${isLive ? '<span class="star-live-pill">LIVE</span>' : ''}
++ <span class="star-badge">⭐ Star</span>
++ </div>
++ <div class="star-body">
++ <div class="star-kicker"><i class="fa-solid fa-star"></i> Featured streamer</div>
++ <h3 class="star-name"><a href="${esc(path)}" onclick="return handleLinkClick(event, '${esc(path)}')">${name}</a>${foreign && lang.code === 'ja' ? '<span class="star-jp">OpenVibeの看板配信者 — ようこそ!</span>' : ''}</h3>
++ <div class="star-chips">${chips}</div>
++ ${bioHtml}
++ ${overview}
++ ${offline}
++ <div class="star-actions">
++ <a class="btn btn-lg star-btn-watch" href="${esc(path)}" onclick="return handleLinkClick(event, '${esc(path)}')"><i class="fa-solid ${isLive ? 'fa-play' : 'fa-user'}"></i> ${isLive ? 'Watch now' : 'Visit channel'}</a>
++ <a class="btn btn-outline btn-lg" href="${esc(path)}#about" onclick="return handleLinkClick(event, '${esc(path)}#about')"><i class="fa-solid fa-comments"></i> Say hi${foreign ? ' — any language works' : ''}</a>
++ </div>
++ </div>
++ ${liveCard}
++ </div>
++ </div>`;
++ sec.style.display = '';
++}
ddiff --git a/public/js/arena.js b/public/js/arena.js
iindex a9ed19cf..bd5ebf7d 100644
---- a/public/js/arena.js
++++ b/public/js/arena.js
@@@ -1,29 +1,24 @@
/**
-- * OpenVibe.Live — Arena tab (speech-driven beefs + the community shit-talking board)
++ * OpenVibe.Live — Arena tab — BATTLE CAM mode (pure mic)
*
* Routes (handled from app.js):
-- * /arena pulse · on the mic now · open beefs · the board · ladders
-- * /arena/beef/<id> one beef: tug-of-war, clock, announcer feed, receipts, sides
-- * /arena/topic/<id> one board subject: lore, moments (chat + on-mic with VOD links), fighters on it
++ * /arena live cams · the shit-talk feed · beefs · the ladder
++ * /arena/beef/<id> one beef: tug-of-war, clock, ringside feed, receipts
* /arena/live/<username> the ears: what the listener hears from a live fighter (auto-refresh)
-- * /arena/<username> fighter profile (stats drill down, voice + quotes, level, beefs, rivalries)
++ * /arena/<username> fighter profile (mic stats, voice + quotes, level, receipts, beefs, rivalries)
*
-- * Nothing here starts a fight. Beefs open when a streamer says another fighter's name on mic
-- * while talking shit (server/arena/listener.js). Everything renders with escaped template
-- * strings — persona/quote/headline text is AI-written or transcribed and is never trusted as HTML.
++ * Nothing here starts a fight and nothing in chat counts. Everything on this page is what
++ * fighters SAID ON MIC (server/arena/listener.js → mic.js / beef.js). All text is transcribed or
++ * AI-written and rendered through _aEsc — never trusted as HTML.
*/
'use strict';
--const ARENA_STATS = ['hype', 'grind', 'chat', 'loyalty', 'clutch', 'vibe', 'mic'];
--const ARENA_STAT_LABEL = { hype: 'Hype', grind: 'Grind', chat: 'Chat', loyalty: 'Loyalty', clutch: 'Clutch', vibe: 'Vibe', mic: 'Mic' };
--const ARENA_KIND = {
-- topic: { label: 'Subject', icon: 'fa-comment-dots' },
-- bounty: { label: 'Bounty', icon: 'fa-sack-dollar' },
--};
++let ARENA_STATS = ['heat', 'aim', 'kills', 'mouth', 'clapback', 'stamina', 'pace'];
++let ARENA_STAT_LABEL = { heat: 'Heat', aim: 'Aim', kills: 'Kills', mouth: 'Mouth', clapback: 'Clapback', stamina: 'Stamina', pace: 'Pace' };
let _arenaRoster = null;
let _arenaTimers = [];
let _arenaImagePoll = null;
--let _arenaUtterance = null;
++let _arenaFeedTop = 0;
function _aEsc(s) { return typeof esc === 'function' ? esc(String(s ?? '')) : String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])); }
function _aNum(n) { n = Number(n) || 0; return n >= 1000 ? `${(n / 1000).toFixed(n >= 10000 ? 0 : 1)}k` : String(Number.isInteger(n) ? n : n.toFixed(1)); }
@@@ -50,9 +45,6 @@ function _aStopTimers() {
function _aEvery(ms, fn) { const t = setInterval(() => { if (typeof currentPage !== 'undefined' && currentPage !== 'arena') return _aStopTimers(); fn(); }, ms); _arenaTimers.push(t); return t; }
// ── Speech: hear a line in that person's OpenVibe chat voice ──
--// /api/arena/voice/<user>?t=… returns the audio synthesized once in their equipped cosmetic voice (or
--// their per-identity chat voice); the server caches the file and the browser caches the URL for a
--// week, so a repeat click never re-synthesizes. Browser speech is only the offline fallback.
let _arenaAudio = null;
function _aStopSpeaking() {
try { if (_arenaAudio) { _arenaAudio.pause(); _arenaAudio.src = ''; } } catch { /* */ }
@@@ -71,7 +63,6 @@ function _aSpeak(text, btn, who) {
_arenaAudio = a;
a.onended = done;
a.onerror = () => {
-- // Engine down / budget hit → browser voice so the button still does something.
if (_arenaAudio !== a) return;
_arenaAudio = null;
try {
@@@ -97,32 +88,9 @@ function _aPortrait(f, size = 'md') {
return `<div class="arena-portrait arena-portrait-${size} arena-portrait-initial" style="--fc:${_aEsc(color)}"><span>${_aEsc(_aInitial(u))}</span></div>`;
}
--/** Heptagon radar chart as inline SVG (no library). */
--function _aRadar(r, color, size = 200) {
-- const c = size / 2, R = size / 2 - 28;
-- const angle = (i) => -Math.PI / 2 + (i * 2 * Math.PI) / ARENA_STATS.length;
-- const pt = (i, v) => { const rr = R * (v / 99); return [c + rr * Math.cos(angle(i)), c + rr * Math.sin(angle(i))]; };
-- const ring = (v) => ARENA_STATS.map((_, i) => pt(i, v).map(x => x.toFixed(1)).join(',')).join(' ');
-- const poly = ARENA_STATS.map((k, i) => pt(i, r[k] || 0).map(x => x.toFixed(1)).join(',')).join(' ');
-- const labels = ARENA_STATS.map((k, i) => {
-- const rr = R + 16, x = c + rr * Math.cos(angle(i)), y = c + rr * Math.sin(angle(i));
-- return `<text x="${x.toFixed(1)}" y="${y.toFixed(1)}" text-anchor="middle" dominant-baseline="middle">${_aEsc(ARENA_STAT_LABEL[k])}</text>`;
-- }).join('');
-- return `<svg class="arena-radar" viewBox="0 0 ${size} ${size}" width="${size}" height="${size}" aria-hidden="true">
-- ${[33, 66, 99].map(v => `<polygon points="${ring(v)}" class="arena-radar-ring"></polygon>`).join('')}
-- <polygon points="${poly}" class="arena-radar-fill" style="fill:${_aEsc(color)}33;stroke:${_aEsc(color)}"></polygon>
-- ${labels}
-- </svg>`;
--}
--
--/**
-- * A fighter's radar — their AI characteristics (six stats named after their own bits, read from
-- * their chat history + channel), drawn correctly: value v ∈ [0, 99] sits at radius R·v/99 on its
-- * axis, rings at 33/66/99, axis lines, a value pill at each vertex. Falls back to the objective
-- * seven only when the persona has no custom stats yet.
-- */
--function _aCustomRadar(f, color, size = 240) {
-- const cs = (f.persona && Array.isArray(f.persona.custom_stats) ? f.persona.custom_stats : []).filter(x => x && x.name && Number.isFinite(Number(x.value))).slice(0, 8);
++/** 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 N = axes.length, c = size / 2, R = size / 2 - 62;
const ang = (i) => -Math.PI / 2 + (i * 2 * Math.PI) / N;
@@@ -163,18 +131,23 @@ function _aSpark(series, color) {
<div class="arena-spark-caption"><span>${_aEsc(_aDate(series[0].date))}</span><span>peak ${_aEsc(_aNum(max))}</span><span>${_aEsc(_aDate(series[series.length - 1].date))}</span></div>`;
}
--function _aChannelLink(u) { return `/${encodeURIComponent(u.username)}`; }
++function _aChannelLink(u, slot = null) { return typeof channelPath === 'function' ? channelPath(u.username, slot) : `/@${encodeURIComponent(u.username)}`; }
function _aFighterLink(u) { return `/arena/${encodeURIComponent(u.username)}`; }
function _aConsoleLink(u) { return `/arena/live/${encodeURIComponent(u.username)}`; }
function _aBeefLink(b) { return `/arena/beef/${b.id}`; }
--function _aTopicLink(t) { return `/arena/topic/${t.id}`; }
function _aVodLink(vodId, sec) { return vodId ? `/vod/${vodId}?t=${Math.max(0, Math.floor(sec || 0))}` : null; }
function _aA(href, inner, cls = '', title = '') { return `<a class="${cls}" href="${_aEsc(href)}" ${title ? `title="${_aEsc(title)}"` : ''} onclick="return handleLinkClick(event, '${_aEsc(href)}')">${inner}</a>`; }
function _aPlay(vodId, sec, label = '') { const h = _aVodLink(vodId, sec); return h ? _aA(h, `<i class="fa-solid fa-play"></i>${label ? ` ${_aEsc(label)}` : ''}`, 'arena-play', 'Hear them say it (jumps to the VOD)') : ''; }
--function _aLevelPill(level) { return `<span class="arena-lvl" title="Trash Level — earned by talking shit on the board and in beefs">TL ${_aEsc(level ?? 1)}</span>`; }
++function _aLevelPill(level) { return `<span class="arena-lvl" title="Trash Level — XP from what you say on mic, nothing else">TL ${_aEsc(level ?? 1)}</span>`; }
function _aBriefChip(f, extra = '') {
return _aA(_aFighterLink(f.user), `${_aPortrait(f, 'xs')}<span><strong>${_aEsc(f.fighter_name)}${f.live ? ' <span class="arena-live-pill">LIVE</span>' : ''}</strong><small>${f.rank ? `#${f.rank} · ` : ''}${_aLevelPill(f.level)}${extra}</small></span>`, 'arena-chip');
}
++function _aQ(q) { return `<span class="arena-q ${q >= 8 ? 'is-fire' : q >= 6 ? 'is-hot' : ''}" title="judge score">${q}/10</span>`; }
++function _aAim(m) {
++ if (m.target) return _aA(_aFighterLink(m.target.user), `<i class="fa-solid fa-crosshairs"></i> ${_aEsc(m.target.fighter_name)}`, 'arena-aim is-fighter', 'called out on mic — this fed a beef');
++ if (m.aimed_at && m.aimed_at !== 'nobody') return `<span class="arena-aim"><i class="fa-solid fa-bullseye"></i> at ${_aEsc(m.aimed_at)}</span>`;
++ return '';
++}
// ── Page entry ───────────────────────────────────────────────
@@@ -185,63 +158,56 @@ async function loadArenaPage(segments = []) {
const [, first, second] = segments;
try {
if (first === 'beef' && second) return await _aRenderBeef(root, Number(second));
-- if (first === 'topic' && second) return await _aRenderTopic(root, Number(second));
if (first === 'live' && second) return await _aRenderConsole(root, second);
-- if (first === 'chatter' && second) return await _aRenderChatter(root, decodeURIComponent(second));
++ if (first === 'topic' || first === 'chatter') return _aGone(root);
if (first) return await _aRenderFighter(root, first);
return await _aRenderHome(root);
} catch (err) {
root.innerHTML = `<div class="arena-empty"><i class="fa-solid fa-plug-circle-xmark"></i><p>${_aEsc(err?.message || 'The arena lights went out.')}</p></div>`;
}
}
++function _aGone(root) {
++ root.innerHTML = `<div class="arena-empty"><i class="fa-solid fa-microphone-lines"></i><p>That part of the Arena is gone. It's <b>pure mic</b> now — no boards, no topics, no chat levels. Everything that counts is said on stream.</p>${_aA('/arena', 'Back to the cams', 'btn btn-primary')}</div>`;
++}
--// ── Home ─────────────────────────────────────────────────────
++// ── Home: Battle Cam ─────────────────────────────────────────
async function _aRenderHome(root) {
-- root.innerHTML = _aSpinner('Reading the room…');
-- const [boardData, beefs, live, roster] = await Promise.all([api('/arena/board'), api('/arena/beefs'), api('/arena/live').catch(() => ({ live: [] })), api('/arena/fighters')]);
++ root.innerHTML = _aSpinner('Putting the ears on…');
++ const [feed, beefs, live, roster] = await Promise.all([api('/arena/feed?limit=40').catch(() => ({ feed: [] })), api('/arena/beefs'), api('/arena/live').catch(() => ({ live: [] })), api('/arena/fighters')]);
_arenaRoster = roster;
++ if (Array.isArray(roster.stats) && roster.stats.length) { ARENA_STATS = roster.stats; ARENA_STAT_LABEL = Object.fromEntries(roster.stats.map(k => [k, roster.stat_meta?.[k]?.label || k])); }
++ _arenaFeedTop = feed.feed?.[0]?.id || 0;
root.innerHTML = `
-- <div class="arena-hero">
++ <div class="arena-hero arena-hero-cam">
<div>
-- <h1><i class="fa-solid fa-hand-fist"></i> Arena</h1>
-- <p class="arena-lede">Nobody clicks "fight" here. Say a fighter's name on mic while you're talking shit and the beef opens itself. Say something the site is on about and it lands on the board as lore. Chat piles on, hypes, starts subjects and posts bounties. Nobody votes.</p>
++ <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>
</div>
<div class="arena-hero-actions">
-- ${_aMe() ? `<button class="btn btn-primary" id="arena-new-topic-btn"><i class="fa-solid fa-plus"></i> Start a topic</button>` : ''}
-- <span class="arena-note">${roster.ai ? '<i class="fa-solid fa-ear-listen"></i> AI ears 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>
++ ${_aMe() ? _aA('/broadcast', '<i class="fa-solid fa-tower-broadcast"></i> Get on a cam', 'btn btn-primary') : ''}
</div>
</div>
-- <section class="arena-pulse" id="arena-pulse">${_aPulse(boardData.pulse, boardData)}</section>
-- <section class="arena-me" id="arena-me">${_aMe() ? _aSpinner('Loading your arena…') : `<div class="arena-me-guest"><i class="fa-solid fa-user-plus"></i> <b>Sign in</b> and every line you type about a subject on the board is XP, a yap level, a card — and OpenCoins on each level-up.</div>`}</section>
-- <section class="arena-live" id="arena-live">${_aRenderLive(live.live || [])}</section>
++ <section class="arena-me" id="arena-me">${_aMe() ? _aSpinner('Checking your beefs…') : `<div class="arena-me-guest"><i class="fa-solid fa-microphone"></i> <b>Sign in</b>, go live with transcription on, and the Arena hears you. Say a name. See what happens.</div>`}</section>
++ <section class="arena-live arena-cams" id="arena-live">${_aRenderCams(live.live || [])}</section>
<section class="arena-beefs" id="arena-beefs">${_aRenderBeefs(beefs)}</section>
-- <section class="arena-board" id="arena-board">${_aRenderBoard(boardData)}</section>
-- <section class="arena-ladders">
-- <div class="arena-ladder">
-- <h2><i class="fa-solid fa-fire"></i> Trash Level ladder <small>XP from beef hits, judged moments on subjects, chat hype</small></h2>
-- ${_aLevelsList(boardData.levels || [])}
-- ${(boardData.fighters_week || []).length ? `<div class="arena-yap-week"><span class="arena-note"><i class="fa-solid fa-crown" style="color:#f5c542"></i> this week:</span> ${boardData.fighters_week.map((f, i) => _aA(_aFighterLink(f.user), `${i === 0 ? '👑 ' : ''}${_aEsc(f.fighter_name)} <b>+${f.gained}</b>`, 'arena-tag')).join(' ')}</div>` : ''}
-- </div>
-- <div class="arena-ladder arena-ladder-yap" id="arena-yappers">
-- ${_aYappersSection(boardData)}
-- </div>
-- </section>
++ <section class="arena-feed-section" id="arena-feed">${_aRenderFeed(feed.feed || [])}</section>
<section class="arena-leaderboard">
<div class="arena-board-head">
-- <h2><i class="fa-solid fa-ranking-star"></i> Power ladder <small>${roster.fighters.length} fighters · stats from real streams</small></h2>
++ <h2><i class="fa-solid fa-ranking-star"></i> The ladder <small>${roster.fighters.length} fighters · every number from the mic</small></h2>
<input type="search" id="arena-search" placeholder="Find a fighter…" autocomplete="off">
</div>
<div class="arena-list" id="arena-list"></div>
</section>
<section class="arena-rules">
-- <h3>How the Arena works</h3>
++ <h3>How Battle Cam works</h3>
<ul>
-- <li><b>Beefs.</b> Say another fighter's name on stream while talking shit → a beef opens and they go on the clock (15 min if they're live, 24 h if not; the clock resets every time a side answers). Silence is a forfeit. Best mouth after 24 h wins. Beat someone ranked 4+ spots above you and it's an <b>upset</b>.</li>
-- <li><b>The board.</b> Subjects show up on their own from what chat and streamers are saying (the AI reads global chat + live mics every few minutes). Each subject has <b>threads</b> — the angles people are actually arguing. Every chat or on-mic line that touches one becomes a <b>moment</b>; the story gets rewritten as it escalates. Nobody joins anything: the ears detect who's on what. Streamers' judged lines → XP → Trash Level; chatters' lines → yap XP. Bounties come from chat (<code>!bounty</code>).</li>
-- <li><b>Start one.</b> Signed-in users can put up one subject per 24 h (per person and per connection); the AI rewrites it into a proper headline. No voting anywhere — you don't pick sides, you pile on.</li>
-- <li><b>Chat.</b> <code>!hype</code> boosts your streamer · <code>!topic &lt;text&gt;</code> · <code>!bounty &lt;user&gt;</code> · <code>!beef</code> · <code>!board</code> · <code>!arena</code>.</li>
-- <li><b>Language.</b> Nothing gets censored for being offensive. The only lines that don't count: real threats, anything sexual about minors, and doxxing.</li>
++ <li><b>The ears.</b> Every 15 s the Arena reads the live transcription of every fighter's cam. That is the only input. Chat can <code>!hype</code> a side of a beef and that is all chat can do.</li>
++ <li><b>Callouts → beefs.</b> Say another fighter's name (however the mic hears it) while talking shit → the ears lock on, the judge scores it, the beef opens and they're on the clock: <b>15 min if live, 24 h if not</b>. Every answer flips the clock. Silence is a forfeit. Best mouth after 24 h wins. Beat someone ranked 4+ spots above you → <b>upset</b>.</li>
++ <li><b>Free shit talk.</b> Not aimed at a fighter? Still judged. Ranting at chat, the mods, other platforms, the game — if it's actually shit talk it lands in the feed with a score and pays Trash Level XP. Gameplay narration and small talk score nothing.</li>
++ <li><b>The ladder.</b> Seven ratings, all mic: <b>Heat</b> (how good the shit talk is) · <b>Aim</b> (callouts per hour on mic) · <b>Kills</b> (beefs won) · <b>Mouth</b> (share of stream spent talking) · <b>Clapback</b> (answering when called out) · <b>Stamina</b> (minutes of speech) · <b>Pace</b> (words per minute). Percentiles across the roster → POWER, plus a mouth bonus for recent XP and wins.</li>
++ <li><b>Language.</b> Nothing is censored for being offensive. The only lines that don't count: real threats, anything sexual about minors, and doxxing.</li>
</ul>
</section>`;
_aRenderList(roster.fighters);
@@@ -251,16 +217,13 @@ async function _aRenderHome(root) {
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))));
});
-- document.getElementById('arena-new-topic-btn')?.addEventListener('click', () => _aTopicComposer());
-- _aEvery(20000, async () => {
++ _aEvery(12000, async () => {
try {
-- const [b, l, bd] = await Promise.all([api('/arena/beefs'), api('/arena/live'), api('/arena/board')]);
-- const beefsEl = document.getElementById('arena-beefs'), liveEl = document.getElementById('arena-live'), boardEl = document.getElementById('arena-board'), pulseEl = document.getElementById('arena-pulse'), yapEl = document.getElementById('arena-yappers');
-- if (yapEl) { const prevTop = yapEl.dataset.top; yapEl.innerHTML = _aYappersSection(bd); if (prevTop && bd.yappers?.[0] && prevTop !== bd.yappers[0].key) _aFlash(yapEl, 'new #1 yapper'); yapEl.dataset.top = bd.yappers?.[0]?.key || ''; }
++ const [b, l, fd] = await Promise.all([api('/arena/beefs'), api('/arena/live'), api('/arena/feed?limit=40')]);
++ const beefsEl = document.getElementById('arena-beefs'), liveEl = document.getElementById('arena-live'), feedEl = document.getElementById('arena-feed');
if (beefsEl) beefsEl.innerHTML = _aRenderBeefs(b);
-- if (liveEl) liveEl.innerHTML = _aRenderLive(l.live || []);
-- if (boardEl) boardEl.innerHTML = _aRenderBoard(bd);
-- if (pulseEl) pulseEl.innerHTML = _aPulse(bd.pulse, bd);
++ if (liveEl) liveEl.innerHTML = _aRenderCams(l.live || []);
++ if (feedEl) { const top = fd.feed?.[0]?.id || 0; feedEl.innerHTML = _aRenderFeed(fd.feed || [], { fresh: top > _arenaFeedTop ? fd.feed.filter(m => m.id > _arenaFeedTop).length : 0 }); if (top > _arenaFeedTop) _aFlash(feedEl, 'new line on the mic'); _arenaFeedTop = top; }
_aBindHome(root);
} catch { /* keep the last render */ }
});
@@@ -270,65 +233,60 @@ async function _aRenderHome(root) {
async function _aRenderMe() {
const el = document.getElementById('arena-me');
if (!el) return;
-- let me = await api('/arena/me');
-- // Daily check-in: first visit of the day pays XP (+ streak). One call, idempotent.
-- if (!me.checked_in) { try { const r = await api('/arena/checkin', { method: 'POST' }); if (!r.already) { _aToast(`+${r.gained} XP — daily check-in${r.streak >= 2 ? ` · ${r.streak}-day streak` : ''}${r.leveled_up ? ` · LEVEL ${r.level}!` : ''}`, 'success'); if (r.leveled_up) _aLevelUp(r.level); me = await api('/arena/me'); } } catch { /* */ } }
-- const c = me.chatter, pct = c.xp_for_next ? Math.round((c.xp_into_level / c.xp_for_next) * 100) : 100;
++ const me = await api('/arena/me');
++ if (!me.on_roster) { el.innerHTML = `<div class="arena-me-guest"><i class="fa-solid fa-microphone-slash"></i> You're not on the roster yet — the Arena only knows what the transcription hears. Go live with audio transcription on and say something worth judging.</div>`; return; }
++ const f = me.fighter, l = f.level, pct = l.xp_per_level ? Math.round((l.xp_into_level / l.xp_per_level) * 100) : 0;
el.innerHTML = `<div class="arena-me-inner">
-- <div class="arena-me-left">${_aYapRing(c, 56)}<div><div class="arena-me-name"><b>${_aEsc(c.name)}</b> ${me.progress ? _aTierBadge(me.progress.tier) : ''} <span class="arena-lvl">YAP ${c.level} · ${_aEsc((c.card && c.card.title) || c.title)}</span>${c.streak >= 2 ? `<span class="arena-tag arena-tag-hot arena-streak"><i class="fa-solid fa-fire"></i> ${c.streak}-day streak</span>` : ''}</div>
++ <div class="arena-me-left">${_aPortrait(f, 'sm')}<div><div class="arena-me-name"><b>${_aEsc(f.fighter_name)}</b> <span class="arena-lvl">TRASH LVL ${l.level}</span> <span class="arena-note">#${f.rank} · PWR ${f.power} · ${f.record.wins}W–${f.record.losses}L</span></div>
<span class="arena-xp-track"><span class="arena-xp-fill" style="width:${pct}%"></span></span>
-- <small class="arena-note">${c.xp_into_level}/${c.xp_for_next} to level ${c.level + 1} · <b>+${me.xp_today} XP today</b>${me.progress?.tier?.next ? ` · ${me.progress.tier.next.xp - me.progress.xp} to ${_aEsc(me.progress.tier.next.name)}` : ''} · ${me.progress ? `${me.progress.earned}/${me.progress.total} achievements` : ''} · ${me.coins_from_arena} OpenCoins from levels</small></div></div>
++ <small class="arena-note">${l.xp_into_level}/${l.xp_per_level} to level ${l.level + 1} · <b>+${l.recent_xp} XP</b> this week · ${f.mic.moments} judged lines (30d) · avg ${f.mic.avg_quality}/10</small></div></div>
<div class="arena-me-right">
-- ${me.on_clock.length ? me.on_clock.map(b => `<div class="arena-me-alert">${_aA(_aBeefLink(b), `<i class="fa-solid fa-stopwatch"></i> <b>${_aEsc((b.a.user.id === c.user?.id ? b.b : b.a).fighter_name)}</b> has words for you — answer on stream`)} ${_aClockTag(b)}</div>`).join('') : ''}
-- ${me.fighter ? `<div class="arena-me-row"><i class="fa-solid fa-hand-fist"></i> Fighter #${me.fighter.rank} · PWR ${me.fighter.power} · TL ${me.fighter.level.level} · ${me.fighter.record.wins}W–${me.fighter.record.losses}L ${me.fighter.live ? _aA(_aConsoleLink(me.fighter.user), '<i class="fa-solid fa-ear-listen"></i> your ears', 'arena-tag arena-tag-hot') : ''}</div>` : ''}
-- ${me.subjects.length ? `<div class="arena-me-row"><i class="fa-solid fa-comments"></i> You're in: ${me.subjects.map(s => _aA(`/arena/topic/${s.id}`, `${_aEsc(s.headline || s.text)} <b>${s.moments}</b>`, 'arena-thread')).join(' ')}</div>` : `<div class="arena-me-row arena-note"><i class="fa-solid fa-keyboard"></i> Type about ${me.hot_now.length ? me.hot_now.map(h => _aA(`/arena/topic/${h.id}`, _aEsc(h.headline), 'arena-thread')).join(' ') : 'any subject on the board'} in chat — every line that lands is XP.</div>`}
-- ${me.progress ? `<div class="arena-me-row arena-me-ach">${me.progress.achievements.filter(a => !a.earned_at).slice(0, 3).map(a => `<span class="arena-ach is-locked is-mini" title="${_aEsc(a.desc)}"><span class="arena-ach-icon">${a.icon}</span><span class="arena-ach-name">${_aEsc(a.name)}</span><span class="arena-ach-hint">${_aEsc(a.desc)} · +${a.xp} XP${a.coins ? ` · +${a.coins} coins` : ''}</span></span>`).join('')}</div>` : ''}
-- ${_aA(_aChatterLink(c.key), 'your page <i class="fa-solid fa-arrow-right"></i>', 'arena-subject-open')}
-- </div>
-- </div>`;
-- _aEvery(1000, () => _aTickClocks(el));
--}
--
--function _aPulse(p, boardData) {
-- const open = boardData?.open || [];
-- const hot = open.filter(t => t.hot).length;
-- const mentions = open.reduce((n, t) => n + (t.mentions?.total || 0), 0);
-- const talking = open.reduce((n, t) => n + (t.talking_now?.length || 0), 0);
-- const text = p && p.text ? p.text : (open.length ? `${open.length} subject${open.length > 1 ? 's' : ''} on the board and ${mentions} mention${mentions === 1 ? '' : 's'} so far. Nobody's said anything worth a headline yet.` : 'Dead quiet. Say something stupid on mic and watch the board fill up.');
-- return `<div class="arena-pulse-inner">
-- <div class="arena-pulse-icon"><i class="fa-solid fa-heart-pulse ${p && p.text ? 'fa-beat' : ''}"></i><span>PULSE</span></div>
-- <div class="arena-pulse-body">
-- <div class="arena-pulse-text">${_aEsc(text)}</div>
-- <div class="arena-pulse-meta">
-- <span class="arena-pulse-stat"><b>${open.length}</b> subjects</span>
-- <span class="arena-pulse-stat ${hot ? 'is-hot' : ''}"><b>${hot}</b> hot</span>
-- <span class="arena-pulse-stat"><b>${mentions}</b> mentions</span>
-- <span class="arena-pulse-stat"><b>${talking}</b> on mic about it</span>
-- <span class="arena-pulse-when">${p && p.at ? `read ${_aEsc(_aAgo(p.at))} from ${(p.sources || []).map(x => x === 'on_mic' ? 'live mics' : 'chat').join(' + ') || 'the site'}` : 'the AI reads chat + live mics every few minutes'}</span>
-- </div>
++ ${me.on_clock.length ? me.on_clock.map(b => `<div class="arena-me-alert">${_aA(_aBeefLink(b), `<i class="fa-solid fa-stopwatch"></i> <b>${_aEsc((b.a.user.id === f.user.id ? b.b : b.a).fighter_name)}</b> called you out — answer on your own cam`)} ${_aClockTag(b)}</div>`).join('') : `<div class="arena-me-row arena-note"><i class="fa-solid fa-circle-check"></i> Nobody has you on the clock.</div>`}
++ ${me.moments.length ? `<div class="arena-me-row"><i class="fa-solid fa-quote-left"></i> last judged: <q>${_aEsc(me.moments[0].text)}</q> ${_aQ(me.moments[0].quality)}</div>` : ''}
++ <div class="arena-me-row">${_aA(_aFighterLink(f.user), 'your fighter page <i class="fa-solid fa-arrow-right"></i>', 'arena-subject-open')} ${me.live ? _aA(_aConsoleLink(f.user), '<i class="fa-solid fa-ear-listen"></i> your ears', 'arena-tag arena-tag-hot') : ''}</div>
</div>
</div>`;
}
--function _aRenderLive(live) {
-- if (!live.length) return `<h2><span class="arena-live-dot"></span> On the mic now <small>nobody from the roster is live</small></h2><p class="arena-note">When a fighter goes live with transcription on, the Arena listens for name-drops and board topics.</p>`;
-- return `<h2><span class="arena-live-dot"></span> On the mic now <small>${live.length} live · the Arena is listening</small></h2>
-- <div class="arena-mic-grid">${live.map(f => `
-- <div class="arena-mic-card ${f.transcribed ? '' : 'is-quiet'}" style="--fc:${_aEsc(f.user.profile_color || '#8b5cf6')}">
-- ${_aA(_aChannelLink(f.user), f.thumbnail_url ? `<img class="arena-live-thumb" src="${_aEsc(f.thumbnail_url)}" alt="">` : _aPortrait(f, 'sm'), 'arena-mic-thumb')}
-- <div class="arena-mic-body">
++function _aRenderCams(live) {
++ if (!live.length) return `<h2><span class="arena-live-dot"></span> Live cams <small>nobody from the roster is live</small></h2><p class="arena-note">When a fighter goes live with transcription on, the ears pick them up here — name-drops open beefs, free shit talk hits the feed.</p>`;
++ return `<h2><span class="arena-live-dot"></span> Live cams <small>${live.length} on · the ears are listening</small></h2>
++ <div class="arena-cam-grid">${live.map(f => {
++ const lock = f.ears && f.ears.focus;
++ return `<div class="arena-cam ${f.transcribed ? '' : 'is-quiet'} ${lock ? 'is-locked' : ''}" style="--fc:${_aEsc(f.user.profile_color || '#8b5cf6')}">
++ ${_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>`)}
-- <span class="arena-mic-meta">#${f.rank} · PWR ${f.ratings.power} · ${_aLevelPill(f.level)} · <i class="fa-solid fa-eye"></i> ${_aNum(f.stream.viewer_count)}</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>
-- ${f.hot_mic ? `<q class="arena-mic-line">${_aEsc(f.hot_mic.text)}</q>` : `<span class="arena-mic-line arena-mic-line-empty">${f.transcribed ? 'listening…' : 'no transcript yet — mic stats need transcription on'}</span>`}
++ ${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>` : ''}
++ ${f.last_moment ? `<div class="arena-cam-last"><q>${_aEsc(f.last_moment.text)}</q><small>${_aQ(f.last_moment.quality)} ${_aAim(f.last_moment)} · ${_aEsc(_aAgo(f.last_moment.at))} ${_aPlay(f.last_moment.vod_id, f.last_moment.sec)}</small></div>` : (f.hot_mic ? `<q class="arena-mic-line">${_aEsc(f.hot_mic.text)}</q>` : `<span class="arena-mic-line arena-mic-line-empty">${f.transcribed ? 'listening…' : 'no transcript yet — the cam needs audio transcription on'}</span>`)}
<div class="arena-mic-tags">
-- ${f.active_topic ? _aA(_aTopicLink(f.active_topic), `<i class="fa-solid fa-comment-dots"></i> on: ${_aEsc(f.active_topic.text)}`, 'arena-tag') : '<span class="arena-tag arena-tag-dim">no subject yet</span>'}
-- ${f.open_beefs ? `<span class="arena-tag arena-tag-hot"><i class="fa-solid fa-fire"></i> ${f.open_beefs} beef${f.open_beefs > 1 ? 's' : ''} open</span>` : ''}
++ ${f.open_beefs ? `<span class="arena-tag arena-tag-hot"><i class="fa-solid fa-fire"></i> ${f.open_beefs} beef${f.open_beefs > 1 ? 's' : ''} open</span>` : '<span class="arena-tag arena-tag-dim">no beef open</span>'}
++ ${f.ears ? `<span class="arena-tag arena-tag-dim" title="words buffered for the judge"><i class="fa-solid fa-ear-listen"></i> ${f.ears.pending_words} words pending</span>` : ''}
${_aA(_aConsoleLink(f.user), '<i class="fa-solid fa-ear-listen"></i> ears', 'arena-tag')}
</div>
</div>
-- </div>`).join('')}</div>`;
++ </div>`; }).join('')}</div>`;
++}
++
++// ── The feed ─────────────────────────────────────────────────
++
++function _aMomentRow(m) {
++ return `<div class="arena-mic-moment ${m.kind === 'beef_hit' ? 'is-beef' : ''} ${m.quality >= 8 ? 'is-fire' : ''}" data-id="${m.id}">
++ ${_aA(_aFighterLink(m.user), _aPortrait(m, 'xs'))}
++ <div class="arena-mic-moment-body">
++ <div class="arena-mic-moment-head">${_aA(_aFighterLink(m.user), `<b>${_aEsc(m.fighter_name)}</b>`)}${m.live ? ' <span class="arena-live-pill">LIVE</span>' : ''} ${_aAim(m)} ${_aQ(m.quality)} <small>${_aEsc(_aAgo(m.at))}</small></div>
++ <q>${_aEsc(m.text)}</q>
++ ${m.announcer ? `<div class="arena-feed-announcer"><i class="fa-solid fa-bullhorn"></i> ${_aEsc(m.announcer)}</div>` : ''}
++ <small class="arena-mic-moment-foot">${m.about ? `${_aEsc(m.about)} · ` : ''}${m.kind === 'beef_hit' && m.beef_id ? `${_aA(_aBeefLink({ id: m.beef_id }), '<i class="fa-solid fa-fire-flame-curved"></i> beef', 'arena-tag arena-tag-hot')} ` : ''}${_aPlay(m.vod_id, m.sec)} ${_aSpeakBtn(m.text, '', m.user.username)}</small>
++ </div>
++ </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>
++ ${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>`}`;
}
// ── Beefs ────────────────────────────────────────────────────
@@@ -356,7 +314,6 @@ function _aTickClocks(root) {
function _aBeefTags(b) {
const tags = [];
if (b.rematch) tags.push('<span class="arena-tag arena-tag-hot"><i class="fa-solid fa-rotate-left"></i> REMATCH</span>');
-- if (b.bounty) tags.push('<span class="arena-tag arena-tag-gold"><i class="fa-solid fa-sack-dollar"></i> BOUNTY</span>');
if (b.upset) tags.push('<span class="arena-tag arena-tag-hot"><i class="fa-solid fa-bolt"></i> UPSET</span>');
if (b.streaks?.a >= 2) tags.push(`<span class="arena-tag">${_aEsc(b.a.fighter_name)} on a ${b.streaks.a}-beef streak</span>`);
if (b.streaks?.b >= 2) tags.push(`<span class="arena-tag">${_aEsc(b.b.fighter_name)} on a ${b.streaks.b}-beef streak</span>`);
@@@ -390,10 +347,9 @@ function _aBeefCard(b) {
function _aRenderBeefs(beefs) {
const open = beefs.open || [], done = beefs.resolved || [];
return `<h2><i class="fa-solid fa-fire-flame-curved"></i> Beefs <small>${open.length ? `${open.length} open` : 'none open — say a name on mic'}</small></h2>
-- ${open.length ? `<div class="arena-beef-grid">${open.map(_aBeefCard).join('')}</div>` : `<div class="arena-beef-empty"><i class="fa-solid fa-microphone-lines"></i><p>No beef right now. A streamer only has to say another fighter's name while talking shit — the ears do the rest.</p></div>`}
++ ${open.length ? `<div class="arena-beef-grid">${open.map(_aBeefCard).join('')}</div>` : `<div class="arena-beef-empty"><i class="fa-solid fa-microphone-lines"></i><p>No beef right now. A fighter only has to call another one out on mic — the ears do the rest.</p></div>`}
${done.length ? `<details class="arena-done"><summary>Settled beefs <small>${done.length}</small></summary><div class="arena-beef-grid">${done.slice(0, 6).map(_aBeefCard).join('')}</div></details>` : ''}`;
}
--
async function _aHype(beefId, side, btn) {
try {
const r = await api(`/arena/beefs/${beefId}/hype`, { method: 'POST', body: { side } });
@@@ -403,184 +359,13 @@ async function _aHype(beefId, side, btn) {
}
function _aBindHome(root) {
root.querySelectorAll('.arena-hype-btn').forEach(btn => btn.onclick = (e) => { e.preventDefault(); _aHype(btn.dataset.beef, btn.dataset.side, btn); });
-- root.querySelectorAll('.arena-topic-hype').forEach(btn => btn.onclick = async (e) => {
-- e.preventDefault();
-- try { const r = await api(`/arena/board/topics/${btn.dataset.topic}/hype`, { method: 'POST', body: { user_id: Number(btn.dataset.user) } }); _aToast(r.added ? `🔥 Hyped (${r.hypers} hyping)` : 'Already hyped', r.added ? 'success' : 'info'); btn.classList.add('is-hyped'); }
-- catch (err) { _aToast(err?.message || 'Failed', 'error'); }
-- });
_aBindSpeak(root);
}
--
--// ── Board ────────────────────────────────────────────────────
--
--function _aHeat(t, hotThreshold) {
-- const pct = Math.min(100, Math.round((t.heat / Math.max(hotThreshold * 2, 1)) * 100));
-- return `<span class="arena-heat ${t.hot ? 'is-hot' : ''}" title="heat: on-mic ×3 + chat + hype + fighters talking, last hour"><i class="fa-solid fa-fire"></i> ${t.hot ? 'HOT' : ''} ${t.heat}<span class="arena-heat-bar"><span style="width:${pct}%"></span></span></span>`;
--}
--function _aTopicTitle(t) {
-- if (t.kind === 'bounty') return `Bounty on <b>${_aEsc(t.target?.fighter_name || '?')}</b>`;
-- return _aEsc(t.text);
--}
--function _aMomentLine(m, { compact = false } = {}) {
-- const icon = m.kind === 'chat' ? 'fa-keyboard' : 'fa-microphone';
-- return `<div class="arena-moment ${m.kind === 'chat' ? 'is-chat' : 'is-mic'} ${m.quality != null ? 'is-judged' : ''}">
-- <i class="fa-solid ${icon}" title="${m.kind === 'chat' ? 'said in chat' : 'said on mic'}"></i>
-- <div class="arena-moment-body">
-- <q>${_aEsc(m.text)}</q>
-- <small><b>${_aEsc(m.username || 'anon')}</b>${m.quality != null ? ` · ${m.quality}/10` : ''}${compact ? '' : ` · ${_aEsc(_aAgo(m.at))}`} ${_aPlay(m.vod_id, m.sec)}</small>
-- </div>
-- </div>`;
--}
--function _aAvatarStack(list, max = 5) {
-- return `<span class="arena-stack">${list.slice(0, max).map(f => _aA(_aFighterLink(f.user), _aPortrait(f, 'xs'), '', `${f.fighter_name}${f.live ? ' · LIVE' : ''}`)).join('')}${list.length > max ? `<span class="arena-stack-more">+${list.length - max}</span>` : ''}</span>`;
--}
--function _aMentionChips(t) {
-- return `<span class="arena-chips">
-- <span class="arena-chip-stat" title="chat lines about it"><i class="fa-solid fa-keyboard"></i> ${t.mentions.chat}</span>
-- <span class="arena-chip-stat" title="on-mic moments"><i class="fa-solid fa-microphone"></i> ${t.mentions.mic}</span>
-- <span class="arena-chip-stat" title="chatters involved"><i class="fa-solid fa-users"></i> ${t.chatters}</span>
-- ${t.talking_now.length ? `<span class="arena-chip-stat is-live" title="fighters on it right now"><span class="arena-live-dot arena-live-dot-sm"></span> ${t.talking_now.length} on mic</span>` : ''}
-- </span>`;
--}
--function _aThreadChips(t, { max = 4, link = true } = {}) {
-- const th = (t.threads || []).slice(0, max);
-- if (!th.length) return '';
-- return `<div class="arena-threads">${th.map(x => link ? _aA(`${_aTopicLink(t)}#thread-${x.id}`, `<span class="arena-thread"><i class="fa-solid fa-angle-right"></i> ${_aEsc(x.name)}${x.moments ? ` <b>${x.moments}</b>` : ''}</span>`) : `<span class="arena-thread"><i class="fa-solid fa-angle-right"></i> ${_aEsc(x.name)}${x.moments ? ` <b>${x.moments}</b>` : ''}</span>`).join('')}${(t.threads || []).length > max ? `<span class="arena-thread is-more">+${t.threads.length - max}</span>` : ''}</div>`;
--}
--function _aSubjectCard(t, hot, { featured = false } = {}) {
-- const k = ARENA_KIND[t.kind] || ARENA_KIND.topic;
-- return `<div class="arena-subject ${featured ? 'is-featured' : ''} ${t.hot ? 'is-hot' : ''} arena-topic-${t.kind}" data-topic="${t.id}">
-- <div class="arena-subject-head">
-- ${_aHeat(t, hot)}
-- <span class="arena-topic-by">${t.kind === 'bounty' ? '<i class="fa-solid fa-sack-dollar"></i> bounty · ' : ''}${t.created_by === 'community' ? `<i class="fa-solid fa-satellite-dish"></i> ${_aEsc(t.source_note || 'the site')}` : `<i class="fa-solid fa-user"></i> ${_aEsc(t.creator_name || t.created_by)}`} · ${_aEsc(_aAgo(t.last_mention_at || t.created_at))}</span>
-- </div>
-- ${_aA(_aTopicLink(t), `<h3 class="arena-subject-headline">${_aEsc(t.headline || t.text)}</h3>`, 'arena-topic-link')}
-- <div class="arena-subject-sub"><b>${_aTopicTitle(t)}</b>${t.tagline ? ` — ${_aEsc(t.tagline)}` : ''}</div>
-- ${_aThreadChips(t)}
-- ${t.last_moment ? `<q class="arena-subject-last"><i class="fa-solid ${t.last_moment.kind === 'chat' ? 'fa-keyboard' : 'fa-microphone'}"></i> <b>${_aEsc(t.last_moment.username || 'anon')}</b>: ${_aEsc(t.last_moment.text)}</q>` : `<span class="arena-note">${_aEsc(t.hint || 'nobody has said anything yet')}</span>`}
-- <div class="arena-subject-foot">
-- ${_aMentionChips(t)}
-- ${t.fighters.length ? _aAvatarStack(t.fighters, 4) : ''}
-- ${_aA(_aTopicLink(t), 'open <i class="fa-solid fa-arrow-right"></i>', 'arena-subject-open')}
-- </div>
-- </div>`;
--}
--function _aRenderBoard(bd) {
-- const me = _aMe();
-- const open = bd.open || [], archive = bd.archive || [];
-- return `<div class="arena-board-head">
-- <h2><i class="fa-solid fa-comments"></i> The board <small>what the site is on about · ${open.length} subject${open.length === 1 ? '' : 's'} · say one on mic or type it in chat and it counts</small></h2>
-- ${me ? `<button class="btn btn-ghost btn-sm" id="arena-board-new"><i class="fa-solid fa-plus"></i> Start a subject</button>` : '<span class="arena-note">sign in to start one (or <code>!topic</code> in any chat)</span>'}
-- </div>
-- ${open.length ? `<div class="arena-subject-grid">${open.map((t, i) => _aSubjectCard(t, bd.hot_threshold || 12, { featured: i === 0 })).join('')}</div>` : '<p class="arena-note">Empty for now — the AI reads chat and live mics every few minutes; the moment people are on about something, it shows up here.</p>'}
-- ${archive.length ? `<details class="arena-done"><summary>Cooled off <small>${archive.length}</small></summary><div class="arena-topic-feed is-archive">${archive.map(t => `<div class="arena-topic-row is-archived">
-- <div class="arena-topic-row-main">${_aA(_aTopicLink(t), `<strong>${_aEsc(t.headline || t.text)}</strong>`, 'arena-topic-link')}<div class="arena-topic-row-sub">${_aTopicTitle(t)} · ${t.mentions.total} mentions · ${_aEsc(_aAgo(t.last_activity_at))}${t.resolved?.headline ? ` · ${_aEsc(t.resolved.headline)}` : ''}</div></div>
-- </div>`).join('')}</div></details>` : ''}`;
--}
--function _aTopicComposer() {
-- const box = document.createElement('div');
-- box.className = 'arena-lightbox';
-- box.innerHTML = `<button class="arena-lightbox-close" aria-label="Close">&times;</button>
-- <div class="arena-lightbox-inner arena-composer">
-- <h3><i class="fa-solid fa-comment-dots"></i> Start a subject</h3>
-- <p class="arena-note">What should the site be arguing about? Type the dumb version — the AI rewrites it into a headline, works out the words people use for it and pulls in everything already said about it. One per 24 h.</p>
-- <textarea id="arena-composer-text" maxlength="140" rows="3" placeholder="e.g. goosely's tent, people who say 'bud', the cat enema thing"></textarea>
-- <div class="arena-composer-actions"><button class="btn btn-primary" id="arena-composer-go">Put it up</button></div>
-- </div>`;
-- const close = () => box.remove();
-- box.addEventListener('click', (e) => { if (e.target === box || e.target.closest('.arena-lightbox-close')) close(); });
-- box.querySelector('#arena-composer-go').addEventListener('click', async () => {
-- const text = box.querySelector('#arena-composer-text').value.trim();
-- if (!text) return;
-- const btn = box.querySelector('#arena-composer-go'); btn.disabled = true; btn.innerHTML = '<i class="fa-solid fa-circle-notch fa-spin"></i> Cooking…';
-- try { const r = await api('/arena/board/topics', { method: 'POST', body: { text } }); close(); _aToast(r.folded ? `📌 Already a thing — folded into “${r.topic.headline || r.topic.text}” as a thread` : `📌 On the board: ${r.topic.headline || r.topic.text}`, 'success'); navigate(`/arena/topic/${r.topic.id}`); }
-- catch (err) { _aToast(err?.message || 'Failed', 'error'); btn.disabled = false; btn.textContent = 'Put it up'; }
-- });
-- document.body.appendChild(box);
-- box.querySelector('textarea').focus();
--}
--document.addEventListener('click', (e) => { if (e.target.closest('#arena-board-new')) { e.preventDefault(); _aTopicComposer(); } });
--
--// ── Ladders ──────────────────────────────────────────────────
--
--function _aLevelsList(rows) {
-- if (!rows.length) return '<p class="arena-note">Nobody has XP yet. Talk on a topic or start a beef.</p>';
-- return `<div class="arena-mini-list">${rows.map((r, i) => `<div class="arena-mini-row">
-- <span class="arena-rank ${i < 3 ? `arena-rank-${i + 1}` : ''}">${i + 1}</span>
-- ${_aA(_aFighterLink(r.user), `${_aPortrait(r, 'xs')}<span><strong>${_aEsc(r.fighter_name)}</strong><small>${r.xp} XP · ${r.beef_hits} beef hits · ${r.topic_moments} moments · ${r.topics_joined} subjects</small></span>`, 'arena-chip')}
-- <span class="arena-lvl arena-lvl-big">LVL ${r.level}</span>
-- </div>`).join('')}</div>`;
--}
--function _aChatterLink(key) { return `/arena/chatter/${encodeURIComponent(key)}`; }
--function _aYapRing(y, size = 44) {
-- const pct = y.xp_for_next ? Math.max(0.04, Math.min(1, y.xp_into_level / y.xp_for_next)) : 1;
-- const r = (size / 2) - 3, c = 2 * Math.PI * r;
-- const color = y.user?.profile_color || (y.kind === 'relay' ? '#60a5fa' : y.kind === 'anon' ? '#9ca3af' : '#f97316');
-- return `<span class="arena-yap-ring" style="--yc:${_aEsc(color)};width:${size}px;height:${size}px"><svg viewBox="0 0 ${size} ${size}" width="${size}" height="${size}"><circle cx="${size / 2}" cy="${size / 2}" r="${r}" class="arena-yap-ring-bg"></circle><circle cx="${size / 2}" cy="${size / 2}" r="${r}" class="arena-yap-ring-fg" stroke-dasharray="${c.toFixed(1)}" stroke-dashoffset="${(c * (1 - pct)).toFixed(1)}"></circle></svg><b>${y.level}</b></span>`;
--}
--function _aYapKind(y) { return y.kind === 'relay' ? `<span class="arena-tag arena-tag-dim" title="relayed from ${_aEsc(y.platform || 'another platform')}"><i class="fa-solid fa-satellite-dish"></i> ${_aEsc(y.platform || 'relay')}</span>` : y.kind === 'anon' ? '<span class="arena-tag arena-tag-dim" title="anonymous viewer"><i class="fa-solid fa-user-secret"></i> anon</span>' : ''; }
--function _aYapCard(y, i) {
-- const card = y.card || {};
-- return `<div class="arena-yap-card ${i === 0 ? 'is-top' : ''}" style="--yc:${_aEsc(y.user?.profile_color || '#f97316')}">
-- <div class="arena-yap-rank">${i + 1}</div>
-- ${_aYapRing(y, i === 0 ? 64 : 52)}
-- <div class="arena-yap-main">
-- <div class="arena-yap-name">${_aA(_aChatterLink(y.key), `<strong>${_aEsc(y.name)}</strong>`)} ${_aYapKind(y)} ${y.streak >= 2 ? `<span class="arena-tag arena-tag-hot arena-streak" title="days in a row"><i class="fa-solid fa-fire"></i> ${y.streak}</span>` : ''}</div>
-- <div class="arena-yap-title">${_aEsc(card.title || y.title)} <small>· ${card.title ? `${_aEsc(y.title)} · ` : ''}${y.xp} XP</small></div>
-- ${card.blurb ? `<p class="arena-yap-blurb">${_aEsc(card.blurb)}</p>` : (y.best_line ? `<q class="arena-yap-line">${_aEsc(y.best_line.text)}</q>` : '')}
-- <div class="arena-yap-stats"><span><b>${y.moments}</b> moments</span><span><b>${y.subjects}</b> subjects</span>${y.quoted ? `<span><b>${y.quoted}</b> quoted in lore</span>` : ''}${y.gained != null ? `<span><b>+${y.gained}</b> this week</span>` : ''}</div>
-- </div>
-- </div>`;
--}
--function _aYappersSection(bd) {
-- const rows = bd.yappers || [], week = bd.yappers_week || [];
-- return `<h2><i class="fa-solid fa-keyboard"></i> Yappers <small>${bd.yappers_total || rows.length} chatters with a yap level · accounts, anons and relayed chatters alike</small></h2>
-- ${rows.length ? `<div class="arena-yap-podium">${rows.slice(0, 3).map(_aYapCard).join('')}</div>
-- ${rows.length > 3 ? `<div class="arena-mini-list">${rows.slice(3).map((y, i) => `<div class="arena-mini-row arena-yap-row">
-- <span class="arena-rank">${i + 4}</span>
-- ${_aYapRing(y, 34)}
-- <span class="arena-chip"><span><strong>${_aA(_aChatterLink(y.key), _aEsc(y.name))} ${_aYapKind(y)}</strong><small>${_aEsc((y.card && y.card.title) || y.title)} · lvl ${y.level} · ${y.moments} moment${y.moments === 1 ? '' : 's'} on ${y.subjects} subject${y.subjects === 1 ? '' : 's'}${y.streak >= 2 ? ` · 🔥 ${y.streak}` : ''}</small></span></span>
-- <span class="arena-lvl">${y.xp} XP</span>
-- </div>`).join('')}</div>` : ''}
-- ${week.length ? `<div class="arena-yap-week"><span class="arena-note"><i class="fa-solid fa-bolt"></i> hottest this week:</span> ${week.slice(0, 5).map(y => _aA(_aChatterLink(y.key), `${_aEsc(y.name)} <b>+${y.gained}</b>`, 'arena-tag')).join(' ')}</div>` : ''}` : '<p class="arena-note">Type about a subject on the board in any chat — your lines become moments, you get XP, a yap level and a card. Anons and relayed chatters too; OpenVibe accounts also get OpenCoins on every level-up.</p>'}`;
--}
function _aFlash(el, text) {
const f = document.createElement('div'); f.className = 'arena-flash'; f.textContent = text; el.prepend(f); setTimeout(() => f.remove(), 2200);
}
--// ── Chatter (yapper) page ────────────────────────────────────
--async function _aRenderChatter(root, key) {
-- root.innerHTML = _aSpinner('Pulling the yap sheet…');
-- let y;
-- try { y = await api(`/arena/chatter/${encodeURIComponent(key)}`); } catch (err) { root.innerHTML = `<div class="arena-empty"><i class="fa-solid fa-user-secret"></i><p>${_aEsc(err?.message || 'No yap profile yet')}</p>${_aA('/arena', 'Back to the Arena', 'btn')}</div>`; return; }
-- const card = y.card || {};
-- const next = y.titles.find(t => t.level > y.level);
-- root.innerHTML = `
-- <div class="arena-back">${_aA('/arena', '<i class="fa-solid fa-arrow-left"></i> Arena')} ${y.user ? _aA(_aChannelLink(y.user), `<i class="fa-solid fa-user"></i> @${_aEsc(y.user.username)}`) : ''}</div>
-- <div class="arena-yap-page" style="--yc:${_aEsc(y.user?.profile_color || '#f97316')}">
-- <div class="arena-yap-hero">
-- ${_aYapRing(y, 96)}
-- <div class="arena-yap-hero-main">
-- <h1>${_aEsc(y.name)} ${_aYapKind(y)}</h1>
-- <div class="arena-yap-hero-title"><span class="arena-lvl arena-lvl-big">YAP LVL ${y.level} · ${_aEsc(y.title)}</span>${card.title ? `<span class="arena-yap-card-title">“${_aEsc(card.title)}”</span>` : ''}${card.archetype ? `<span class="arena-tag">${_aEsc(card.archetype)}</span>` : ''}</div>
-- <span class="arena-xp-track"><span class="arena-xp-fill" style="width:${y.xp_for_next ? Math.round((y.xp_into_level / y.xp_for_next) * 100) : 100}%"></span></span>
-- <small class="arena-note">${y.xp} XP · ${y.xp_into_level}/${y.xp_for_next} to level ${y.level + 1}${next ? ` · next title <b>${_aEsc(next.title)}</b> at level ${next.level}` : ''} · rank #${y.rank}${y.coins ? ' · OpenCoins on every level-up' : ' · sign in to bank OpenCoins for your levels'}</small>
-- <div class="arena-yap-stats"><span><b>${y.moments}</b> moments</span><span><b>${y.subjects}</b> subjects</span><span><b>${y.subjects_started}</b> started</span><span><b>${y.quoted}</b> quoted in lore</span><span><b>${y.hypes}</b> hypes</span><span><b>${y.streak}</b> day streak <small>(best ${y.best_streak})</small></span></div>
-- </div>
-- </div>
-- ${_aProgressPanel(y.progress)}
-- ${card.blurb ? `<section class="arena-yap-cardbox"><h3><i class="fa-solid fa-id-badge"></i> Yap card <small>AI-written from their chat history${y.card_at ? ` · ${_aEsc(_aAgo(y.card_at))}` : ''}</small></h3><p>${_aEsc(card.blurb)}</p>${card.catchphrase ? `<q class="arena-yap-line">${_aEsc(card.catchphrase)}</q>` : ''}${card.known_for?.length ? `<div class="arena-keywords"><span class="arena-note">known for:</span>${card.known_for.map(k => `<span>${_aEsc(k)}</span>`).join('')}</div>` : ''}</section>` : `<p class="arena-note">The AI writes a yap card at level ${3}+ from their chat history — keep yapping.</p>`}
-- ${y.chat_ai?.overview ? `<section class="arena-yap-cardbox"><h3><i class="fa-solid fa-brain"></i> What the chat AI has on them</h3><p>${_aEsc(y.chat_ai.overview)}</p></section>` : ''}
-- <div class="arena-topic-cols">
-- <section class="arena-moments"><h3><i class="fa-solid fa-stream"></i> Their moments <small>${y.recent_moments.length}</small></h3>${y.recent_moments.length ? `<div class="arena-moment-list">${y.recent_moments.map(m => `<div class="arena-moment is-chat"><i class="fa-solid fa-keyboard"></i><div class="arena-moment-body"><q>${_aEsc(m.text)}</q><small>on ${_aA(`/arena/topic/${m.topic_id}`, _aEsc(m.subject_headline || m.subject))} · ${_aEsc(_aAgo(m.at))}</small></div></div>`).join('')}</div>` : '<p class="arena-note">Nothing yet.</p>'}</section>
-- <aside class="arena-topic-aside">
-- <section><h3><i class="fa-solid fa-comments"></i> Subjects they pile on</h3>${y.top_subjects.length ? `<div class="arena-mini-list">${y.top_subjects.map((t, i) => `<div class="arena-mini-row"><span class="arena-rank">${i + 1}</span><span class="arena-chip"><span><strong>${_aA(`/arena/topic/${t.id}`, _aEsc(t.headline || t.text))}</strong><small>${_aEsc(t.status)}</small></span></span><span class="arena-lvl">${t.moments}</span></div>`).join('')}</div>` : '<p class="arena-note">None yet.</p>'}</section>
-- <section><h3><i class="fa-solid fa-ranking-star"></i> Titles</h3><div class="arena-mini-list">${y.titles.map(t => `<div class="arena-mini-row ${t.level <= y.level ? 'is-earned' : ''}"><span class="arena-rank">${t.level}</span><span class="arena-chip"><span><strong>${_aEsc(t.title)}</strong><small>${t.xp} XP</small></span></span><span>${t.level <= y.level ? '<i class="fa-solid fa-check" style="color:#4ade80"></i>' : ''}</span></div>`).join('')}</div></section>
-- <section><h3><i class="fa-solid fa-coins"></i> How XP works</h3><ul class="arena-rules-list"><li>A chat line that lands on a subject: +3 (+6 when it's HOT), +4 the first time you touch a subject.</li><li>Starting a subject: +15. Getting quoted in a subject's lore: +10. Hyping: +1.</li><li>Show up daily: +5 × streak (up to 7).</li><li>Level = 1 + √(XP ÷ 25). OpenVibe accounts bank <b>level × 10 OpenCoins</b> on every level-up; anons and relayed chatters keep the level and the title.</li></ul></section>
-- </aside>
-- </div>
-- </div>`;
--}
++// ── Ladder ───────────────────────────────────────────────────
function _aRenderList(fighters) {
const el = document.getElementById('arena-list');
@@@ -591,21 +376,21 @@ 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>' : ''} ${_aTierBadge(f.tier)} ${_aLevelPill(f.level?.level)}<i class="fa-solid fa-chevron-right arena-chevron"></i></strong>
-- <span class="arena-row-sub">${_aEsc(f.user.display_name)} · ${_aEsc(f.persona.class)} · ${_aEsc(f.persona.element)}${f.voice?.has_data ? ` · <i class="fa-solid fa-microphone" title="has transcript data"></i> Mic ${f.ratings.mic}` : ''}</span>
-- <em class="arena-row-taunt">“${_aEsc(f.persona.taunt)}”</em>
++ <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>`}
</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)}<div class="arena-mini-record">${(f.persona.custom_stats || []).length ? 'AI characteristics · ' : ''}${f.category ? _aEsc(f.category) + ' · ' : ''}last live ${_aEsc(f.last_live_at ? _aDate(f.last_live_at) : '—')}</div></div>
++ <div>${_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>
<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-keyboard"></i> ${_aEsc(f.persona.typing_style)}</small>` : ''}</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((f.persona.stat_quips || {})[k] || '')}</span></div>`).join('')}</div>`}
-- ${f.persona.signature_move ? `<div class="arena-mini-record"><b>Signature:</b> ${_aEsc(f.persona.signature_move.name)} — ${_aEsc(f.persona.signature_move.description)}</div>` : ''}
++ ${(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>
<div class="arena-row-expand-actions">
${_aA(_aFighterLink(f.user), '<i class="fa-solid fa-id-card"></i> Full profile', 'btn btn-primary')}
@@@ -625,13 +410,13 @@ function _aRenderList(fighters) {
function _aFeedLine(e, b) {
const side = e.side === 'a' ? b.a : b.b;
-- const sys = e.kind && e.kind !== 'hit';
++ const sys = e.kind && !['hit', 'open', 'respond'].includes(e.kind);
return `<div class="arena-feed-line ${sys ? 'is-system' : `is-${e.side}`}">
${sys ? '' : _aA(_aFighterLink(side.user), _aPortrait(side, 'xs'))}
<div class="arena-feed-body">
${e.announcer ? `<div class="arena-feed-announcer"><i class="fa-solid fa-bullhorn"></i> ${_aEsc(e.announcer)}</div>` : ''}
${e.text ? `<q>${_aEsc(e.text)}</q>` : ''}
-- <small>${sys ? _aEsc(e.kind) : `${_aEsc(side.fighter_name)}${e.quality != null ? ` · ${e.quality}/10` : ''}${e.about ? ` · ${_aEsc(e.about)}` : ''}${e.bonus ? ` · ${_aEsc(e.bonus)}` : ''}`} · ${_aEsc(_aAgo(e.at))} ${_aPlay(e.vod_id, e.sec)} ${e.text ? _aSpeakBtn(e.text, '', side.user.username) : ''}</small>
++ <small>${sys ? _aEsc(e.kind) : `${_aEsc(side.fighter_name)}${e.kind === 'open' ? ' · opened it' : e.kind === 'respond' ? ' · answered' : ''}${e.quality != null ? ` · ${e.quality}/10` : ''}${e.about ? ` · ${_aEsc(e.about)}` : ''}`} · ${_aEsc(_aAgo(e.at))} ${_aPlay(e.vod_id, e.sec)} ${e.text ? _aSpeakBtn(e.text, '', side.user.username) : ''}</small>
</div>
</div>`;
}
@@@ -642,12 +427,12 @@ async function _aRenderBeef(root, id) {
const winner = !open && b.winner_user_id ? (b.winner_user_id === b.a.user.id ? b.a : b.b) : null;
const draw = () => {
root.innerHTML = `
-- <div class="arena-back">${_aA('/arena', '<i class="fa-solid fa-arrow-left"></i> Arena')} ${_aA(_aFighterLink(b.a.user), _aEsc(b.a.fighter_name))} · ${_aA(_aFighterLink(b.b.user), _aEsc(b.b.fighter_name))}</div>
++ <div class="arena-back">${_aA('/arena', '<i class="fa-solid fa-arrow-left"></i> Battle Cam')} ${_aA(_aFighterLink(b.a.user), _aEsc(b.a.fighter_name))} · ${_aA(_aFighterLink(b.b.user), _aEsc(b.b.fighter_name))}</div>
<div class="arena-beef-page">
<div class="arena-beef-hero">
<div class="arena-beef-tags">${_aBeefTags(b)}${!open ? `<span class="arena-tag ${winner ? 'arena-tag-gold' : 'arena-tag-dim'}">${winner ? `${_aEsc(winner.fighter_name)} won${b.resolution === 'forfeit' ? ' by forfeit' : b.resolution === 'score' ? ' on points' : ''}` : 'draw'}</span>` : '<span class="arena-tag arena-tag-hot"><i class="fa-solid fa-fire"></i> OPEN</span>'}</div>
<h1>${_aEsc((open ? b.headline : b.result_headline || b.headline) || `${b.a.fighter_name} vs ${b.b.fighter_name}`)} ${_aSpeakBtn((open ? b.headline : b.result_headline || b.headline) || '')}</h1>
-- ${b.opener_line ? `<p class="arena-beef-opener">It started with: <q>${_aEsc(b.opener_line)}</q></p>` : ''}
++ ${b.opener_line ? `<p class="arena-beef-opener">It started on mic with: <q>${_aEsc(b.opener_line)}</q></p>` : ''}
</div>
<div class="arena-beef-tape">
${['a', 'b'].map(s => { const f = b[s]; return `<div class="arena-beef-tape-side arena-beef-tape-${s}" style="--fc:${_aEsc(f.user.profile_color || (s === 'a' ? '#8b5cf6' : '#e74c3c'))}">
@@@ -673,7 +458,7 @@ async function _aRenderBeef(root, id) {
${b.history?.fights ? `<section><h3><i class="fa-solid fa-receipt"></i> Receipts <small>${b.history.wins_1}–${b.history.wins_2} in ${b.history.fights} earlier beef${b.history.fights > 1 ? 's' : ''}</small></h3>
${b.history.receipts.map(r => { const f = r.side_user_id === b.a.user.id ? b.a : b.b; return `<div class="arena-receipt"><q>${_aEsc(r.text)}</q><small>${_aEsc(f.fighter_name)} · ${r.quality}/10 · ${_aA(_aBeefLink({ id: r.beef_id }), `beef #${r.beef_id}`)} ${_aPlay(r.vod_id, r.sec)}</small></div>`; }).join('') || '<p class="arena-note">No quotable receipts.</p>'}</section>` : '<section><h3><i class="fa-solid fa-receipt"></i> Receipts</h3><p class="arena-note">First time these two have beef.</p></section>'}
<section><h3><i class="fa-solid fa-gavel"></i> Rules</h3><ul class="arena-rules-list">
-- <li>Answer on your own stream within <b>${b.rules.response_live_min} min</b> if you're live, <b>${b.rules.response_offline_hours} h</b> if not — or forfeit.</li>
++ <li>Answer on your own cam within <b>${b.rules.response_live_min} min</b> if you're live, <b>${b.rules.response_offline_hours} h</b> if not — or forfeit.</li>
<li>Every judged answer resets the other side's clock. Beef hard-ends after ${b.rules.max_hours} h; higher total wins.</li>
<li>Total = hit quality (AI-judged 1–10) + crowd hype (max ${b.rules.crowd_max}). No voting — hype is the only thing chat adds.</li>
<li>Beat someone ranked 4+ spots above you → <b>upset</b>. Same two again → <b>rematch</b> (+ rivalry receipts).</li>
@@@ -689,72 +474,18 @@ async function _aRenderBeef(root, id) {
if (open) _aEvery(12000, async () => { try { const fresh = await api(`/arena/beefs/${id}`); if (JSON.stringify(fresh.feed) !== JSON.stringify(b.feed) || fresh.status !== b.status || fresh.share_a !== b.share_a) { Object.assign(b, fresh); draw(); } } catch { /* */ } });
}
--// ── Topic detail ─────────────────────────────────────────────
--
--async function _aRenderTopic(root, id) {
-- root.innerHTML = _aSpinner('Opening the subject…');
-- let t = await api(`/arena/board/topics/${id}`);
-- const me = _aMe();
-- let thread = (location.hash.match(/#thread-(\d+)/) || [])[1] ? Number((location.hash.match(/#thread-(\d+)/) || [])[1]) : 0;
-- const draw = () => {
-- const k = ARENA_KIND[t.kind] || ARENA_KIND.topic;
-- const moments = (t.moments || []).filter(m => !thread || m.thread_id === thread);
-- const cur = (t.threads || []).find(x => x.id === thread);
-- root.innerHTML = `
-- <div class="arena-back">${_aA('/arena', '<i class="fa-solid fa-arrow-left"></i> Arena')}</div>
-- <div class="arena-topic-page arena-topic-${t.kind} ${t.hot ? 'is-hot' : ''} ${t.status !== 'open' ? 'is-archived' : ''}">
-- <div class="arena-topic-head">
-- <span class="arena-kind"><i class="fa-solid ${k.icon}"></i> ${k.label}${t.status !== 'open' ? ` · ${_aEsc(t.status)}` : ''}</span>
-- ${t.status === 'open' ? _aHeat(t, 12) : ''}
-- <span class="arena-topic-by">${t.created_by === 'community' ? `<i class="fa-solid fa-satellite-dish"></i> ${_aEsc(t.source_note || 'the site')}` : `started by ${_aEsc(t.creator_name || t.created_by)}`} · ${_aEsc(_aAgo(t.created_at))}${t.expires_at && t.status === 'open' ? ` · <span class="arena-expires" data-until="${_aEsc(t.expires_at)}">${_aEsc(_aClock((Date.parse(t.expires_at) - Date.now()) / 1000))} left</span>` : ''}</span>
-- </div>
-- <h1 class="arena-topic-headline-big">${_aEsc(t.headline || t.text)} ${_aSpeakBtn(t.headline || t.text, '')}</h1>
-- <div class="arena-subject-sub"><b>${_aTopicTitle(t)}</b>${t.tagline ? ` — ${_aEsc(t.tagline)}` : ''}</div>
-- ${t.submitted_text && t.submitted_text.toLowerCase() !== t.text.toLowerCase() ? `<p class="arena-note">as typed by ${_aEsc(t.creator_name || 'someone')}: “${_aEsc(t.submitted_text)}”</p>` : ''}
-- ${t.resolved?.headline ? `<div class="arena-topic-result"><i class="fa-solid fa-flag-checkered"></i> ${_aEsc(t.resolved.headline)}</div>` : ''}
-- ${t.kind === 'bounty' && t.target ? `<div class="arena-bounty-target">${_aBriefChip(t.target, ' · the mark')}<span class="arena-note">Any fighter who lands judged shit talk on ${_aEsc(t.target.fighter_name)} while this is open gets <b>double XP</b>.</span></div>` : ''}
-- <div class="arena-strip">
-- ${_aMentionChips(t)}
-- ${t.fighters.length ? `<span class="arena-strip-people">${_aAvatarStack(t.fighters, 6)} <small>${t.fighters.length} fighter${t.fighters.length === 1 ? '' : 's'} heard on it</small></span>` : '<small class="arena-note">no fighter has said it on mic yet — the ears auto-detect it the moment one does</small>'}
-- </div>
-- ${t.lore ? `<section class="arena-story"><h3><i class="fa-solid fa-book-skull"></i> Story so far <small>${t.lore_updated_at ? _aEsc(_aAgo(t.lore_updated_at)) : ''}</small></h3><p class="arena-lore-p">${_aEsc(t.lore)}</p></section>` : ''}
-- <div class="arena-tabs">
-- <button class="arena-tab ${!thread ? 'is-on' : ''}" data-thread="0">All <b>${(t.moments || []).length}</b></button>
-- ${(t.threads || []).map(x => `<button class="arena-tab ${thread === x.id ? 'is-on' : ''}" data-thread="${x.id}" id="thread-${x.id}" title="${_aEsc(x.hint || '')}">${_aEsc(x.name)} <b>${x.moments}</b></button>`).join('')}
-- </div>
-- ${cur && cur.hint ? `<p class="arena-note arena-tab-hint"><i class="fa-solid fa-angle-right"></i> ${_aEsc(cur.hint)}</p>` : ''}
-- <div class="arena-topic-cols">
-- <section class="arena-moments">
-- <h3><i class="fa-solid fa-stream"></i> ${cur ? _aEsc(cur.name) : 'Moments'} <small>${moments.length} · chat and on-mic, newest first</small></h3>
-- ${moments.length ? `<div class="arena-moment-list">${moments.map(m => _aMomentLine(m)).join('')}</div>` : '<p class="arena-note">Nothing on this thread yet.</p>'}
-- </section>
-- <aside class="arena-topic-aside">
-- ${t.fighters.length ? `<section><h3><i class="fa-solid fa-microphone-lines"></i> Heard on mic</h3>${t.fighters.map(f => `<div class="arena-member ${f.active ? 'is-active' : ''}">${_aBriefChip(f, ` · ${f.moments} on-mic${f.score ? ` · ${f.score} pts` : ''}`)}${f.best ? `<q class="arena-receipt-mini">${_aEsc(f.best.text)}</q>` : ''}${me && f.user.id !== me.id && t.status === 'open' ? `<button class="btn btn-ghost btn-sm arena-topic-hype" data-topic="${t.id}" data-user="${f.user.id}"><i class="fa-solid fa-fire"></i> Hype</button>` : ''}</div>`).join('')}</section>` : ''}
-- <section><h3><i class="fa-solid fa-keyboard"></i> Loudest in chat</h3>${t.top_chatters?.length ? `<div class="arena-mini-list">${t.top_chatters.map((c, i) => `<div class="arena-mini-row"><span class="arena-rank">${i + 1}</span><span class="arena-chip"><span><strong>${c.chatter_key ? _aA(_aChatterLink(c.chatter_key), _aEsc(c.username)) : _aEsc(c.username)}</strong>${c.level ? `<small>yap ${c.level} · ${_aEsc(c.title)}</small>` : ''}</span></span><span class="arena-lvl">${c.n}</span></div>`).join('')}</div>` : '<p class="arena-note">Nobody yet. Type about it in any chat.</p>'}</section>
-- ${t.best_lines?.length ? `<section><h3><i class="fa-solid fa-quote-left"></i> Best on mic</h3>${t.best_lines.map(l => `<div class="arena-quote"><div><q>${_aEsc(l.text)}</q><small>${_aEsc(l.username || 'anon')} · ${l.quality}/10</small></div><div class="arena-quote-actions">${_aPlay(l.vod_id, l.sec)} ${_aSpeakBtn(l.text, '', l.username)}</div></div>`).join('')}</section>` : ''}
-- <section><h3><i class="fa-solid fa-circle-info"></i> How it works</h3><ul class="arena-rules-list"><li>Say it on mic or type it in chat — it lands here on its own. No joining.</li><li>Threads are the angles inside the subject; new ones appear as the argument moves.</li><li>Chat lines = yap XP · on-mic lines are judged for streamer XP.</li></ul></section>
-- </aside>
-- </div>
-- </div>`;
-- root.querySelectorAll('.arena-tab').forEach(b => b.onclick = () => { thread = Number(b.dataset.thread) || 0; draw(); });
-- _aBindHome(root);
-- };
-- draw();
-- _aEvery(15000, async () => { try { const fresh = await api(`/arena/board/topics/${id}`); if (JSON.stringify(fresh) !== JSON.stringify(t)) { t = fresh; draw(); } } catch { /* */ } });
--}
--
// ── Live console ("the ears") ────────────────────────────────
--function f0(c) { return c?.fighter?.user?.id; }
async function _aRenderConsole(root, username) {
root.innerHTML = _aSpinner('Putting the ears on…');
let c = await api(`/arena/console/${encodeURIComponent(username)}`);
const draw = () => {
-- const f = c.fighter, L = c.listener || {}, lvl = c.level || {};
++ const f = c.fighter, L = c.listener || {}, lvl = c.level || {}, m = c.mic || {};
const me = _aMe(), mine = me && me.id === f.user.id;
const xpPct = lvl.xp_per_level ? Math.round((lvl.xp_into_level / lvl.xp_per_level) * 100) : 0;
++ const J = L.last_mic_judgement;
root.innerHTML = `
-- <div class="arena-back">${_aA('/arena', '<i class="fa-solid fa-arrow-left"></i> Arena')} ${_aA(_aFighterLink(f.user), _aEsc(f.fighter_name))} ${c.live ? _aA(_aChannelLink(f.user), '<i class="fa-solid fa-tv"></i> watch', '') : ''}</div>
++ <div class="arena-back">${_aA('/arena', '<i class="fa-solid fa-arrow-left"></i> Battle Cam')} ${_aA(_aFighterLink(f.user), _aEsc(f.fighter_name))} ${c.live ? _aA(_aChannelLink(f.user), '<i class="fa-solid fa-tv"></i> watch', '') : ''}</div>
<div class="arena-console" style="--fc:${_aEsc(f.user.profile_color || '#8b5cf6')}">
<div class="arena-console-head">
${_aPortrait(f, 'sm')}
@@@ -762,13 +493,13 @@ async function _aRenderConsole(root, username) {
<h1>${_aEsc(f.fighter_name)} ${c.live ? '<span class="arena-live-pill">LIVE</span>' : '<span class="arena-tag arena-tag-dim">offline</span>'}</h1>
<div class="arena-console-status ${L.listening ? 'is-on' : ''}">
<i class="fa-solid ${L.listening ? 'fa-ear-listen' : 'fa-ear-deaf'}"></i>
-- ${!c.live ? 'Not live — the ears only work on a live, transcribed stream.' : !c.transcribed ? 'Live, but no transcript in the last 30 min. Turn on audio transcription in the dashboard and the Arena hears you.' : L.listening ? `Listening. ${L.pending_topic_words || 0} words buffered for the topic judge · ${L.mention_buffers || 0} name-drop buffer${L.mention_buffers === 1 ? '' : 's'} open${L.last_judge_at ? ` · last judged ${_aEsc(_aAgo(L.last_judge_at))}` : ''}` : 'Live and transcribed — the listener picks this stream up on its next 15 s tick.'}
++ ${!c.live ? 'Not live — the ears only work on a live, transcribed cam.' : !c.transcribed ? 'Live, but no transcript in the last 30 min. Turn on audio transcription in the dashboard and the Arena hears you.' : L.listening ? `Listening. ${L.pending_mic_words || 0} words buffered for the mic judge${L.focus ? ` · locked on ${_aEsc(L.focus.target)}` : ''}${L.last_judge_at ? ` · last judged ${_aEsc(_aAgo(L.last_judge_at))}` : ''}` : 'Live and transcribed — the listener picks this cam up on its next 15 s tick.'}
</div>
</div>
<div class="arena-console-level">
<span class="arena-lvl arena-lvl-big">TRASH LVL ${lvl.level || 1}</span>
<span class="arena-xp-track"><span class="arena-xp-fill" style="width:${xpPct}%"></span></span>
-- <small>${lvl.xp_into_level || 0}/${lvl.xp_per_level || 50} XP to level ${(lvl.level || 1) + 1} · ${lvl.recent_xp || 0} this week</small>
++ <small>${lvl.xp_into_level || 0}/${lvl.xp_per_level || 50} XP to level ${(lvl.level || 1) + 1} · ${lvl.recent_xp || 0} this week · ${m.moments || 0} judged lines (30d)</small>
</div>
</div>
<div class="arena-console-cols">
@@@ -776,26 +507,23 @@ async function _aRenderConsole(root, username) {
<h3><i class="fa-solid fa-microphone"></i> Hot mic <small>last lines the ears heard</small></h3>
${L.focus ? `<div class="arena-focus"><i class="fa-solid fa-crosshairs fa-beat"></i> <b>Locked on ${_aEsc(L.focus.target || 'a fighter')}</b> <small>${_aEsc(L.focus.how)} name-drop · ${L.focus.hits} hit${L.focus.hits === 1 ? '' : 's'} · ${L.focus.pending_words} words waiting for the judge · lock <span class="arena-clock" data-until="${new Date(Date.now() + L.focus.lock_seconds_left * 1000).toISOString()}" data-who="the lock"><b>${_aClock(L.focus.lock_seconds_left)}</b></span></small>${L.focus.context ? `<div class="arena-focus-ctx">so far: ${_aEsc(L.focus.context)}</div>` : ''}<p class="arena-note">Everything said now goes to the beef judge as a continuation — no need to say the name again. Two chunks about something else and the ears let go.</p></div>` : ''}
<div class="arena-mic-feed">${c.hot_mic?.length ? c.hot_mic.map(l => `<div class="arena-mic-line-row"><span class="arena-mic-time">${_aStamp(l.sec)}</span><span>${_aEsc(l.text)}</span>${_aPlay(l.vod_id, l.sec)}</div>`).join('') : '<p class="arena-note">Nothing heard yet.</p>'}</div>
-- ${L.last_beef_judgement ? `<div class="arena-judgement ${L.last_beef_judgement.aimed_at_target ? 'is-hit' : ''}"><b><i class="fa-solid fa-gavel"></i> Beef judge (${_aEsc(_aAgo(L.last_beef_judgement.at))}):</b> ${L.last_beef_judgement.aimed_at_target ? `HIT${L.last_beef_judgement.named === false ? ' (continuation, name not said)' : ''} · ${L.last_beef_judgement.quality}/10 · ${_aEsc(L.last_beef_judgement.about || '')}${L.last_beef_judgement.opened ? ' · <b>beef opened</b>' : ''}${L.last_beef_judgement.bounty ? ' · <b>bounty collected</b>' : ''}` : L.last_beef_judgement.about_target ? `still about them, not shit talk — ${_aEsc(L.last_beef_judgement.about || '')}` : `moved on — ${_aEsc(L.last_beef_judgement.about || 'not about them')}`}${L.last_beef_judgement.flagged ? ' · <span class="arena-tag arena-tag-dim">line not counted (threat/minor/dox)</span>' : ''}</div>` : ''}
-- ${L.last_topic_judgement ? `<div class="arena-judgement ${L.last_topic_judgement.applied ? 'is-hit' : ''}"><b><i class="fa-solid fa-gavel"></i> Subject judge (${_aEsc(_aAgo(L.last_topic_judgement.at))}) · ${_aEsc(L.last_topic_judgement.topic || '')}:</b> ${L.last_topic_judgement.applied ? `MOMENT · ${L.last_topic_judgement.quality}/10 · +${L.last_topic_judgement.xp} XP · ${_aEsc(L.last_topic_judgement.about || '')}` : `not about it — ${_aEsc(L.last_topic_judgement.about || 'say the subject')}`}</div>` : ''}
++ ${L.last_beef_judgement ? `<div class="arena-judgement ${L.last_beef_judgement.aimed_at_target ? 'is-hit' : ''}"><b><i class="fa-solid fa-gavel"></i> Beef judge (${_aEsc(_aAgo(L.last_beef_judgement.at))}):</b> ${L.last_beef_judgement.aimed_at_target ? `HIT${L.last_beef_judgement.named === false ? ' (continuation, name not said)' : ''} · ${L.last_beef_judgement.quality}/10 · ${_aEsc(L.last_beef_judgement.about || '')}${L.last_beef_judgement.opened ? ' · <b>beef opened</b>' : ''}` : L.last_beef_judgement.about_target ? `still about them, not shit talk — ${_aEsc(L.last_beef_judgement.about || '')}` : `moved on — ${_aEsc(L.last_beef_judgement.about || 'not about them')}`}${L.last_beef_judgement.flagged ? ' · <span class="arena-tag arena-tag-dim">line not counted (threat/minor/dox)</span>' : ''}</div>` : ''}
++ ${J ? `<div class="arena-judgement ${J.is_trash_talk ? 'is-hit' : ''}"><b><i class="fa-solid fa-gavel"></i> Mic judge (${_aEsc(_aAgo(J.at))}):</b> ${J.is_trash_talk ? `SHIT TALK · ${J.quality}/10${J.aimed_at ? ` · at ${_aEsc(J.aimed_at)}` : ''} · ${_aEsc(J.about || '')}${J.opened ? ' · <b>callout — beef opened</b>' : J.target_id ? ' · <b>callout — fed the beef</b>' : ''}` : `not shit talk — ${_aEsc(J.about || 'gameplay / small talk')}`}${J.flagged ? ' · <span class="arena-tag arena-tag-dim">line not counted (threat/minor/dox)</span>' : ''}</div>` : ''}
</section>
<aside class="arena-console-aside">
<section>
-- <h3><i class="fa-solid fa-comment-dots"></i> Active topic</h3>
-- ${c.active_topic ? `${_aA(_aTopicLink(c.active_topic), `<b>${_aEsc(c.active_topic.headline || c.active_topic.text)}</b>`)}
-- <p class="arena-lore-p is-clamp">${_aEsc(c.active_topic.lore || c.active_topic.tagline || c.active_topic.hint || '')}</p>
-- ${c.active_topic.keywords?.length ? `<div class="arena-keywords">${c.active_topic.keywords.slice(0, 6).map(k => `<span>${_aEsc(k)}</span>`).join('')}</div>` : ''}
-- <small class="arena-note">${(c.active_topic.fighters || []).find(f => f.user.id === f0(c))?.moments || 0} of your on-mic moments on it</small>` : `<p class="arena-note">${mine ? 'Nothing detected yet — say any board subject on mic and the ears file it here.' : 'Not heard on a board subject yet.'}</p>${mine ? _aA('/arena', 'See the board', 'btn btn-ghost btn-sm') : ''}`}
++ <h3><i class="fa-solid fa-satellite-dish"></i> Their last lines in the feed</h3>
++ ${c.recent_moments?.length ? `<div class="arena-mic-feed-list is-compact">${c.recent_moments.map(_aMomentRow).join('')}</div>` : `<p class="arena-note">${mine ? 'Nothing judged yet — talk your shit.' : 'Nothing judged yet.'}</p>`}
</section>
<section>
<h3><i class="fa-solid fa-fire-flame-curved"></i> Open beefs</h3>
${c.open_beefs?.length ? c.open_beefs.map(b => `<div class="arena-console-beef">${_aA(_aBeefLink(b), `<b>${_aEsc(b.headline || `${b.a.fighter_name} vs ${b.b.fighter_name}`)}</b>`)}${_aTug(b)}${_aClockTag(b)}</div>`).join('') : `<p class="arena-note">None. ${mine ? 'Say another fighter\'s name while talking shit and one opens.' : ''}</p>`}
</section>
-- ${c.bounty_on_me ? `<section><h3><i class="fa-solid fa-sack-dollar"></i> Bounty on ${_aEsc(f.fighter_name)}</h3>${_aA(_aTopicLink(c.bounty_on_me), _aEsc(c.bounty_on_me.headline || c.bounty_on_me.text))}<p class="arena-note">Everyone gets double XP for talking shit about ${mine ? 'you' : 'them'} until it expires. ${mine ? 'Answer on mic.' : ''}</p></section>` : ''}
-- <section><h3><i class="fa-solid fa-circle-info"></i> How it's judged</h3><ul class="arena-rules-list"><li>Every 15 s the ears read new transcript lines.</li><li>A fighter's name (however the transcriber spells it — split, glued, misheard) locks the ears on them; ≥20 words go to the beef judge, and everything after counts as a continuation until they move on.</li><li>Saying a board subject's keywords adds a moment on it and auto-joins you; ≥20 words about it go to the subject judge (≥30 s apart).</li><li>Offensive language is fine. Threats, minors, doxxing → line ignored.</li></ul></section>
++ <section><h3><i class="fa-solid fa-circle-info"></i> How it's judged</h3><ul class="arena-rules-list"><li>Every 15 s the ears read new transcript lines.</li><li>A fighter's name (however the mic hears it — split, glued, misheard) locks the ears on them; ≥20 words go to the beef judge, and everything after counts as a continuation until they move on.</li><li>Not locked? ≥20 words go to the mic judge: is it shit talk, how good, and at whom. If "whom" is a fighter, it's a callout and feeds a beef anyway.</li><li>Offensive language is fine. Threats, minors, doxxing → line ignored.</li></ul></section>
</aside>
</div>
</div>`;
++ _aBindSpeak(root);
};
draw();
_aEvery(1000, () => _aTickClocks(root));
@@@ -809,12 +537,12 @@ function _aVoiceCard(f) {
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, MIC is rated at the floor and 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)}">
<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.mic}</b><small>MIC</small></span></h3>
++ <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>` : ''}
@@@ -836,29 +564,13 @@ function _aVoiceCard(f) {
</div>`;
}
--function _aTierBadge(t, cls = '') { return t ? `<span class="arena-tier ${cls}" style="--tc:${_aEsc(t.color)}" title="${_aEsc(t.name)} tier${t.next ? ` · ${t.progress}% to ${_aEsc(t.next.name)}` : ''}"><i class="fa-solid fa-shield-halved"></i> ${_aEsc(t.name)}</span>` : ''; }
--function _aProgressPanel(pr, { compact = false } = {}) {
-- if (!pr) return '';
-- const t = pr.tier;
-- const earned = pr.achievements.filter(a => a.earned_at), locked = pr.achievements.filter(a => !a.earned_at);
-- return `<section class="arena-progress-panel">
-- <div class="arena-progress-head">
-- ${_aTierBadge(t, 'arena-tier-big')}
-- <div class="arena-progress-bar"><div class="arena-tier-track">${pr.tiers.map((x, i) => `<span class="arena-tier-step ${i <= t.index ? 'is-done' : ''} ${i === t.index ? 'is-now' : ''}" style="--tc:${_aEsc(x.color)}" title="${_aEsc(x.name)} · ${x.min} XP"></span>`).join('')}</div>
-- <small class="arena-note"><b>${pr.xp} XP</b> all-time · ${t.next ? `${t.next.xp - pr.xp} more to <b style="color:${_aEsc(t.next.color)}">${_aEsc(t.next.name)}</b>` : 'top tier'} · <b>+${pr.week_xp}</b> this week · ${pr.earned}/${pr.total} unlocked</small></div>
-- </div>
-- <div class="arena-ach-grid">${[...earned, ...locked].slice(0, compact ? 8 : 40).map(a => `<div class="arena-ach ${a.earned_at ? 'is-earned' : 'is-locked'}" title="${_aEsc(a.desc)} · +${a.xp} XP${a.coins ? ` · +${a.coins} OpenCoins` : ''}${a.earned_at ? ` · ${_aEsc(_aAgo(a.earned_at))}` : ''}"><span class="arena-ach-icon">${a.icon}</span><span class="arena-ach-name">${_aEsc(a.name)}</span>${a.earned_at ? '' : `<span class="arena-ach-hint">${_aEsc(a.desc)}</span>`}</div>`).join('')}</div>
-- ${pr.history.length ? `<details class="arena-done arena-history" ${compact ? '' : 'open'}><summary><i class="fa-solid fa-scroll"></i> History <small>${pr.history.length}</small></summary><div class="arena-history-list">${pr.history.slice(0, compact ? 6 : 30).map(e => `<div class="arena-history-row"><span class="arena-history-kind ${_aEsc(e.kind)}"><i class="fa-solid ${({ beef: 'fa-fire-flame-curved', beef_over: 'fa-flag-checkered', level: 'fa-arrow-up', tier: 'fa-shield-halved', achievement: 'fa-medal', subject: 'fa-comment-dots', quoted: 'fa-quote-left' })[e.kind] || 'fa-circle'}"></i></span><span class="arena-history-body">${e.url ? _aA(e.url, `<b>${_aEsc(e.title)}</b>`) : `<b>${_aEsc(e.title)}</b>`}${e.detail ? `<small>${_aEsc(e.detail)}</small>` : ''}</span><span class="arena-history-when">${_aEsc(_aAgo(e.created_at))}</span></div>`).join('')}</div></details>` : ''}
-- </section>`;
--}
--
function _aLevelCard(f) {
-- const l = f.level || {};
++ const l = f.level || {}, m = f.mic || {};
const pct = l.xp_per_level ? Math.round((l.xp_into_level / l.xp_per_level) * 100) : 0;
return `<div class="arena-level-card">
<div class="arena-level-head"><span class="arena-lvl arena-lvl-big">TRASH LVL ${l.level || 1}</span><span class="arena-note">${l.xp || 0} XP · ${l.recent_xp || 0} this week${f.ratings.talk_bonus ? ` · <b>+${f.ratings.talk_bonus} POWER</b> from the mouth` : ''}</span></div>
<span class="arena-xp-track"><span class="arena-xp-fill" style="width:${pct}%"></span></span>
-- <div class="arena-level-nums"><span><b>${l.beef_hits || 0}</b><small>beef hits</small></span><span><b>${l.topic_moments || 0}</b><small>moments</small></span><span><b>${l.topics_joined || 0}</b><small>subjects</small></span><span><b>${f.record.wins}–${f.record.losses}${f.record.draws ? `–${f.record.draws}` : ''}</b><small>beef record</small></span></div>
++ <div class="arena-level-nums"><span><b>${m.moments || 0}</b><small>judged lines (30d)</small></span><span><b>${m.avg_quality ?? 0}</b><small>avg score</small></span><span><b>${m.bangers || 0}</b><small>bangers (7+)</small></span><span><b>${l.beef_hits || 0}</b><small>beef hits</small></span><span><b>${m.answered || 0}/${m.targeted || 0}</b><small>answered when called out</small></span><span><b>${f.record.wins}–${f.record.losses}${f.record.draws ? `–${f.record.draws}` : ''}</b><small>beef record</small></span></div>
${l.best_line ? `<div class="arena-quote"><div><q>${_aEsc(l.best_line.text)}</q><small>best line on record · ${l.best_line.score}/10</small></div><div class="arena-quote-actions">${_aPlay(l.best_line.vod_id, l.best_line.sec)} ${_aSpeakBtn(l.best_line.text, '', f.user.username)}</div></div>` : ''}
</div>`;
}
@@@ -867,19 +579,18 @@ async function _aRenderFighter(root, username) {
root.innerHTML = _aSpinner('Pulling the fighter file…');
const f = await api(`/arena/fighters/${encodeURIComponent(username)}`);
if (f.not_on_roster) {
-- root.innerHTML = `<div class="arena-empty"><i class="fa-solid fa-user-slash"></i><p><strong>${_aEsc(f.user.display_name)}</strong> is not on the roster yet — ${_aEsc(f.reason)}.</p>${_aA('/arena', 'Back to the Arena', '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;
}
const p = f.persona, color = f.user.profile_color || '#8b5cf6';
-- const beefs = f.beefs || [], rivalries = f.rivalries || [];
++ const beefs = f.beefs || [], rivalries = f.rivalries || [], moments = f.moments || [];
root.innerHTML = `
-- <div class="arena-back">${_aA('/arena', '<i class="fa-solid fa-arrow-left"></i> Arena')} ${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-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>
-- ${f.active_topic ? `<div class="arena-mini-record"><i class="fa-solid fa-comment-dots"></i> Last heard on: ${_aA(_aTopicLink(f.active_topic), _aEsc(f.active_topic.text))}</div>` : ''}
</div>
<div class="arena-profile-main">
<div class="arena-profile-name">
@@@ -889,14 +600,21 @@ async function _aRenderFighter(root, username) {
</div>
<div class="arena-profile-power">
<div class="arena-power arena-power-lg"><b>${f.ratings.power}</b><small>POWER</small></div>
-- ${f.progress ? _aTierBadge(f.progress.tier, 'arena-tier-big') : ''}
${f.ratings.talk_bonus ? `<div class="arena-talk-bonus" title="Mouth bonus — recent Trash Level XP and beef wins, decays over a week"><i class="fa-solid fa-microphone-lines"></i> +${f.ratings.talk_bonus} mouth</div>` : ''}
<div class="arena-record arena-record-lg" title="beef record">${f.record.wins}W – ${f.record.losses}L</div>
</div>
-- ${_aProgressPanel(f.progress)}
${_aLevelCard(f)}
++ <div class="arena-profile-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>
++ <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-custom">
-- <div>${_aCustomRadar(f, color, 280)}<div class="arena-mini-record">${(p.custom_stats || []).length ? 'their characteristics — AI-read from their chat history + channel' : 'objective stats (the AI characteristics appear once the persona is generated)'}</div></div>
++ <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>
<section class="arena-sheet">
@@@ -908,24 +626,15 @@ async function _aRenderFighter(root, username) {
<div class="arena-sheet-row is-weak"><span class="arena-sheet-k">Weakness</span><span>${_aEsc(p.weakness)}</span></div>
<div class="arena-sheet-row"><span class="arena-sheet-k">Walk-out</span><span><i class="fa-solid fa-music"></i> ${_aEsc(p.entrance_music)}</span></div>
<div class="arena-sheet-row"><span class="arena-sheet-k">Catchphrase</span><span>“${_aEsc(p.catchphrase)}”</span></div>
-- ${p.typing_style ? `<div class="arena-sheet-row"><span class="arena-sheet-k">Types like</span><span>${_aEsc(p.typing_style)}</span></div>` : ''}
++ ${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 data.</p>' : ''}
++ ${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 typing voice · 🔊 reads it in their chat voice</small></h3>
++ <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 class="arena-profile-stats is-bars">
-- <div class="arena-bars-head"><b>The numbers</b> <small>percentile across the roster · these make POWER · tap one</small></div>
-- <div class="arena-numgrid">${ARENA_STATS.map(k => `<div class="arena-num is-clickable" data-stat="${k}" title="${_aEsc((p.stat_quips || {})[k] || ARENA_STAT_LABEL[k])}"><span class="arena-num-label">${_aEsc(ARENA_STAT_LABEL[k])}</span><span class="arena-num-track"><span class="arena-num-fill" style="width:${Math.max(0, Math.min(100, f.ratings[k] || 0))}%;background:${_aEsc(color)}"></span></span><span class="arena-num-val">${f.ratings[k] ?? '–'}</span></div>`).join('')}</div>
-- <div id="arena-stat-detail"></div>
-- </div>
-- <div class="arena-numbers arena-numbers-compact">
-- ${[['Hours (90d)', f.raw.hours], ['Peak', f.raw.peak_viewers], ['Avg', f.raw.avg_viewers], ['Msgs/hr', f.raw.messages_per_hour], ['Followers', f.raw.followers], ['Clips', f.raw.clips]]
-- .map(([l, v]) => `<div class="arena-number"><b>${_aEsc(_aNum(v))}</b><span>${_aEsc(l)}</span></div>`).join('')}
-- </div>
</div>
</div>
${rivalries.length ? `<section class="arena-challenge"><h2><i class="fa-solid fa-skull-crossbones"></i> Rivalries</h2><div class="arena-rivalries">${rivalries.map(r => `<div class="arena-rivalry ${r.open ? 'is-open' : ''}">${_aBriefChip(r.opponent, ` · ${r.wins}–${r.losses} in ${r.fights}${r.open ? ' · <b>beef open</b>' : ''}`)}${r.receipts.map(x => `<q class="arena-receipt-mini">${_aEsc(x.text)}</q>`).join('')}</div>`).join('')}</div></section>` : ''}
@@@ -934,7 +643,7 @@ async function _aRenderFighter(root, username) {
_aBindHome(root);
_aEvery(1000, () => _aTickClocks(root));
-- root.querySelectorAll('.arena-num.is-clickable, .arena-quip.is-clickable').forEach(el => el.addEventListener('click', async () => {
++ root.querySelectorAll('.arena-num.is-clickable').forEach(el => el.addEventListener('click', async () => {
const stat = el.dataset.stat;
const box = document.getElementById('arena-stat-detail');
if (box.dataset.stat === stat) { box.innerHTML = ''; box.dataset.stat = ''; return; }
@@@ -989,7 +698,7 @@ function _aLightbox(f) {
<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 the stream's own data, 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>
++ <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>`;
ddiff --git a/public/js/chat.js b/public/js/chat.js
iindex 22b62f9b..75723762 100644
---- a/public/js/chat.js
++++ b/public/js/chat.js
@@@ -2665,12 +2665,77 @@ function destroyBgBroadcastChat() {
}
}
++/* ── Chat auto-translation (non-English streamers) ─────────────
++ The server translates foreign chat → English for everyone, and English → the channel's
++ language when the streamer doesn't read English (JapaneseOldGuy's chat was the reason).
++ Translations arrive a beat after the message as { type:'chat_translation', id, from, to, text }
++ and are persisted in chat_messages.metadata.translation for history. */
++const _LANG_LABEL = { en: 'English', ja: 'Japanese', ko: 'Korean', zh: 'Chinese', ru: 'Russian', uk: 'Ukrainian', ar: 'Arabic', th: 'Thai', he: 'Hebrew', el: 'Greek', hi: 'Hindi', es: 'Spanish', pt: 'Portuguese', fr: 'French', de: 'German', it: 'Italian', tr: 'Turkish', vi: 'Vietnamese', id: 'Indonesian', pl: 'Polish', nl: 'Dutch' };
++let _chatChannelLang = 'en';
++function _msgTranslation(msg) {
++ if (!msg) return null;
++ if (msg.translation && msg.translation.text) return msg.translation;
++ if (msg.metadata) {
++ try {
++ const m = typeof msg.metadata === 'string' ? JSON.parse(msg.metadata) : msg.metadata;
++ if (m && m.translation && m.translation.text) return m.translation;
++ } catch { /* not JSON */ }
++ }
++ return null;
++}
++function _translationEl(tr) {
++ const d = document.createElement('div');
++ d.className = 'chat-translation';
++ d.title = `Auto-translated from ${_LANG_LABEL[tr.from] || tr.from || '?'} to ${_LANG_LABEL[tr.to] || tr.to || '?'}`;
++ d.innerHTML = `<i class="fa-solid fa-language"></i> <span class="chat-translation-text">${esc(tr.text)}</span>`;
++ return d;
++}
++function _applyChatTranslation(evt) {
++ if (!evt || evt.id == null || !evt.text) return;
++ document.querySelectorAll(`.chat-msg[data-msg-id="${CSS.escape(String(evt.id))}"]`).forEach((el) => {
++ if (el.querySelector('.chat-translation')) return;
++ el.appendChild(_translationEl(evt));
++ });
++}
++// The viewer's own language (browser setting, 2-letter), used by the on-demand translate button.
++function _viewerLang() { try { return String(navigator.language || 'en').slice(0, 2).toLowerCase() || 'en'; } catch { return 'en'; } }
++async function _translateForMe(el, text, btn) {
++ const to = _viewerLang();
++ const existing = el.querySelector(`.chat-translation[data-to="${CSS.escape(to)}"]`);
++ if (existing) { existing.hidden = !existing.hidden; return; }
++ if (btn) { btn.disabled = true; btn.innerHTML = '<i class="fa-solid fa-circle-notch fa-spin"></i>'; }
++ try {
++ const d = await api('/i18n/translate', { method: 'POST', body: { text, to } });
++ if (d && d.same) { toast(`Already in ${_LANG_LABEL[to] || to}`, 'info'); return; }
++ if (!d || !d.text) { toast('Translation unavailable right now', 'error'); return; }
++ const node = _translationEl({ from: d.from, to: d.to, text: d.text });
++ node.dataset.to = to;
++ el.appendChild(node);
++ } catch (err) { toast(err?.message || 'Translation failed', 'error'); }
++ finally { if (btn) { btn.disabled = false; btn.innerHTML = '<i class="fa-solid fa-language"></i>'; } }
++}
++// "Auto-translated ↔ Japanese" pill in every chat tools row while in a non-English channel.
++function _updateTranslateHint(lang) {
++ _chatChannelLang = lang || 'en';
++ document.querySelectorAll('.chat-tools-row').forEach((row) => {
++ let pill = row.querySelector('.chat-translate-hint');
++ if (_chatChannelLang === 'en') { if (pill) pill.remove(); return; }
++ if (!pill) { pill = document.createElement('span'); pill.className = 'chat-translate-hint'; row.appendChild(pill); }
++ const name = _LANG_LABEL[_chatChannelLang] || _chatChannelLang;
++ pill.title = `This streamer reads ${name}. Type in YOUR language — every message is auto-translated to ${name} for them, and their ${name} is translated to English for you.`;
++ pill.innerHTML = `<i class="fa-solid fa-language"></i> <span>Auto-translated ↔ ${esc(name)}</span>`;
++ });
++}
++
/* ── Message handling ─────────────────────────────────────────── */
function handleChatMessage(msg) {
switch (msg.type) {
case 'vibe-coding':
_handleVibeSocketMessage(msg);
break;
++ case 'chat_translation':
++ _applyChatTranslation(msg);
++ break;
case 'chat':
addChatMessage(msg);
// Self-mode TTS: speak every incoming chat message via browser synthesis
@@@ -2714,6 +2779,7 @@ function handleChatMessage(msg) {
name: String(msg.username || ''),
core: msg.core_username || null,
};
++ _updateTranslateHint(msg.channel_language);
// A surface rejoin (live↔offline swap on the same socket) re-issues this
// confirmation — skip the duplicate "Chatting as" notice, but still run
// the rest of the sync below (slow mode, gifs, emote scale, …).
@@@ -3249,6 +3315,11 @@ function addChatMessage(msg) {
const separator = (ttsOff || msg.message_type === 'soundboard' || msg.message_type === 'channel-sound') ? ' ' : ': ';
el.innerHTML = `${replyHtml}${timestamp}${streamBadge}${voiceBadge}${gameBadge}<span class="chat-avatar-wrap">${avatarHtml}</span>${badge}${_relayBadge}${_aiBadge}${hatHtml}${particleWrapOpen}<span class="chat-user${nameFXClass}" style="color:${esc(nameColor)}" data-username="${displayName}" data-core-username="${coreUsername}" data-user-id="${userId}" data-anon="${isAnon ? '1' : ''}" oncontextmenu="showChatContextMenu(event)" onclick="showChatContextMenu(event)">${_visibleName}</span>${particleWrapClose}${separator}${text}`;
++ // Auto-translation under the message (live 'chat_translation' event, or persisted
++ // metadata.translation on history). See server/i18n/translate.js for the direction rules.
++ const _tr = _msgTranslation(msg);
++ if (_tr) el.appendChild(_translationEl(_tr));
++
// Reply action button (hover)
if (msg.id) {
const replyBtn = document.createElement('button');
@@@ -3265,6 +3336,16 @@ function addChatMessage(msg) {
});
};
el.appendChild(replyBtn);
++ // "Translate to my language" — for every viewer, whatever the channel's language is
++ // (the automatic translations only cover English ↔ the streamer's language).
++ if (/\p{L}{2,}/u.test(rawText || '')) {
++ const trBtn = document.createElement('button');
++ trBtn.className = 'chat-translate-btn';
++ trBtn.title = `Translate to ${_LANG_LABEL[_viewerLang()] || 'my language'}`;
++ trBtn.innerHTML = '<i class="fa-solid fa-language"></i>';
++ trBtn.onclick = (e) => { e.stopPropagation(); _translateForMe(el, displayRaw, trBtn); };
++ el.appendChild(trBtn);
++ }
el.classList.add('chat-msg-hoverable');
}
@@@ -4303,7 +4384,6 @@ function showChatContextMenu(event) {
if (isRelay) { const m = String(username || '').match(/^\[([A-Za-z]+)\]\s+(.+)$/); const plat = String(sourcePlatform || (m && m[1]) || '').toLowerCase().replace(/^yt$/, 'youtube').replace(/^robotstreamer$/, 'rs'); if (m && plat) yapKey = `relay:${plat}:${m[2].trim().toLowerCase()}`; }
else if (isAnon) { const m = String(coreUsername || username || '').match(/^anon(\d+)$/i); if (m) yapKey = `anon:${m[1]}`; }
else if (userId) yapKey = `user:${userId}`;
-- if (yapKey) _injectYapBlock(menu, yapKey);
} catch { /* optional */ }
// Clicks inside the menu shouldn't dismiss it (needed for rename submenu toggle etc.)
@@@ -4377,20 +4457,6 @@ function positionContextMenu(menu, x, y) {
menu._resizeHandler = onResize;
}
--async function _injectYapBlock(menu, key) {
-- try {
-- const y = await api(`/arena/chatter/${encodeURIComponent(key)}`);
-- if (!y || !menu.isConnected) return;
-- const pct = y.xp_for_next ? Math.round((y.xp_into_level / y.xp_for_next) * 100) : 100;
-- const card = y.card || {};
-- const el = document.createElement('div');
-- el.className = 'ctx-yap';
-- el.innerHTML = `<a href="/arena/chatter/${encodeURIComponent(key)}" onclick="return handleLinkClick(event, '/arena/chatter/${encodeURIComponent(key)}')" class="ctx-yap-link"><span class="ctx-yap-lvl">YAP ${y.level}</span><span class="ctx-yap-title">${esc(card.title || y.title)}</span>${y.streak >= 2 ? `<span class="ctx-yap-streak">🔥 ${y.streak}</span>` : ''}</a><span class="ctx-yap-bar"><span style="width:${pct}%"></span></span><small>${y.xp} XP · ${y.moments} moments on ${y.subjects} subject${y.subjects === 1 ? '' : 's'}${card.catchphrase ? ` · “${esc(String(card.catchphrase).slice(0, 60))}”` : ''}</small>`;
-- const actions = menu.querySelector('.ctx-actions');
-- if (actions) actions.parentNode.insertBefore(el, actions); else menu.appendChild(el);
-- } catch { /* no profile yet */ }
--}
--
function dismissContextMenu() {
if (activeContextMenu) {
// Clean up observers
ddiff --git a/public/js/rs-promo.js b/public/js/rs-promo.js
nnew file mode 100644
iindex 00000000..a1703ba3
---- /dev/null
++++ b/public/js/rs-promo.js
@@@ -0,0 +1,535 @@
++/* ═══════════════════════════════════════════════════════════════
++ RS-PROMO — RobotStreamer switch bonus (Zelle / PayPal)
++ ───────────────────────────────────────────────────────────────
++ Every RobotStreamer user who moves to OpenVibe.Live gets a cash
++ bonus, paid personally by the site owner. This module mounts the
++ four promo surfaces and all of their effects:
++
++ 1. Ticker — animated marquee under the navbar on every page
++ 2. Takeover — one-time full-screen splash (money rain, aurora,
++ synthwave grid, count-up amount)
++ 3. Hero card — 3D-tilt bounty card in the home hero
++ 4. Claim modal — Zelle/PayPal toggle, steps, copyable message,
++ sign-in / DM-the-owner / Discord actions + confetti
++
++ Amount, owner username, Discord link and the on/off switch come
++ from GET /api/promo/robotstreamer (env RS_PROMO_*), so the promo
++ can be retuned or paused without a frontend deploy. Everything
++ here is static copy + server-provided strings (escaped) — no user
++ content is rendered.
++ ═══════════════════════════════════════════════════════════════ */
++(function () {
++ 'use strict';
++
++ const PROMO_VERSION = 'rs-exclusive-v3'; // bump to re-show the takeover to everyone
++ const SEEN_KEY = 'ov_rs_promo_seen';
++ const TICKER_KEY = 'ov_rs_ticker_hidden'; // sessionStorage — comes back next visit
++ const 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 STREAM_ALERT_KEY = 'ov_rs_stream_alert_min'; // sessionStorage — collapsed (never hidden) state
++ let modalEl = null, takeoverEl = null, tickerEl = null;
++ let rainStop = null;
++
++ const esc = (s) => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
++ const money = () => (cfg.amountMin && cfg.amountMin < cfg.amount ? `$${cfg.amountMin}–$${cfg.amount}` : `$${cfg.amount}`);
++ const moneyMax = () => `$${cfg.amount}`;
++ const referral = () => `$${cfg.referral}`;
++ const ghLink = (label) => cfg.github ? `<a href="${esc(cfg.github)}" target="_blank" rel="noopener noreferrer">${label}</a>` : label;
++ const store = {
++ get(k, ss) { try { return (ss ? sessionStorage : localStorage).getItem(k); } catch { return null; } },
++ set(k, v, ss) { try { (ss ? sessionStorage : localStorage).setItem(k, v); } catch { /* private mode */ } },
++ };
++ const isLoggedIn = () => !!(window.currentUser);
++ const say = (msg, type) => { if (typeof window.toast === 'function') window.toast(msg, type || 'info'); };
++
++ // Pages where a full-screen splash would be hostile (mid-broadcast, overlays, popouts).
++ function takeoverAllowedHere() {
++ const p = location.pathname;
++ if (/^\/(popout|obs|whip|embed|kiosk)/.test(p)) return false;
++ if (/^\/(broadcast|dashboard)/.test(p)) return false;
++ return true;
++ }
++ function promoAllowedHere() {
++ return !/^\/(popout|obs|whip|embed|kiosk)/.test(location.pathname);
++ }
++
++ /* ─────────────────────────────────────────────────────────────
++ Shared fragments
++ ───────────────────────────────────────────────────────────── */
++ 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-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>`;
++
++ const coinHtml = () => `
++ <div class="rs-coin-wrap" aria-hidden="true">
++ <div class="rs-coin">
++ <div class="rs-coin-face front">$</div>
++ <div class="rs-coin-face back"><i class="fa-solid fa-robot"></i></div>
++ <div class="rs-coin-edge"></div>
++ </div>
++ </div>`;
++
++ /* ─────────────────────────────────────────────────────────────
++ 1. TICKER
++ ───────────────────────────────────────────────────────────── */
++ 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-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');
++ tickerEl.className = 'rs-ticker';
++ tickerEl.setAttribute('role', 'region');
++ tickerEl.setAttribute('aria-label', `RobotStreamer switch bonus: ${money()} via Zelle or PayPal`);
++ tickerEl.innerHTML = `
++ <div class="rs-ticker-track">
++ <span class="rs-ticker-copy" style="display:contents">${copy}</span>
++ <span class="rs-ticker-copy" style="display:contents" aria-hidden="true">${copy}</span>
++ </div>
++ <button class="rs-ticker-close" type="button" title="Hide for this visit" aria-label="Hide promo bar"><i class="fa-solid fa-xmark"></i></button>`;
++ tickerEl.addEventListener('click', (e) => {
++ if (e.target.closest('.rs-ticker-close')) { hideTicker(); return; }
++ openClaim();
++ });
++ document.body.appendChild(tickerEl);
++ document.body.classList.add('rs-ticker-on');
++ }
++ function hideTicker() {
++ if (!tickerEl) return;
++ store.set(TICKER_KEY, '1', true);
++ tickerEl.style.transition = 'transform 0.35s ease, opacity 0.35s ease';
++ tickerEl.style.transform = 'translateY(-100%)';
++ tickerEl.style.opacity = '0';
++ document.body.classList.remove('rs-ticker-on');
++ setTimeout(() => { tickerEl?.remove(); tickerEl = null; }, 400);
++ }
++
++ /* ─────────────────────────────────────────────────────────────
++ 2. TAKEOVER (money rain canvas + count-up)
++ ───────────────────────────────────────────────────────────── */
++ function buildTakeover() {
++ const titleWords = ['Leave', 'RobotStreamer', 'for', 'good.', 'Get', 'paid.'];
++ const el = document.createElement('div');
++ el.className = 'rs-takeover';
++ el.setAttribute('role', 'dialog');
++ el.setAttribute('aria-modal', 'true');
++ el.setAttribute('aria-label', `RobotStreamer switch bonus: ${money()}`);
++ el.innerHTML = `
++ <div class="rs-tk-aurora" aria-hidden="true"><span></span><span></span><span></span><span></span></div>
++ <div class="rs-tk-grid" aria-hidden="true"></div>
++ <canvas class="rs-tk-canvas" aria-hidden="true"></canvas>
++ <button class="rs-tk-close" type="button" aria-label="Close"><i class="fa-solid fa-xmark"></i></button>
++ <div class="rs-tk-content">
++ <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-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">
++ <span class="rs-flow-node rs-from"><i class="fa-solid fa-robot"></i> RobotStreamer</span>
++ <span class="rs-flow-arrow"><span class="rs-walker">🤖</span></span>
++ <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>
++ </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>`;
++ el.addEventListener('click', (e) => {
++ const act = e.target.closest('[data-act]')?.dataset.act;
++ if (e.target.closest('.rs-tk-close') || act === 'later') { closeTakeover(); return; }
++ if (act === 'claim') { closeTakeover(); openClaim(); }
++ });
++ return el;
++ }
++ function showTakeover(force) {
++ if (takeoverEl) return;
++ if (!force && (store.get(SEEN_KEY) === PROMO_VERSION || !takeoverAllowedHere())) return;
++ takeoverEl = buildTakeover();
++ document.body.appendChild(takeoverEl);
++ document.body.style.overflow = 'hidden';
++ store.set(SEEN_KEY, PROMO_VERSION);
++ if (!REDUCED) {
++ rainStop = startMoneyRain(takeoverEl.querySelector('.rs-tk-canvas'));
++ countUp(takeoverEl.querySelector('[data-countup]'), cfg.amount, 1500, 500);
++ } else {
++ takeoverEl.querySelector('[data-countup]').textContent = money();
++ }
++ document.addEventListener('keydown', onTakeoverKey);
++ setTimeout(() => takeoverEl?.querySelector('[data-act="claim"]')?.focus({ preventScroll: true }), 900);
++ }
++ function onTakeoverKey(e) { if (e.key === 'Escape') closeTakeover(); }
++ function closeTakeover() {
++ if (!takeoverEl) return;
++ const el = takeoverEl; takeoverEl = null;
++ document.removeEventListener('keydown', onTakeoverKey);
++ document.body.style.overflow = '';
++ if (rainStop) { rainStop(); rainStop = null; }
++ el.classList.add('leaving');
++ setTimeout(() => el.remove(), REDUCED ? 0 : 480);
++ }
++
++ function countUp(el, target, duration, delay) {
++ if (!el) return;
++ const t0 = performance.now() + (delay || 0);
++ const ease = (t) => 1 - Math.pow(2, -10 * t); // easeOutExpo
++ function frame(now) {
++ const t = Math.min(1, Math.max(0, (now - t0) / duration));
++ const fmt = (n) => (cfg.amountMin < cfg.amount ? `$${cfg.amountMin}–$${n}` : `$${n}`);
++ el.textContent = fmt(Math.round(ease(t) * target));
++ if (t < 1) requestAnimationFrame(frame);
++ else { el.textContent = fmt(target); el.parentElement?.classList.add('pop'); }
++ }
++ requestAnimationFrame(frame);
++ }
++
++ // Emoji particle rain: bills, coins, robots, hearts. Cheap (fillText), DPR-aware, pauses when hidden.
++ function startMoneyRain(canvas) {
++ if (!canvas) return () => {};
++ const ctx = canvas.getContext('2d');
++ const GLYPHS = ['💸', '💵', '🪙', '🤖', '💜', '💰', '🤖', '💸'];
++ let w = 0, h = 0, dpr = 1, raf = 0, running = true, last = 0;
++ const parts = [];
++ function resize() {
++ dpr = Math.min(2, window.devicePixelRatio || 1);
++ w = canvas.clientWidth; h = canvas.clientHeight;
++ canvas.width = w * dpr; canvas.height = h * dpr;
++ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
++ }
++ function spawn(initial) {
++ return {
++ g: GLYPHS[(Math.random() * GLYPHS.length) | 0],
++ x: Math.random() * w,
++ y: initial ? Math.random() * h : -40,
++ s: 18 + Math.random() * 26,
++ vy: 40 + Math.random() * 90,
++ vx: (Math.random() - 0.5) * 30,
++ r: Math.random() * Math.PI * 2,
++ vr: (Math.random() - 0.5) * 2.2,
++ sw: Math.random() * Math.PI * 2,
++ a: 0.55 + Math.random() * 0.45,
++ };
++ }
++ resize();
++ const N = Math.min(90, Math.max(35, Math.round((w * h) / 16000)));
++ for (let i = 0; i < N; i++) parts.push(spawn(true));
++ function tick(now) {
++ if (!running) return;
++ const dt = Math.min(0.05, (now - (last || now)) / 1000); last = now;
++ ctx.clearRect(0, 0, w, h);
++ ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
++ for (let i = 0; i < parts.length; i++) {
++ const p = parts[i];
++ p.sw += dt * 1.6;
++ p.x += (p.vx + Math.sin(p.sw) * 22) * dt;
++ p.y += p.vy * dt;
++ p.r += p.vr * dt;
++ if (p.y > h + 50 || p.x < -60 || p.x > w + 60) parts[i] = spawn(false);
++ ctx.save();
++ ctx.globalAlpha = p.a;
++ ctx.translate(p.x, p.y); ctx.rotate(p.r);
++ ctx.font = `${p.s}px "Segoe UI Emoji","Apple Color Emoji","Noto Color Emoji",sans-serif`;
++ ctx.fillText(p.g, 0, 0);
++ ctx.restore();
++ }
++ raf = requestAnimationFrame(tick);
++ }
++ const onVis = () => { if (document.hidden) { running = false; cancelAnimationFrame(raf); } else if (!running) { running = true; last = 0; raf = requestAnimationFrame(tick); } };
++ window.addEventListener('resize', resize);
++ document.addEventListener('visibilitychange', onVis);
++ raf = requestAnimationFrame(tick);
++ return () => { running = false; cancelAnimationFrame(raf); window.removeEventListener('resize', resize); document.removeEventListener('visibilitychange', onVis); };
++ }
++
++ /* ─────────────────────────────────────────────────────────────
++ 3. HERO BOUNTY CARD (home page)
++ ───────────────────────────────────────────────────────────── */
++ function mountHeroCard() {
++ const mount = document.getElementById('rs-hero-mount');
++ if (!mount || mount.dataset.mounted) return;
++ mount.dataset.mounted = '1';
++ mount.innerHTML = `
++ <div class="rs-bills" aria-hidden="true"><span>💸</span><span>🪙</span><span>💵</span><span>💸</span><span>🤖</span></div>
++ <div class="rs-card-border">
++ <div class="rs-hero-card" id="rs-hero-card">
++ <div class="rs-card-glare" aria-hidden="true"></div>
++ ${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>
++ <div class="rs-card-chips">${chipsHtml()}</div>
++ </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>
++ <button class="rs-card-replay" type="button" onclick="rsPromoReplay()">replay the splash ✨</button>
++ </div>
++ </div>
++ </div>`;
++ if (!REDUCED && !COARSE) attachTilt(mount.querySelector('.rs-card-border'), mount.querySelector('#rs-hero-card'));
++ }
++ function attachTilt(zone, card) {
++ if (!zone || !card) return;
++ let raf = 0;
++ zone.addEventListener('pointermove', (e) => {
++ const r = card.getBoundingClientRect();
++ const px = (e.clientX - r.left) / r.width, py = (e.clientY - r.top) / r.height;
++ cancelAnimationFrame(raf);
++ raf = requestAnimationFrame(() => {
++ card.style.setProperty('--rs-ry', `${((px - 0.5) * 14).toFixed(2)}deg`);
++ card.style.setProperty('--rs-rx', `${((0.5 - py) * 10).toFixed(2)}deg`);
++ card.style.setProperty('--rs-mx', `${(px * 100).toFixed(1)}%`);
++ card.style.setProperty('--rs-my', `${(py * 100).toFixed(1)}%`);
++ card.style.setProperty('--rs-glare', '1');
++ });
++ });
++ zone.addEventListener('pointerleave', () => {
++ cancelAnimationFrame(raf);
++ card.style.transition = 'transform 0.5s cubic-bezier(.2,1.4,.3,1)';
++ card.style.setProperty('--rs-rx', '0deg'); card.style.setProperty('--rs-ry', '0deg'); card.style.setProperty('--rs-glare', '0');
++ setTimeout(() => { card.style.transition = ''; }, 500);
++ });
++ }
++
++ /* ─────────────────────────────────────────────────────────────
++ 3b. STREAM-PAGE ALERT — on every channel page, under the player.
++ Never hidden: it collapses to a slim bar (remembered per session).
++ ───────────────────────────────────────────────────────────── */
++ function mountStreamAlert() {
++ // One mount inside the live layout, one inside the offline layout — whichever is showing.
++ // The offline one is (re)created on demand in case the channel page rebuilt its offline area.
++ const offHead = document.getElementById('ch-offline-header');
++ if (offHead && !document.getElementById('rs-stream-alert-offline')) {
++ const m = document.createElement('div'); m.className = 'rs-stream-alert-mount'; m.id = 'rs-stream-alert-offline';
++ offHead.parentNode.insertBefore(m, offHead);
++ }
++ document.querySelectorAll('.rs-stream-alert-mount').forEach(mountStreamAlertInto);
++ }
++ // The SPA swaps pages without reloading — re-check the channel page each time it becomes active.
++ function watchChannelPage() {
++ const page = document.getElementById('page-channel');
++ if (!page || !('MutationObserver' in window)) return;
++ new MutationObserver(() => { if (page.classList.contains('active')) mountStreamAlert(); }).observe(page, { attributes: true, attributeFilter: ['class'] });
++ }
++ function mountStreamAlertInto(mount) {
++ if (!mount || mount.dataset.mounted) return;
++ mount.dataset.mounted = '1';
++ const minimized = store.get(STREAM_ALERT_KEY, true) === '1';
++ mount.innerHTML = `
++ <div class="rs-stream-alert${minimized ? ' minimized' : ''}" role="region" aria-label="RobotStreamer switch bonus">
++ <div class="rs-sa-stripe" aria-hidden="true"></div>
++ <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-chips">${chipsHtml()}</div>
++ </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>
++ <button class="rs-btn rs-btn-ghost" type="button" onclick="rsPromoReplay()"><i class="fa-solid fa-circle-info"></i> How it works</button>
++ </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>
++ <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>
++ </div>`;
++ }
++ function toggleStreamAlert() {
++ const els = document.querySelectorAll('.rs-stream-alert-mount .rs-stream-alert');
++ if (!els.length) return;
++ const min = !els[0].classList.contains('minimized');
++ els.forEach(el => el.classList.toggle('minimized', min));
++ store.set(STREAM_ALERT_KEY, min ? '1' : '0', true);
++ }
++
++ /* ─────────────────────────────────────────────────────────────
++ 4. CLAIM MODAL
++ ───────────────────────────────────────────────────────────── */
++ 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): ______ 🤖💸`;
++ }
++ function openClaim() {
++ if (modalEl) return;
++ const loggedIn = isLoggedIn();
++ const owner = esc(cfg.owner);
++ const discord = cfg.discord ? esc(cfg.discord) : '';
++ modalEl = document.createElement('div');
++ modalEl.className = 'rs-modal';
++ modalEl.setAttribute('role', 'dialog');
++ modalEl.setAttribute('aria-modal', 'true');
++ modalEl.setAttribute('aria-label', `Claim your ${money()} RobotStreamer switch bonus`);
++ modalEl.innerHTML = `
++ <div class="rs-modal-card">
++ <button class="rs-modal-close" type="button" aria-label="Close"><i class="fa-solid fa-xmark"></i></button>
++ <div class="rs-modal-head">
++ ${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>
++ </div>
++ </div>
++ <div class="rs-toggle" 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>
++ </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>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>
++ <div class="rs-modal-actions">
++ ${loggedIn
++ ? `<button class="rs-btn rs-btn-gold" type="button" data-act="dm"><i class="fa-solid fa-paper-plane"></i> DM @${owner} to claim</button>`
++ : `<a class="rs-btn rs-btn-gold" href="/api/auth/sso/login"><i class="fa-solid fa-network-wired"></i> Sign in &amp; claim</a>`}
++ <div class="rs-row">
++ ${discord ? `<a class="rs-btn rs-btn-ghost" href="${discord}" target="_blank" rel="noopener noreferrer"><i class="fa-brands fa-discord"></i> Claim on Discord</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>
++ <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>
++ </div>`;
++ modalEl.addEventListener('click', onModalClick);
++ document.addEventListener('keydown', onModalKey);
++ document.body.appendChild(modalEl);
++ document.body.style.overflow = 'hidden';
++ if (!REDUCED) confettiBurst();
++ setTimeout(() => modalEl?.querySelector('.rs-modal-actions .rs-btn')?.focus({ preventScroll: true }), 400);
++ }
++ function onModalKey(e) { if (e.key === 'Escape') closeClaim(); }
++ function onModalClick(e) {
++ if (e.target === modalEl || e.target.closest('.rs-modal-close')) { closeClaim(); return; }
++ const tb = e.target.closest('.rs-toggle button');
++ if (tb) { setMethod(tb.dataset.method); return; }
++ if (e.target.closest('.rs-msg-copy')) { copyMessage(e.target.closest('.rs-msg-copy')); return; }
++ const act = e.target.closest('[data-act]')?.dataset.act;
++ if (act === 'dm') {
++ closeClaim();
++ if (typeof window.openMessengerDm === 'function') window.openMessengerDm(cfg.owner);
++ else say(`DM @${cfg.owner} to claim your ${money()}`, 'info');
++ } else if (act === 'golive') {
++ closeClaim();
++ }
++ }
++ function setMethod(method) {
++ 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-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>';
++ }
++ async function copyMessage(btn) {
++ const text = modalEl?.querySelector('.rs-msg-text')?.textContent || '';
++ try { await navigator.clipboard.writeText(text); }
++ catch {
++ const ta = document.createElement('textarea'); ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0';
++ document.body.appendChild(ta); ta.select(); try { document.execCommand('copy'); } catch { /* ignore */ } ta.remove();
++ }
++ btn.classList.add('copied', 'rs-wiggle'); btn.innerHTML = '<i class="fa-solid fa-check"></i>';
++ setTimeout(() => btn.classList.remove('rs-wiggle'), 450);
++ say('Claim message copied — paste it in your DM', 'success');
++ }
++ function closeClaim() {
++ if (!modalEl) return;
++ const el = modalEl; modalEl = null;
++ document.removeEventListener('keydown', onModalKey);
++ if (!takeoverEl) document.body.style.overflow = '';
++ el.classList.add('leaving');
++ setTimeout(() => el.remove(), REDUCED ? 0 : 260);
++ }
++
++ // Confetti burst from the bottom corners — gold, violet, RS blue, green.
++ function confettiBurst() {
++ const canvas = document.createElement('canvas');
++ canvas.className = 'rs-confetti';
++ (modalEl || document.body).appendChild(canvas);
++ const ctx = canvas.getContext('2d');
++ const dpr = Math.min(2, window.devicePixelRatio || 1);
++ const w = window.innerWidth, h = window.innerHeight;
++ canvas.width = w * dpr; canvas.height = h * dpr; ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
++ const COLORS = ['#fbbf24', '#fde68a', '#8b5cf6', '#a78bfa', '#7dd3fc', '#22c55e', '#f472b6', '#ffffff'];
++ const parts = [];
++ const emit = (x, y, dir) => {
++ for (let i = 0; i < 90; i++) {
++ const ang = dir + (Math.random() - 0.5) * 1.1, sp = 520 + Math.random() * 520;
++ parts.push({ x, y, vx: Math.cos(ang) * sp, vy: Math.sin(ang) * sp, g: 900, r: Math.random() * Math.PI, vr: (Math.random() - 0.5) * 12,
++ w: 6 + Math.random() * 8, hgt: 4 + Math.random() * 6, c: COLORS[(Math.random() * COLORS.length) | 0], life: 1.6 + Math.random() * 0.8, t: 0, round: Math.random() < 0.3 });
++ }
++ };
++ emit(0, h, -Math.PI / 3.2); emit(w, h, -Math.PI + Math.PI / 3.2);
++ let last = 0, raf = 0;
++ function tick(now) {
++ const dt = Math.min(0.04, (now - (last || now)) / 1000); last = now;
++ ctx.clearRect(0, 0, w, h);
++ let alive = 0;
++ for (const p of parts) {
++ p.t += dt; if (p.t > p.life) continue; alive++;
++ p.vy += p.g * dt; p.vx *= 0.985; p.x += p.vx * dt; p.y += p.vy * dt; p.r += p.vr * dt;
++ ctx.save(); ctx.globalAlpha = Math.max(0, 1 - p.t / p.life); ctx.translate(p.x, p.y); ctx.rotate(p.r); ctx.fillStyle = p.c;
++ if (p.round) { ctx.beginPath(); ctx.arc(0, 0, p.hgt / 1.5, 0, Math.PI * 2); ctx.fill(); }
++ else ctx.fillRect(-p.w / 2, -p.hgt / 2, p.w, p.hgt);
++ ctx.restore();
++ }
++ if (alive) raf = requestAnimationFrame(tick); else canvas.remove();
++ }
++ raf = requestAnimationFrame(tick);
++ setTimeout(() => { cancelAnimationFrame(raf); canvas.remove(); }, 3200);
++ }
++
++ /* ─────────────────────────────────────────────────────────────
++ Boot
++ ───────────────────────────────────────────────────────────── */
++ async function loadConfig() {
++ try {
++ const r = await fetch('/api/promo/robotstreamer', { credentials: 'same-origin' });
++ if (!r.ok) return;
++ const j = await r.json();
++ if (typeof j.enabled === 'boolean') cfg.enabled = j.enabled;
++ 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.github !== undefined) cfg.github = j.github || '';
++ if (j.owner) cfg.owner = String(j.owner);
++ if (j.discord !== undefined) cfg.discord = j.discord || '';
++ } catch { /* offline / dev without the route — defaults stand */ }
++ }
++ async function boot() {
++ if (!promoAllowedHere()) return;
++ await loadConfig();
++ if (!cfg.enabled) return;
++ mountTicker();
++ mountHeroCard();
++ mountStreamAlert();
++ watchChannelPage();
++ // Let the page settle (fonts, hero paint) before the splash slams in.
++ setTimeout(() => showTakeover(false), 700);
++ }
++ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
++ else boot();
++
++ // Public hooks (hero card buttons, console, other modules)
++ window.rsPromoOpenClaim = openClaim;
++ window.rsPromoReplay = () => showTakeover(true);
++ window.rsPromoHideTicker = hideTicker;
++ window.rsPromoToggleStreamAlert = toggleStreamAlert;
++})();
ddiff --git a/server/ai/chat-ai-routes.js b/server/ai/chat-ai-routes.js
iindex f2a0202c..a3b4a985 100644
---- a/server/ai/chat-ai-routes.js
++++ b/server/ai/chat-ai-routes.js
@@@ -219,6 +219,41 @@ router.get('/timeline/:username', (req, res) => {
}
});
++/**
++ * Live captions feed for a channel: the newest speech rows of the current live stream, with
++ * the English rendering for non-English streamers. Poll with ?after=<last id>.
++ * { language:{code,name,flag}, available, live, stream_id, lines:[{id,t,text,text_en,lang}] }
++ * `available` is false when the AI timeline is off, whisper is missing, or a non-English
++ * channel has no multilingual model — the client hides the panel rather than showing an
++ * empty one.
++ */
++router.get('/live-captions/:username', (req, res) => {
++ try {
++ const user = db.getUserByUsername(String(req.params.username || '').trim());
++ if (!user) return res.status(404).json({ error: 'Channel not found' });
++ const i18n = require('../i18n/translate');
++ const transcribe = require('./transcribe');
++ const code = i18n.channelLanguage(user.id);
++ let timelineOn = false;
++ try { const v = db.getSetting('ai_timeline_enabled'); timelineOn = String(v) === 'true' || String(v) === '1'; } catch { /* */ }
++ let whisperOk = false, multi = false;
++ try { whisperOk = transcribe.available(); multi = !!transcribe.multilingualModel(); } catch { /* */ }
++ const available = timelineOn && whisperOk && (code === 'en' || multi);
++ const live = (db.getLiveStreamsByUserId(user.id) || [])[0] || null;
++ const after = Math.max(0, parseInt(req.query.after, 10) || 0);
++ const lines = (live && available) ? db.getTimelineSpeechSince(live.id, after, 40).map(l => ({
++ id: l.id, t: l.start_sec, text: l.text, text_en: l.text_en || null, lang: l.lang || 'en', at: l.created_at,
++ })) : [];
++ res.set('Cache-Control', 'no-store');
++ res.json({
++ language: { code, name: i18n.langName(code), flag: i18n.langFlag(code) },
++ available, live: !!live, stream_id: live ? live.id : null, lines,
++ });
++ } catch (err) {
++ res.status(500).json({ error: 'Failed to load live captions' });
++ }
++});
++
// Full audio transcript for a stream (AI Timeline transcript viewer), with a VOD id for links.
/**
* Batch VOD transcripts, by Media vod id: /api/chat-ai/vod-transcripts?ids=1,2,3
ddiff --git a/server/ai/timeline-job.js b/server/ai/timeline-job.js
iindex c4fe47ca..1da7290e 100644
---- a/server/ai/timeline-job.js
++++ b/server/ai/timeline-job.js
@@@ -54,11 +54,18 @@ async function _processSegment(stream, seg) {
// offsetSec shifts whisper's file-relative timestamps into absolute stream time,
// so a row's start_sec is directly usable as a ?t= deep link.
let tx = null;
++ // Decode in the language the channel lives in (explicit setting or detected from the
++ // streamer's bio). Only "counts" when a multilingual whisper model is installed; otherwise
++ // the English model runs as before and nothing is translated.
++ let language = 'en';
++ try { language = require('../i18n/translate').channelLanguage(stream.user_id); } catch { language = 'en'; }
++ const effLang = (language !== 'en' && transcribe.multilingualModel && transcribe.multilingualModel()) ? language : 'en';
try {
tx = await transcribe.transcribeWavDetailed(seg.path, {
offsetSec: seg.offsetSec,
timeoutMs: 180000,
live: true, // must keep up with the segment rate; see MODEL_LIVE
++ language: effLang,
});
} catch (e) {
console.warn(`[AI-Timeline] stream ${stream.id} seg ${seg.name}: transcribe error`, e.message);
@@@ -72,8 +79,20 @@ async function _processSegment(stream, seg) {
start_sec: Number(s.start) || seg.offsetSec,
end_sec: s.end == null ? null : Number(s.end),
text,
++ lang: effLang !== 'en' ? effLang : null,
});
}
++ // English rendering of non-English speech (one LLM call per segment, cached) — this is
++ // what the live-captions panel and English-speaking viewers read.
++ if (effLang !== 'en') {
++ const speech = events.filter(e => e.kind === 'speech');
++ if (speech.length) {
++ try {
++ const en = await require('../i18n/translate').translateLines(speech.map(e => e.text), { from: effLang, to: 'en' });
++ speech.forEach((e, i) => { if (en[i]) e.text_en = en[i]; });
++ } catch { /* captions stay untranslated for this segment */ }
++ }
++ }
}
// ── Non-speech sound events ───────────────────────────────────────────────
ddiff --git a/server/ai/transcribe.js b/server/ai/transcribe.js
iindex c1cca103..7081e0d9 100644
---- a/server/ai/transcribe.js
++++ b/server/ai/transcribe.js
@@@ -32,7 +32,19 @@ const MODEL = process.env.WHISPER_MODEL || path.join(HOME, 'whisper.cpp/models/g
// live. VOD/clip backfill has no such constraint and should use the most accurate model
// available. So allow the live path to pick a faster one; falls back to MODEL when unset.
const MODEL_LIVE = process.env.WHISPER_MODEL_LIVE || MODEL;
++// Multilingual ggml for non-English streamers. The default *.en models can only ever emit
++// English — which is how a Japanese streamer's audio became hours of nonsense in the
++// timeline. Install e.g. ggml-base.bin / ggml-small.bin (no ".en") and point
++// WHISPER_MODEL_MULTI at it; until then non-English channels fall back to the English model.
++const MODEL_MULTI = process.env.WHISPER_MODEL_MULTI || path.join(HOME, 'whisper.cpp/models/ggml-base.bin');
++function multilingualModel() { try { return fs.existsSync(MODEL_MULTI) ? MODEL_MULTI : null; } catch { return null; } }
++let _warnedNoMulti = false;
function _modelFor(opts) {
++ if (opts && opts.language && opts.language !== 'en') {
++ const mm = multilingualModel();
++ if (mm) return mm;
++ if (!_warnedNoMulti) { _warnedNoMulti = true; console.warn(`[Transcribe] language "${opts.language}" requested but no multilingual model at ${MODEL_MULTI} — using the English model (set WHISPER_MODEL_MULTI)`); }
++ }
const m = (opts && opts.live) ? MODEL_LIVE : MODEL;
try { return fs.existsSync(m) ? m : MODEL; } catch { return MODEL; }
}
@@@ -200,7 +212,7 @@ function _joinSegments(segments) {
*/
// Inner implementation. Wrapped by transcribeWavDetailed, which holds the concurrency
// slot for the whole run so only MAX_CONCURRENT decoders exist at any moment.
--function _transcribeWavInner(wavPath, { timeoutMs = 180000, offsetSec = 0, live = false } = {}) {
++function _transcribeWavInner(wavPath, { timeoutMs = 180000, offsetSec = 0, live = false, language = 'en' } = {}) {
return new Promise((resolve) => {
const bin = whisperBin();
// ok=false only for genuine FAILURES (missing binary, spawn/exec error, timeout,
@@@ -211,7 +223,9 @@ function _transcribeWavInner(wavPath, { timeoutMs = 180000, offsetSec = 0, live
}
const outBase = `${wavPath}.out`;
const jsonPath = `${outBase}.json`;
-- const args = ['-m', _modelFor({ live }), '-f', wavPath, '-oj', '-of', outBase, '-t', String(_threads(live)), '-l', 'en'];
++ // Decode in the channel's language when a multilingual model is installed; else English.
++ const lang = (language && language !== 'en' && multilingualModel()) ? String(language) : 'en';
++ const args = ['-m', _modelFor({ live, language: lang }), '-f', wavPath, '-oj', '-of', outBase, '-t', String(_threads(live)), '-l', lang];
if (BEAM > 1) args.push('-bs', String(BEAM));
// Decode only the regions Silero says contain a voice. Segment timestamps stay in
// absolute file time, so the {start,end} contract is unchanged for every caller.
@@@ -308,10 +322,10 @@ function describe() {
available: available(),
bin: bin || null,
model: MODEL, modelExists: fs.existsSync(MODEL),
-- modelLive: MODEL_LIVE, modelLiveExists: fs.existsSync(MODEL_LIVE),
++ modelLive: MODEL_LIVE, modelLiveExists: fs.existsSync(MODEL_LIVE), modelMulti: MODEL_MULTI, modelMultiExists: !!multilingualModel(),
vadModel: vadModel() || null,
threads: THREADS, beam: BEAM, maxConcurrent: MAX_CONCURRENT,
};
}
--module.exports = { available, describe, laneStatus, transcribeWav, transcribeWavDetailed, transcribeMedia, transcribeMediaDetailed, killActive, setLowPower };
++module.exports = { available, describe, laneStatus, multilingualModel, transcribeWav, transcribeWavDetailed, transcribeMedia, transcribeMediaDetailed, killActive, setLowPower };
ddiff --git a/server/arena/arena-chat.js b/server/arena/arena-chat.js
iindex c49867dd..c171c3c8 100644
---- a/server/arena/arena-chat.js
++++ b/server/arena/arena-chat.js
@@@ -1,25 +1,21 @@
/**
-- * OpenVibe.Live — Arena chat commands (viewers play along from any stream chat)
++ * OpenVibe.Live — Arena chat commands (viewers watch along from any stream chat)
*
-- * !topic <text> start a board subject from chat (signed in; one per person + per IP per 24 h; the AI rewrites it)
-- * !bounty <user> put a bounty on a fighter: double XP for anyone who talks shit about them (1/hour)
-- * !hype hype the streamer: their newest open beef, else their active board topic
++ * !hype hype the streamer in their newest open beef (one per person per side)
* !beef what beefs this streamer has open + clocks
-- * !board the pulse + hottest events on the board
* !arena [user] fighter card summary + link
*
-- * Called from ChatServer.handleBangCommand; returns true when the command was handled.
-- * Replies go to the sender as a system line; milestones are announced to the room.
++ * Nothing in chat starts, feeds or decides anything — the Arena is pure mic. Called from
++ * ChatServer.handleBangCommand; returns true when the command was handled. Replies go to the
++ * sender as a system line; milestones are announced to the room.
*/
'use strict';
const db = require('../db/database');
--const COMMANDS = ['!topic', '!bounty', '!hype', '!beef', '!board', '!arena'];
++const COMMANDS = ['!hype', '!beef', '!arena'];
const RATE_MS = 4000;
--const BOUNTY_RATE_MS = 60 * 60 * 1000;
const _last = new Map(); // voterKey → ms
--const _lastBounty = new Map();
function voterKey(client) {
if (client.user?.id) return `user:${client.user.id}`;
@@@ -45,86 +41,44 @@ function handle(chat, ws, client, cmd, parts) {
if (!COMMANDS.includes(cmd)) return false;
const reply = (message) => chat.sendTo(ws, { type: 'system', message });
const room = (message) => client.streamId && chat.broadcastToStream(client.streamId, { type: 'system', message });
-- let arena, board, beef;
-- try { arena = require('./arena-service'); board = require('./board'); beef = require('./beef'); } catch { reply('The Arena is closed right now.'); return true; }
++ let arena, mic, beef;
++ try { arena = require('./arena-service'); mic = require('./mic'); beef = require('./beef'); } catch { reply('The Arena is closed right now.'); return true; }
if (!arena.arenaEnabled()) { reply('The Arena is closed right now.'); return true; }
const key = voterKey(client);
if (limited(_last, key, RATE_MS)) { reply('Easy — one Arena command every few seconds.'); return true; }
const stream = client.streamId ? db.getStreamById(client.streamId) : null;
const streamer = stream ? db.getUserById(stream.user_id) : null;
-- const who = client.user?.display_name || client.user?.username || 'Chat';
(async () => {
try {
-- if (cmd === '!topic') {
-- if (!client.user?.id) return reply('Sign in to start a topic.');
-- const text = parts.slice(1).join(' ').trim();
-- if (!text) return reply('Usage: !topic <something the streamers should talk shit about>');
-- const onRoster = !!arena.loadRoster().byId[client.user.id];
-- const t = await board.submitTopic({ text, userId: client.user.id, ip: client.ip, creatorName: who, onRoster });
-- if (t.folded) { reply(`📌 That's already on the board — folded into “${t.text}” as a new thread → ${base()}/arena/topic/${t.id}`); return; }
-- board.buildLore(t.id, { force: true }).catch(() => {});
-- reply(`📌 On the board as “${t.text}”${t.headline ? ` — ${t.headline}` : ''} → ${base()}/arena/topic/${t.id}`);
-- room(`📌 ${who} put a subject on the Arena board: “${t.text}” — say it on mic or keep it going in chat. ${base()}/arena/topic/${t.id}`);
-- return;
-- }
-- if (cmd === '!bounty') {
-- if (!client.user?.id) return reply('Sign in to put up a bounty.');
-- const target = parts[1] ? db.getUserByUsername(String(parts[1]).replace(/^@/, '')) : null;
-- if (!target) return reply('Usage: !bounty <username> — double XP for anyone who talks shit about them on stream.');
-- const roster = arena.loadRoster();
-- if (!roster.byId[target.id]) return reply(`${target.display_name || target.username} isn't on the Arena roster.`);
-- if (target.id === client.user.id) return reply("You can't put a bounty on yourself. Nice try.");
-- if (board.openBountyOn(target.id)) return reply(`There's already a bounty on ${target.display_name || target.username}. Go collect it.`);
-- if (limited(_lastBounty, key, BOUNTY_RATE_MS)) return reply('One bounty per hour per person.');
-- board.assertCanSubmit(client.user.id, client.ip);
-- const name = board.fighterBrief(target.id, roster).fighter_name;
-- const t = board.createTopic({ text: `Bounty: ${name}`, hint: `Put up by ${who}. Say the name, collect the bag.`, createdBy: 'chat', creatorUserId: client.user.id, creatorName: who, creatorIp: client.ip, kind: 'bounty', targetUserId: target.id, headline: `WANTED: ${name} — chat wants smoke`, tagline: `Double XP for anyone who talks shit about ${name} on stream` });
-- reply(`💰 Bounty posted on ${name} → ${base()}/arena/topic/${t.id}`);
-- room(`💰 ${who} put a bounty on ${name}. Any streamer who talks shit about them on mic gets double XP for the next ${board.KIND_TTL_HOURS.bounty} hours.`);
-- return;
-- }
if (cmd === '!hype') {
if (!streamer) return reply("!hype works inside a streamer's chat.");
const open = beef.openBeefsFor(streamer.id);
-- if (open.length) {
-- const b = open[0];
-- const side = b.a_user_id === streamer.id ? 'a' : 'b';
-- const r = beef.hype(b.id, side, key);
-- const v = beef.get(b.id);
-- if (!r.added) return reply(`You already hyped this beef. ${beefLine(v)}`);
-- reply(`🔥 Hyped ${streamer.display_name || streamer.username} in their beef. ${beefLine(v)}`);
-- const n = side === 'a' ? v.a.crowd : v.b.crowd;
-- if (n === 1 || n % 5 === 0) room(`🔥 Chat is hyping ${streamer.display_name || streamer.username}'s beef with ${(side === 'a' ? v.b : v.a).fighter_name} — ${n} so far. Type !hype to add yours. ${base()}/arena/beef/${v.id}`);
-- return;
-- }
-- const t = board.activeTopicFor(streamer.id);
-- if (!t) return reply(`${streamer.display_name || streamer.username} has no beef open and hasn't been heard on a board subject yet — the ears auto-detect it when they say one. ${base()}/arena`);
-- const r = board.hypeTopic(t.id, streamer.id, key);
-- if (!r.added) return reply(`You already hyped them on “${t.text}”. ${r.hypers} hyping.`);
-- reply(`🔥 Hyped ${streamer.display_name || streamer.username} on “${t.text}” — ${r.hypers} hyping → ${base()}/arena/topic/${t.id}`);
-- if (r.hypers === 1 || r.hypers % 5 === 0) room(`🎤 ${streamer.display_name || streamer.username} is talking shit on “${t.text}” — ${r.hypers} hyping. Type !hype. ${base()}/arena/topic/${t.id}`);
++ if (!open.length) return reply(`${streamer.display_name || streamer.username} has no beef open. Another fighter only has to get called out on mic… ${base()}/arena`);
++ const b = open[0];
++ const side = b.a_user_id === streamer.id ? 'a' : 'b';
++ const r = beef.hype(b.id, side, key);
++ const v = beef.get(b.id);
++ if (!r.added) return reply(`You already hyped this beef. ${beefLine(v)}`);
++ reply(`🔥 Hyped ${streamer.display_name || streamer.username} in their beef. ${beefLine(v)}`);
++ const n = side === 'a' ? v.a.crowd : v.b.crowd;
++ if (n === 1 || n % 5 === 0) room(`🔥 Chat is hyping ${streamer.display_name || streamer.username}'s beef with ${(side === 'a' ? v.b : v.a).fighter_name} — ${n} so far. Type !hype to add yours. ${base()}/arena/beef/${v.id}`);
return;
}
if (cmd === '!beef') {
if (!streamer) return reply(`Open beefs → ${base()}/arena`);
const open = beef.openBeefsFor(streamer.id).map(b => beef.get(b.id));
if (!open.length) {
-- const lvl = board.levelView(streamer.id);
++ const lvl = mic.levelView(streamer.id);
return reply(`${streamer.display_name || streamer.username} has no beef open right now (Trash Level ${lvl.level}). Another streamer only has to say their name on mic… ${base()}/arena`);
}
return reply(open.map(b => `🥊 ${b.headline || `${b.a.fighter_name} vs ${b.b.fighter_name}`}: ${beefLine(b)}`).join(' · '));
}
-- if (cmd === '!board') {
-- const v = board.boardView();
-- const top = v.open.slice(0, 3).map(t => `${t.hot ? '🔥 ' : ''}${t.kind === 'bounty' ? `Bounty: ${t.target?.fighter_name}` : t.text}${t.tagline ? ` — ${t.tagline}` : ''} (${t.mentions.total} mentions)`).join(' · ');
-- return reply(`📰 ${v.pulse?.text || 'The board is quiet.'} ${top ? `Hottest: ${top}` : ''} → ${base()}/arena · !topic <text> to add one`);
-- }
if (cmd === '!arena') {
const target = parts[1] ? db.getUserByUsername(String(parts[1]).replace(/^@/, '')) : streamer;
if (!target) return reply('Usage: !arena <username>');
const card = await arena.getFighter(target.id, { generate: false });
-- if (!card || card.not_on_roster) return reply(`${target.display_name || target.username} isn't on the Arena roster yet.`);
++ if (!card || card.not_on_roster) return reply(`${target.display_name || target.username} isn't on the Arena roster yet — it takes mic time on a transcribed stream.`);
const r = card.ratings;
return reply(`🥊 ${card.persona.fighter_name} — ${card.persona.title} · #${card.rank} of ${card.roster_size} · PWR ${r.power}${r.talk_bonus ? ` (+${r.talk_bonus} mouth)` : ''} · Trash Level ${card.level.level} · beefs ${card.record.wins}W–${card.record.losses}L · “${card.persona.taunt}” → ${base()}/arena/${encodeURIComponent(target.username)}`);
}
ddiff --git a/server/arena/arena-job.js b/server/arena/arena-job.js
iindex 7dde1c11..e856a489 100644
---- a/server/arena/arena-job.js
++++ b/server/arena/arena-job.js
@@@ -51,26 +51,20 @@ function start() {
_timer = setInterval(() => tick().catch(e => console.warn('[Arena] job:', e.message)), INTERVAL_MS);
if (_timer.unref) _timer.unref();
setTimeout(() => tick().catch(() => {}), 90_000).unref?.();
-- // The ears: every 15 s the listener reads live transcripts for name-drops (beefs) and topic talk.
++ // The ears: every 15 s the listener reads live transcripts — name-drops → beef judge, free talk → mic judge.
try { require('./listener').start(); } catch (e) { console.warn('[Arena] listener not started:', e.message); }
-- // Clocks + the board: forfeit beefs whose clock ran out, settle bounties, scan new chat into
-- // topic moments, discover new subjects from what was said (AI, ≤ 1 call / 5 min), rewrite lore.
++ // Clocks: forfeit beefs whose clock ran out, hard-end the ones past 24 h.
_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; clocks + chat scan every 60 s; discovery ≤ every 5 min; lore on new moments)');
++ console.log('[Arena] job started (personas every 20 min; listener every 15 s; beef clocks every 60 s) — Battle Cam mode: pure mic');
}
async function housekeeping() {
if (!arena.arenaEnabled()) return;
-- const beef = require('./beef'), board = require('./board');
-- try { beef.tick(); } catch (e) { console.warn('[Arena] beef tick:', e.message); }
-- try { board.resolveExpired(); } catch (e) { console.warn('[Arena] resolve:', e.message); }
-- try { const r = board.scanChat(); if (r.moments) console.log(`[Arena] chat scan: ${r.moments} moment(s) from ${r.scanned} message(s)`); } catch (e) { console.warn('[Arena] chat scan:', e.message); }
-- try { await board.discoverTopics(); } catch (e) { console.warn('[Arena] discover:', e.message); } // every 5 min, only with new material
-- try { await board.loreSweep(3); } catch (e) { console.warn('[Arena] lore:', e.message); } // only topics with ≥ 3 new moments
-- try { await require('./chatters').cardSweep(); } catch (e) { console.warn('[Arena] yap cards:', e.message); } // ≤ 4 chatters per minute, level ≥ 3, once a day each
-- try { await require('./chatters').settleCoins(); } catch (e) { console.warn('[Arena] yap coins:', e.message); } // retry level-up coins the wallet hasn't confirmed
++ // Clocks only: forfeit beefs whose clock ran out, hard-end the ones past 24 h. There is no chat
++ // scan, no discovery and no lore — the Arena is pure mic (listener.js).
++ try { require('./beef').tick(); } catch (e) { console.warn('[Arena] beef tick:', e.message); }
}
function stop() {
ddiff --git a/server/arena/arena-service.js b/server/arena/arena-service.js
iindex 0f19ce40..a6304cdb 100644
---- a/server/arena/arena-service.js
++++ b/server/arena/arena-service.js
@@@ -1,18 +1,21 @@
/**
-- * OpenVibe.Live — Arena (roster, ratings, personas, portraits, quotes)
++ * OpenVibe.Live — Arena (roster, ratings, personas, portraits, quotes) — Battle Cam mode
*
-- * The Arena turns the analytics + AI data the site already collects about each streamer
-- * into a fighting-game roster. Everything competitive is driven by what streamers SAY on
-- * stream and what chat does — see listener.js (the ears), beef.js (streamer vs streamer)
-- * and board.js (topics, angles, Trash Levels). This module owns the roster:
++ * The Arena is PURE MIC. Viewer counts, chat volume, followers, clips and tips do not exist
++ * here; the only input is what a streamer says into the microphone (the continuous audio
++ * transcription in stream_timeline_events), judged by listener.js and stored by mic.js.
++ * This module owns the roster:
*
-- * stats → seven 40–99 ratings (HYPE, GRIND, CHAT, LOYALTY, CLUTCH, VIBE, MIC)
-- * computed as percentiles across the active roster, plus an overall POWER
-- * that also carries the Trash Talk bonus (recent XP + beef wins).
-- * persona → AI "character select" bio, cached 24 h.
++ * roster → every streamer with transcribed speech in the last ACTIVE_DAYS days.
++ * stats → seven 40–99 ratings, all from the mic ledger, as percentiles across the
++ * roster: HEAT (how good the shit talk is), AIM (callouts + beef hits per
++ * hour on mic), KILLS (beef wins), MOUTH (share of stream time talking),
++ * CLAPBACK (answering when called out), STAMINA (minutes of speech), PACE
++ * (words per minute) — plus an overall POWER carrying the mouth bonus
++ * (recent XP + beef wins).
++ * persona → AI "character select" bio written from their TRANSCRIPTS, cached 24 h.
* quotes → AI-picked "things they actually said" from the transcripts, VOD-linked.
-- * image → optional AI character portrait (never a likeness — persona + an
-- * identity-free scene description of the latest thumbnail).
++ * image → optional AI character portrait drawn from their own stream frames.
*
* Nothing here touches the AI unless AI is enabled and within budget (server/ai/llm.js).
*/
@@@ -33,17 +36,18 @@ const IMAGE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
const RATING_MIN = 40;
const MIN_QUOTE_LINES = 20;
const TALK_BONUS_MAX = 12;
--const STAT_KEYS = ['hype', 'grind', 'chat', 'loyalty', 'clutch', 'vibe', 'mic'];
++const MIC_WINDOW_DAYS = 30;
++const STAT_KEYS = ['heat', 'aim', 'kills', 'mouth', 'clapback', 'stamina', 'pace'];
const STAT_META = {
-- hype: { label: 'Hype', desc: 'peak concurrent viewers' },
-- grind: { label: 'Grind', desc: 'hours live' },
-- chat: { label: 'Chat', desc: 'chat messages per hour live' },
-- loyalty: { label: 'Loyalty', desc: 'followers + returning chatters' },
-- clutch: { label: 'Clutch', desc: 'clips + tips per hour' },
-- vibe: { label: 'Vibe', desc: 'average viewers' },
-- mic: { label: 'Mic', desc: 'how much (and how loud) they talk — from the transcripts' },
++ heat: { label: 'Heat', desc: 'how good the shit talk is — average judge score of their mic moments (30d)' },
++ aim: { label: 'Aim', desc: 'callouts + beef hits per hour on mic (30d)' },
++ kills: { label: 'Kills', desc: 'beefs won' },
++ mouth: { label: 'Mouth', desc: 'share of stream time spent talking' },
++ clapback: { label: 'Clapback', desc: 'answering when called out — beefs answered on the clock' },
++ stamina: { label: 'Stamina', desc: 'minutes of speech heard (90d)' },
++ pace: { label: 'Pace', desc: 'words per minute on mic' },
};
--const STAT_WEIGHTS = { hype: 0.22, vibe: 0.18, chat: 0.14, loyalty: 0.13, grind: 0.13, clutch: 0.1, mic: 0.1 };
++const STAT_WEIGHTS = { heat: 0.22, aim: 0.18, kills: 0.16, mouth: 0.14, clapback: 0.12, stamina: 0.10, pace: 0.08 };
const HYPE_PATTERNS = ["let's go", 'lets go', 'no way', 'oh my god', 'insane', 'clutch', 'holy', 'gg', 'unreal', 'what the', 'bro', 'chat,', 'chat ', 'yo ', 'welcome', 'lfg', 'poggers', 'pog'];
// ── Behavior line (NOT a vocabulary filter) ────────────────
@@@ -92,7 +96,7 @@ function ensureTables() {
try { db.run(`ALTER TABLE arena_profiles ADD COLUMN ${col}`); } catch { /* exists */ }
}
try { fs.mkdirSync(ARENA_DIR, { recursive: true }); } catch { /* */ }
-- try { require('./board').ensureTables(); require('./beef').ensureTables(); } catch { /* */ }
++ try { require('./mic').ensureTables(); require('./beef').ensureTables(); } catch { /* */ }
_tablesReady = true;
}
@@@ -115,10 +119,11 @@ function imageGenAvailable() {
// ── Raw stats ────────────────────────────────────────────────
++/** The roster is whoever has been HEARD: transcribed speech in the last ACTIVE_DAYS days. */
function activeStreamerIds() {
return db.all(`
-- SELECT DISTINCT s.user_id FROM streams s JOIN users u ON u.id = s.user_id
-- WHERE s.duration_seconds > 0 AND s.started_at >= datetime('now', ?) AND COALESCE(u.is_banned, 0) = 0
++ SELECT DISTINCT e.user_id FROM stream_timeline_events e JOIN users u ON u.id = e.user_id
++ WHERE e.kind = 'speech' AND e.user_id IS NOT NULL AND e.created_at >= datetime('now', ?) AND COALESCE(u.is_banned, 0) = 0
`, [`-${ACTIVE_DAYS} days`]).map(r => r.user_id);
}
@@@ -163,33 +168,27 @@ function voiceStatsFor(userId, win) {
};
}
++/** Everything the ratings are built from: the transcripts (voice) + the mic ledger (mic). No audience numbers. */
function rawStatsFor(userId) {
const win = `-${STATS_WINDOW_DAYS} days`;
-- const agg = db.get(`
-- SELECT COUNT(*) AS streams, COALESCE(SUM(s.duration_seconds), 0) / 3600.0 AS hours, COALESCE(MAX(s.peak_viewers), 0) AS peak_viewers,
-- COALESCE(AVG(sa.avg_viewers), 0) AS avg_viewers, COALESCE(SUM(sa.total_messages), 0) AS messages, COALESCE(SUM(sa.unique_chatters), 0) AS unique_chatters,
-- COALESCE(SUM(sa.total_watch_minutes), 0) AS watch_minutes, COALESCE(SUM(sa.clips_created), 0) AS clips, MAX(s.ended_at) AS last_live_at
-- FROM streams s LEFT JOIN stream_analytics sa ON sa.stream_id = s.id
-- WHERE s.user_id = ? AND s.duration_seconds > 0 AND s.started_at >= datetime('now', ?)
-- `, [userId, win]) || {};
-- const allTime = db.get(`SELECT COUNT(*) AS streams, COALESCE(SUM(duration_seconds), 0) / 3600.0 AS hours, COALESCE(MAX(peak_viewers), 0) AS peak_viewers FROM streams WHERE user_id = ? AND duration_seconds > 0`, [userId]) || {};
-- const followers = db.get('SELECT COUNT(*) AS n FROM follows WHERE streamer_id = ?', [userId])?.n || 0;
-- const tips = db.get(`SELECT COALESCE(SUM(amount), 0) AS n FROM transactions WHERE to_user_id = ? AND type = 'donation' AND created_at >= datetime('now', ?)`, [userId, win])?.n || 0;
++ const agg = db.get(`SELECT COUNT(*) AS streams, COALESCE(SUM(duration_seconds), 0) / 3600.0 AS hours, MAX(ended_at) AS last_live_at FROM streams WHERE user_id = ? AND duration_seconds > 0 AND started_at >= datetime('now', ?)`, [userId, win]) || {};
const category = db.get(`SELECT COALESCE(NULLIF(ai_category, ''), category) AS category, COUNT(*) AS n FROM streams WHERE user_id = ? AND duration_seconds > 0 AND COALESCE(NULLIF(ai_category, ''), category) IS NOT NULL AND COALESCE(NULLIF(ai_category, ''), category) != '' GROUP BY 1 ORDER BY n DESC LIMIT 1`, [userId])?.category || null;
-- const hours = Math.max(Number(agg.hours) || 0, 0.1);
++ let micStats = {};
++ try { micStats = require('./mic').micStats(userId, MIC_WINDOW_DAYS); } catch { micStats = {}; }
return {
-- window_days: STATS_WINDOW_DAYS, streams: agg.streams || 0, hours: Number((agg.hours || 0).toFixed(1)), peak_viewers: agg.peak_viewers || 0,
-- avg_viewers: Number((agg.avg_viewers || 0).toFixed(1)), messages: agg.messages || 0, messages_per_hour: Number((agg.messages / hours).toFixed(1)),
-- unique_chatters: agg.unique_chatters || 0, watch_hours: Number(((agg.watch_minutes || 0) / 60).toFixed(1)), clips: agg.clips || 0, tips,
-- clutch_per_hour: Number((((agg.clips || 0) * 3 + tips / 100) / hours).toFixed(2)), followers, loyalty_score: followers + (agg.unique_chatters || 0) / 4,
-- all_time_hours: Number((allTime.hours || 0).toFixed(1)), all_time_peak: allTime.peak_viewers || 0, all_time_streams: allTime.streams || 0,
-- last_live_at: agg.last_live_at || null, category, voice: voiceStatsFor(userId, win),
++ window_days: STATS_WINDOW_DAYS, mic_window_days: MIC_WINDOW_DAYS, streams: agg.streams || 0, hours: Number((agg.hours || 0).toFixed(1)),
++ last_live_at: agg.last_live_at || null, category, voice: voiceStatsFor(userId, win), mic: micStats,
};
}
const METRIC_FOR_STAT = {
-- hype: (r) => r.peak_viewers, grind: (r) => r.hours, chat: (r) => r.messages_per_hour, loyalty: (r) => r.loyalty_score,
-- clutch: (r) => r.clutch_per_hour, vibe: (r) => r.avg_viewers, mic: (r) => (r.voice ? r.voice.voice_score : 0),
++ heat: (r) => (r.mic ? r.mic.avg_quality : 0),
++ aim: (r) => (r.mic ? r.mic.hits_per_mic_hour : 0),
++ kills: (r) => (r.mic ? r.mic.wins : 0),
++ mouth: (r) => (r.voice ? r.voice.talk_ratio_pct : 0),
++ clapback: (r) => (r.mic ? r.mic.clapback_rate : 0),
++ stamina: (r) => (r.voice ? r.voice.speech_minutes : 0),
++ pace: (r) => (r.voice ? r.voice.wpm : 0),
};
/** Percentile ratings across a roster: rating = 40 + 59 × percentile; a roster of one is a flat 70. */
@@@ -215,8 +214,8 @@ function computeRatings(rosterRaw) {
/** Trash Talk bonus on POWER: recent XP (7 days) + beef wins, capped. */
function talkBonus(userId) {
try {
-- const board = require('./board'), beef = require('./beef');
-- return Math.min(TALK_BONUS_MAX, Math.round(board.recentXp(userId) / 25) + beef.recentWins(userId) * 3);
++ const mic = require('./mic'), beef = require('./beef');
++ return Math.min(TALK_BONUS_MAX, Math.round(mic.recentXp(userId) / 25) + beef.recentWins(userId) * 3);
} catch { return 0; }
}
@@@ -262,29 +261,20 @@ function quotesAreFresh(row) { return !!(row && row.quotes_json) && freshWithin(
function gatherContext(userId) {
const ctx = {};
++ // Pure mic: the voice comes from what they SAY. Their most recent lines, their spiciest judged
++ // lines, who they have called out, their beef record and what the camera saw are the facts.
try { ctx.overview = db.getStreamerOverview(userId)?.overview || null; } catch { /* */ }
try { ctx.memories = db.all('SELECT description FROM stream_memories WHERE user_id = ? ORDER BY captured_at DESC LIMIT 4', [userId]).map(m => m.description).filter(Boolean); } catch { ctx.memories = []; }
-- // Who they are AS A CHATTER — the chat AI's profile of them (overview + long-term memory +
-- // timeline of notable moments). This is the primary source for their voice and personality.
-- try {
-- const chatAi = db.getChatAiSummary('user', userId, 'rolling');
-- if (chatAi) {
-- const ov = parseJson(chatAi.overview, null);
-- ctx.chat_notes = ov ? [ov.alltime, ov.today].filter(Boolean).join(' ') : String(chatAi.overview || '');
-- const mem = parseJson(chatAi.memory_json, null);
-- ctx.chatter_memory = typeof mem === 'string' ? mem.slice(0, 900) : (mem ? JSON.stringify(mem).slice(0, 900) : (chatAi.memory_json ? String(chatAi.memory_json).slice(0, 900) : null));
-- const tl = parseJson(chatAi.timeline_json, []);
-- ctx.chatter_timeline = Array.isArray(tl) ? tl.slice(-10).map(e => (typeof e === 'string' ? e : `${e.label || e.title || ''}${e.detail ? `: ${e.detail}` : ''}`).slice(0, 160)).filter(Boolean) : [];
-- }
-- } catch { /* */ }
-- try { ctx.chatter_timeline = [...(ctx.chatter_timeline || []), ...db.all(`SELECT label, detail FROM chat_timeline_events WHERE scope = 'user' AND subject_id = ? ORDER BY id DESC LIMIT 8`, [userId]).map(r => `${r.label}${r.detail ? `: ${r.detail}` : ''}`.slice(0, 160))].slice(0, 14); } catch { /* */ }
-- try { ctx.vods = db.all(`SELECT v.title, va.ai_overview_short AS overview FROM vods v LEFT JOIN vod_ai_state va ON va.vod_id = v.id WHERE v.user_id = ? AND v.is_public = 1 ORDER BY v.created_at DESC LIMIT 5`, [userId]).map(v => ({ title: v.title, overview: v.overview || null })); }
-- catch { try { ctx.vods = db.all('SELECT title FROM vods WHERE user_id = ? ORDER BY created_at DESC LIMIT 5', [userId]).map(v => ({ title: v.title })); } catch { ctx.vods = []; } }
try { ctx.titles = db.all('SELECT DISTINCT title FROM streams WHERE user_id = ? AND duration_seconds > 0 ORDER BY started_at DESC LIMIT 8', [userId]).map(r => r.title).filter(Boolean); } catch { ctx.titles = []; }
-- try { ctx.said = db.all(`SELECT text FROM stream_timeline_events WHERE user_id = ? AND kind = 'speech' AND LENGTH(text) BETWEEN 30 AND 140 ORDER BY created_at DESC LIMIT 24`, [userId]).map(r => r.text).filter(t => !isBannedText(t)).slice(0, 12); } catch { ctx.said = []; }
-- // How they type when they are a chatter themselves (their own chat lines, newest first) — the taunts copy this voice.
-- try { ctx.typed = db.all(`SELECT c.message, s.user_id AS room_owner FROM chat_messages c LEFT JOIN streams s ON s.id = c.stream_id WHERE c.user_id = ? AND COALESCE(c.is_deleted, 0) = 0 AND c.message NOT LIKE '!%' AND LENGTH(c.message) BETWEEN 4 AND 240 ORDER BY c.id DESC LIMIT 80`, [userId]).filter(r => !isBannedText(r.message)).slice(0, 40).map(r => { const owner = r.room_owner && r.room_owner !== userId ? db.getUserById(r.room_owner)?.username : null; return owner ? `[in ${owner}'s chat] ${r.message}` : (r.room_owner ? `[own chat] ${r.message}` : `[global] ${r.message}`); }); } catch { ctx.typed = []; }
-- try { ctx.chat_rooms = db.all(`SELECT u.username, COUNT(*) AS n FROM chat_messages c JOIN streams s ON s.id = c.stream_id JOIN users u ON u.id = s.user_id WHERE c.user_id = ? AND s.user_id != ? GROUP BY u.username ORDER BY n DESC LIMIT 4`, [userId, userId]).map(r => `${r.username} (${r.n} msgs)`); } catch { ctx.chat_rooms = []; }
++ try { ctx.said = db.all(`SELECT text FROM stream_timeline_events WHERE user_id = ? AND kind = 'speech' AND LENGTH(text) BETWEEN 30 AND 160 ORDER BY created_at DESC LIMIT 60`, [userId]).map(r => r.text).filter(t => !isBannedText(t)).slice(0, 30); } catch { ctx.said = []; }
++ try {
++ const mic = require('./mic');
++ ctx.shit_talk = mic.bestLines(userId, 8).map(m => `${m.text}${m.aimed_at ? ` [at ${m.aimed_at}]` : ''} (${m.quality}/10)`);
++ ctx.called_out = db.all(`SELECT target_user_id, COUNT(*) AS n FROM arena_mic_moments WHERE user_id = ? AND target_user_id IS NOT NULL GROUP BY target_user_id ORDER BY n DESC LIMIT 5`, [userId]).map(r => `${mic.nameOf(r.target_user_id)} (${r.n}×)`);
++ ctx.called_out_by = db.all(`SELECT user_id, COUNT(*) AS n FROM arena_mic_moments WHERE target_user_id = ? GROUP BY user_id ORDER BY n DESC LIMIT 5`, [userId]).map(r => `${mic.nameOf(r.user_id)} (${r.n}×)`);
++ ctx.aimed_at = db.all(`SELECT aimed_at, COUNT(*) AS n FROM arena_mic_moments WHERE user_id = ? AND aimed_at IS NOT NULL AND target_user_id IS NULL GROUP BY aimed_at ORDER BY n DESC LIMIT 6`, [userId]).map(r => `${r.aimed_at} (${r.n}×)`);
++ } catch { ctx.shit_talk = []; ctx.called_out = []; ctx.called_out_by = []; ctx.aimed_at = []; }
++ try { const q = parseJson(profileRow(userId)?.quotes_json); ctx.quotes = q && Array.isArray(q.picks) ? q.picks.map(p => p.text).slice(0, 6) : []; ctx.mic_style = q?.mic_style || null; } catch { ctx.quotes = []; }
return ctx;
}
@@@ -302,17 +292,17 @@ const PERSONA_SCHEMA = {
special: { type: 'object', additionalProperties: false, required: ['name', 'description'], properties: { name: { type: 'string' }, description: { type: 'string' } } },
weakness: { type: 'string' }, taunt: { type: 'string', description: 'Their signature ragebait line: one sentence, written EXACTLY the way this person types/talks (their punctuation, caps, slang, emoji habits, typos), aimed at rivals or their chat, designed to make people reply' },
taunts: { type: 'array', minItems: 3, maxItems: 3, items: { type: 'string' }, description: 'three more ragebait/troll lines in their own voice: one at a specific rival from the roster, one at their own chat, one about a topic they will not shut up about' },
-- typing_style: { type: 'string', description: '≤ 12 words describing how they type/talk (e.g. "all lowercase, no punctuation, calls everyone bud")' },
-- custom_stats: { type: 'array', minItems: 6, maxItems: 6, items: { type: 'object', additionalProperties: false, required: ['name', 'value', 'quip'], properties: { name: { type: 'string', description: '≤ 14 chars, a stat that only makes sense for THIS person — named after their actual bits, habits, subjects, gear, rivals, chat culture (e.g. "Alt Accounts", "Tent Smell", "Baby Voice", "Reads Chat", "Cope")' }, value: { type: 'integer', minimum: 1, maximum: 99 }, quip: { type: 'string', description: '≤ 8 words, in their own typing voice' } } }, description: 'six CHARACTERISTICS unique to this streamer for their radar — read from their chat history (things_they_typed_in_chat, chat_ai_notes, chatter_memory, chatter_timeline) and their channel (ai_overview, what they said on stream, what the camera saw): the bits they run, what they will not shut up about, how they treat chat, their rivals, their gear obsession, their schedule. Names must be specific to THEM (never generic like "Hype" or "Skill"), funny, ragebait-ish; values honestly spread (at least one under 30, at least one over 85) and the quip explains the number in their voice.' },
++ typing_style: { type: 'string', description: '≤ 12 words describing how they TALK on mic (e.g. "mumbles, calls everyone bud, yells at chat every 30 seconds")' },
++ custom_stats: { type: 'array', minItems: 6, maxItems: 6, items: { type: 'object', additionalProperties: false, required: ['name', 'value', 'quip'], properties: { name: { type: 'string', description: '≤ 14 chars, a stat that only makes sense for THIS person — named after their actual bits, habits, subjects, gear, rivals, the way they talk (e.g. "Alt Accounts", "Tent Smell", "Baby Voice", "Reads Chat", "Cope")' }, value: { type: 'integer', minimum: 1, maximum: 99 }, quip: { type: 'string', description: '≤ 8 words, in their own speaking voice' } } }, description: 'six CHARACTERISTICS unique to this streamer for their radar — read from what they SAY ON MIC (things_they_said_on_stream, their_best_shit_talk, who_they_call_out, what_they_rant_at) and their channel (ai_overview, what the camera saw): the bits they run, what they will not shut up about, how they treat chat, their rivals, their gear obsession, their schedule. Names must be specific to THEM (never generic like "Hype" or "Skill"), funny, ragebait-ish; values honestly spread (at least one under 30, at least one over 85) and the quip explains the number in their voice.' },
spoken_as: { type: 'array', minItems: 2, maxItems: 6, items: { type: 'string' }, description: 'how OTHER people say or mishear this streamer\'s name out loud, as a speech-to-text engine would write it: nicknames, shortenings, phonetic misspellings, the handle split into words (e.g. "japanese old guy", "matticus", "goose lee", "dan") — lowercase' },
lore: { type: 'string', description: '2–3 sentence character-select bio' }, catchphrase: { type: 'string' }, entrance_music: { type: 'string' },
stat_quips: { type: 'object', additionalProperties: false, required: STAT_KEYS, properties: Object.fromEntries(STAT_KEYS.map(k => [k, { type: 'string', description: `≤ 8 words explaining their ${k} rating in character` }])) },
},
},
};
--const PERSONA_SYSTEM = `You write fighting-game "character select" bios for a live-streaming site's Arena, where streamers rank each other and talk shit about each other on stream. This is a banter arena: be savage, profane, petty and funny — roast their habits, their chat, their schedule, their takes, their gimmicks, how they talk (transcript lines are provided). Trolling energy is welcome; make it quotable.
--VOICE COMES FIRST, AND IT COMES FROM THEIR CHAT HISTORY. The PRIMARY sources are \`things_they_typed_in_chat\` (verbatim lines they typed, newest first, with the room), \`chat_ai_notes\`, \`chatter_memory\` and \`chatter_timeline\` (the chat AI's profile of them as a chatter: what they are known for, their bits, opinions, grudges, who they mess with). Their streaming profile (\`ai_overview\`, \`things_they_said_on_stream\`, numbers) is SECONDARY flavor to mix in. Copy their actual typing: casing (if they type lowercase, every line is lowercase), punctuation or the lack of it, slang, emoji, typos, pet names, sentence length. A taunt must read like a message THEY would actually send in chat — not a movie trailer, not a wrestling promo, no ALL-CAPS unless they type that way, no invented catchphrases, never repeat a word for effect, no "watch me"/"try to keep up" filler.
--TAUNTS ARE THE MOST IMPORTANT PART. They must be RAGEBAIT in that voice: provocative, trolly, specific, petty — the kind of line that makes a rival or a chat reply instantly. Reference their real recurring subjects, rivals from the roster, rooms they lurk in and their obsessions. stat_quips are ALSO in their voice (≤ 8 words each, how THEY would describe that stat). If there is no chat data at all, build the voice from the transcript lines; if neither exists, infer it from the overview and say so in typing_style.
++const PERSONA_SYSTEM = `You write fighting-game "character select" bios for a live-streaming site's Arena — a battle-cam where streamers talk shit about each other ON MIC and the mic is the only thing that counts. Be savage, profane, petty and funny — roast their habits, their chat, their schedule, their takes, their gimmicks, the way they talk. Trolling energy is welcome; make it quotable.
++VOICE COMES FIRST, AND IT COMES FROM WHAT THEY SAY. The PRIMARY sources are \`things_they_said_on_stream\` (verbatim speech-to-text, newest first — expect noise), \`their_best_shit_talk\` (their judged spiciest lines, with who they were aimed at), \`who_they_call_out\`, \`who_calls_them_out\`, \`what_they_rant_at\` and \`quotes\`. Their streaming profile (\`ai_overview\`, titles, what the camera saw) is SECONDARY flavor. Copy how they actually talk: their rhythm, pet phrases, how they address chat, how they curse, whether they mumble or yell, sentence length. A taunt must read like something THEY would actually say into the mic — not a movie trailer, not a wrestling promo, no invented catchphrases, never repeat a word for effect, no "watch me"/"try to keep up" filler.
++TAUNTS ARE THE MOST IMPORTANT PART. They must be RAGEBAIT in that voice: provocative, trolly, specific, petty — the kind of line that makes a rival pull up on stream or a chat reply instantly. Reference their real recurring subjects, rivals from the roster, the people they keep calling out and their obsessions. stat_quips are ALSO in their voice (≤ 8 words each, how THEY would describe that stat). If there is almost no transcript data, infer the voice from the overview and say so in typing_style.
This platform does not censor language — offensive and taboo words are allowed. The only hard line: no direct threats of violence, nothing sexual about minors, no doxxing. Everything is Arena lore, not real-life claims. Output only the JSON.`;
async function generatePersona(userId, { force = false } = {}) {
@@@ -324,12 +314,14 @@ async function generatePersona(userId, { force = false } = {}) {
const entry = roster.byId[userId] || { user: publicUser(db.getUserById(userId) || { id: userId, username: `user${userId}` }), raw: rawStatsFor(userId), ratings: null };
const ctx = gatherContext(userId);
const stats = entry.ratings || Object.fromEntries(STAT_KEYS.map(k => [k, 70]).concat([['power', 70]]));
++ const m = entry.raw.mic || {};
const facts = {
name: entry.user.display_name, handle: entry.user.username, category: entry.raw.category, ratings: stats,
-- numbers: { hours_live_90d: entry.raw.hours, peak_viewers_90d: entry.raw.peak_viewers, avg_viewers: entry.raw.avg_viewers, chat_messages_per_hour: entry.raw.messages_per_hour, followers: entry.raw.followers, clips: entry.raw.clips, all_time_hours: entry.raw.all_time_hours, all_time_peak: entry.raw.all_time_peak,
-- on_mic: entry.raw.voice.has_data ? { talk_share_pct: entry.raw.voice.talk_ratio_pct, words_per_minute: entry.raw.voice.wpm, hype_words_per_hour: entry.raw.voice.hype_per_hour, laughs_per_hour: entry.raw.voice.laughs_per_hour, stream_sounds: entry.raw.voice.top_sounds.map(s => s.label) } : 'no transcript data yet' },
-- ai_overview: ctx.overview, recent_stream_titles: ctx.titles, what_the_camera_saw_recently: ctx.memories, chat_ai_notes: ctx.chat_notes, recent_vods: ctx.vods, things_they_said_on_stream: ctx.said,
-- things_they_typed_in_chat: ctx.typed, chatter_memory: ctx.chatter_memory || null, chatter_timeline: ctx.chatter_timeline || [], rooms_they_lurk_in: ctx.chat_rooms,
++ numbers: { hours_live_90d: entry.raw.hours,
++ on_mic: entry.raw.voice.has_data ? { talk_share_pct: entry.raw.voice.talk_ratio_pct, words_per_minute: entry.raw.voice.wpm, speech_minutes: entry.raw.voice.speech_minutes, hype_words_per_hour: entry.raw.voice.hype_per_hour, laughs_per_hour: entry.raw.voice.laughs_per_hour, stream_sounds: entry.raw.voice.top_sounds.map(s => s.label) } : 'no transcript data yet',
++ shit_talk_30d: { judged_moments: m.moments || 0, average_quality: m.avg_quality || 0, bangers_7_plus: m.bangers || 0, beef_hits: m.beef_hits || 0, beefs_won: m.wins || 0, beefs_lost: m.losses || 0, times_called_out: m.targeted || 0, times_answered: m.answered || 0 } },
++ ai_overview: ctx.overview, recent_stream_titles: ctx.titles, what_the_camera_saw_recently: ctx.memories, things_they_said_on_stream: ctx.said,
++ their_best_shit_talk: ctx.shit_talk || [], who_they_call_out: ctx.called_out || [], who_calls_them_out: ctx.called_out_by || [], what_they_rant_at: ctx.aimed_at || [], quotes: ctx.quotes || [], mic_style: ctx.mic_style || null,
roster_rivals: (() => { try { return loadRoster().order.filter(id => id !== userId).slice(0, 8).map(id => { const p = parseJson(profileRow(id)?.persona_json); return `${loadRoster().byId[id].user.username}${p?.fighter_name ? ` (${p.fighter_name})` : ''}`; }); } catch { return []; } })(),
};
const r = await llm.complete({ role: 'summary', kind: 'arena_persona', source: 'arena', ownerUserId: userId, system: PERSONA_SYSTEM, user: `Write the Arena persona for this fighter. Facts (JSON):\n${JSON.stringify(facts)}`, json: PERSONA_SCHEMA, maxTokens: 1200, temperature: 0.95, timeoutMs: 30000 });
@@@ -344,13 +336,13 @@ async function generatePersona(userId, { force = false } = {}) {
function fallbackPersona(entry) {
const r = entry.ratings || {};
const best = STAT_KEYS.reduce((a, b) => ((r[b] || 0) > (r[a] || 0) ? b : a), STAT_KEYS[0]);
-- const cls = { hype: 'Rushdown', grind: 'Tank', chat: 'Bard', loyalty: 'Summoner', clutch: 'Assassin', vibe: 'Zoner', mic: 'Caster' }[best];
++ const cls = { heat: 'Rushdown', aim: 'Sniper', kills: 'Assassin', mouth: 'Caster', clapback: 'Counter', stamina: 'Tank', pace: 'Zoner' }[best];
return {
fighter_name: entry.user.display_name, title: `The ${STAT_META[best].label} Specialist`, class: cls,
element: entry.raw.category ? entry.raw.category.replace(/[-_]/g, ' ') : 'Static',
signature_move: { name: `${STAT_META[best].label} Surge`, description: `Turns ${STAT_META[best].desc} into raw damage.` },
-- special: { name: 'Go Live', description: 'Hits the button. The arena fills up.' }, weakness: 'Sleep schedules.', taunt: 'Chat, are you seeing this?',
-- lore: `${entry.user.display_name} shows up, streams, and leaves the leaderboard slightly different than they found it.`,
++ special: { name: 'Hot Mic', description: 'Forgets the mic is on. That is the move.' }, weakness: 'Dead air.', taunt: 'Say my name on stream and see what happens.',
++ lore: `${entry.user.display_name} shows up, talks, and leaves the ladder slightly different than they found it.`,
catchphrase: 'Let him cook.', entrance_music: 'Untitled Loop (feat. Notification Sound)', taunts: [], typing_style: null, spoken_as: [], custom_stats: [],
stat_quips: Object.fromEntries(STAT_KEYS.map(k => [k, STAT_META[k].desc])), _fallback: true,
};
@@@ -534,13 +526,13 @@ function cardFor(userId, roster, { includeRaw = true, includeQuotes = false } =
if (!entry) return null;
const row = profileRow(userId);
const persona = parseJson(row?.persona_json) || fallbackPersona(entry);
-- const board = require('./board'), beef = require('./beef');
++ const mic = require('./mic'), beef = require('./beef');
const card = {
user: entry.user, rank: roster.order.indexOf(userId) + 1, roster_size: roster.order.length,
-- ratings: entry.ratings, stat_meta: STAT_META, raw: includeRaw ? entry.raw : undefined, voice: entry.raw.voice,
++ ratings: entry.ratings, stat_meta: STAT_META, raw: includeRaw ? entry.raw : undefined, voice: entry.raw.voice, mic: entry.raw.mic || null,
persona, persona_is_fallback: !!persona._fallback, persona_generated_at: row?.persona_generated_at || null,
image_url: imageUrlFor(row), image_prompt: row?.image_prompt || null, image_model: row?.image_model || null, image_pending: false,
-- record: beef.recordFor(userId), level: board.levelView(userId), live: isLive(userId),
++ record: beef.recordFor(userId), level: mic.levelView(userId), live: isLive(userId),
};
if (includeQuotes) card.quotes = parseJson(row?.quotes_json) || null;
return card;
@@@ -550,7 +542,7 @@ async function getFighter(usernameOrId, { generate = true } = {}) {
const user = resolveUser(usernameOrId);
if (!user) return null;
const roster = loadRoster();
-- if (!roster.byId[user.id]) return { user: publicUser(user), not_on_roster: true, reason: `No streams in the last ${ACTIVE_DAYS} days` };
++ 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); } }
@@@ -561,7 +553,6 @@ async function getFighter(usernameOrId, { generate = true } = {}) {
card.image_pending = !card.image_url && _imageInFlight.has(user.id);
card.image_generation = imageGenAvailable() ? 'ai' : 'off';
try { card.beefs = require('./beef').forUser(user.id, 8); } catch { card.beefs = []; }
-- try { const t = require('./board').activeTopicFor(user.id); card.active_topic = t ? { id: t.id, text: t.text } : null; } catch { card.active_topic = null; }
return card;
}
@@@ -570,10 +561,11 @@ function listFighters() {
return roster.order.map(id => {
const c = cardFor(id, roster, { includeRaw: false });
return {
-- user: c.user, rank: c.rank, ratings: c.ratings, record: c.record, live: c.live, image_url: c.image_url, level: { level: c.level.level, xp: c.level.xp }, tier: (() => { try { return require('./progress').tierFor(c.level.xp); } catch { return null; } })(),
++ user: c.user, rank: c.rank, ratings: c.ratings, record: c.record, live: c.live, image_url: c.image_url, level: { level: c.level.level, xp: c.level.xp }, mic: c.mic,
persona: { fighter_name: c.persona.fighter_name, title: c.persona.title, class: c.persona.class, element: c.persona.element, taunt: c.persona.taunt, taunts: c.persona.taunts || [], typing_style: c.persona.typing_style || null, lore: c.persona.lore, signature_move: c.persona.signature_move, stat_quips: c.persona.stat_quips, custom_stats: Array.isArray(c.persona.custom_stats) ? c.persona.custom_stats : [] },
persona_is_fallback: c.persona_is_fallback, category: roster.byId[id].raw.category, last_live_at: roster.byId[id].raw.last_live_at,
-- voice: { has_data: c.voice.has_data, talk_ratio_pct: c.voice.talk_ratio_pct, speech_minutes: c.voice.speech_minutes },
++ voice: { has_data: c.voice.has_data, talk_ratio_pct: c.voice.talk_ratio_pct, speech_minutes: c.voice.speech_minutes, wpm: c.voice.wpm },
++ last_line: (() => { try { const m = require('./mic').latestFor(id); return m ? { text: m.text, quality: m.quality, aimed_at: m.aimed_at, target: m.target, at: m.at, vod_id: m.vod_id, sec: m.sec } : null; } catch { return null; } })(),
};
});
}
@@@ -586,25 +578,34 @@ function getStatDetail(userId, stat) {
const win = `-${STATS_WINDOW_DAYS} days`;
let series = [];
try {
++ // One point per stream in the window — every value comes from the transcript / mic ledger of that stream.
const rows = db.all(`
-- SELECT s.id, s.title, s.started_at, s.duration_seconds, s.peak_viewers, sa.avg_viewers, sa.total_messages, sa.unique_chatters, sa.new_followers, sa.clips_created,
-- (SELECT COALESCE(SUM(COALESCE(e.end_sec, e.start_sec + 3) - e.start_sec), 0) FROM stream_timeline_events e WHERE e.stream_id = s.id AND e.kind = 'speech') AS speech_sec
-- FROM streams s LEFT JOIN stream_analytics sa ON sa.stream_id = s.id
++ SELECT s.id, s.title, s.started_at, s.duration_seconds,
++ (SELECT COALESCE(SUM(COALESCE(e.end_sec, e.start_sec + 3) - e.start_sec), 0) FROM stream_timeline_events e WHERE e.stream_id = s.id AND e.kind = 'speech') AS speech_sec,
++ (SELECT COALESCE(SUM(LENGTH(e.text) - LENGTH(REPLACE(e.text, ' ', '')) + 1), 0) FROM stream_timeline_events e WHERE e.stream_id = s.id AND e.kind = 'speech') AS words,
++ (SELECT COUNT(*) FROM arena_mic_moments m WHERE m.stream_id = s.id) AS moments,
++ (SELECT COALESCE(AVG(m.quality), 0) FROM arena_mic_moments m WHERE m.stream_id = s.id) AS avg_q,
++ (SELECT COUNT(*) FROM arena_mic_moments m WHERE m.stream_id = s.id AND m.kind = 'beef_hit') AS hits,
++ (SELECT COUNT(*) FROM arena_beefs b WHERE b.winner_user_id = s.user_id AND b.resolved_at BETWEEN s.started_at AND COALESCE(s.ended_at, s.started_at)) AS wins
++ FROM streams s
WHERE s.user_id = ? AND s.duration_seconds > 0 AND s.started_at >= datetime('now', ?) ORDER BY s.started_at DESC LIMIT 14`, [userId, win]).reverse();
const per = {
-- hype: r => r.peak_viewers || 0, grind: r => Number(((r.duration_seconds || 0) / 3600).toFixed(2)),
-- chat: r => Number(((r.total_messages || 0) / Math.max((r.duration_seconds || 0) / 3600, 0.1)).toFixed(1)), loyalty: r => (r.new_followers || 0) + (r.unique_chatters || 0) / 4,
-- clutch: r => Number((((r.clips_created || 0) * 3) / Math.max((r.duration_seconds || 0) / 3600, 0.1)).toFixed(2)), vibe: r => Number((r.avg_viewers || 0).toFixed(1)),
-- mic: r => (r.duration_seconds ? Number(((r.speech_sec || 0) / r.duration_seconds * 100).toFixed(1)) : 0),
++ heat: r => Number((r.avg_q || 0).toFixed(1)),
++ aim: r => Number(((r.moments || 0) / Math.max((r.speech_sec || 0) / 3600, 0.05)).toFixed(2)),
++ kills: r => r.wins || 0,
++ mouth: r => (r.duration_seconds ? Number(((r.speech_sec || 0) / r.duration_seconds * 100).toFixed(1)) : 0),
++ clapback: r => r.hits || 0,
++ stamina: r => Number(((r.speech_sec || 0) / 60).toFixed(1)),
++ pace: r => Number(((r.words || 0) / Math.max((r.speech_sec || 0) / 60, 0.1)).toFixed(0)),
};
series = rows.map(r => ({ stream_id: r.id, title: r.title, date: r.started_at, value: per[stat](r) }));
} catch { series = []; }
-- const unit = { hype: 'peak viewers', grind: 'hours', chat: 'msgs / hour', loyalty: 'loyalty points', clutch: 'clutch / hour', vibe: 'avg viewers', mic: '% of stream talking' }[stat];
-- const shown = (raw) => (stat === 'mic' ? (raw.voice?.talk_ratio_pct || 0) : (METRIC_FOR_STAT[stat](raw) || 0));
++ const unit = { heat: 'avg judge score', aim: 'hits / mic hour', kills: 'beefs won', mouth: '% of stream talking', clapback: 'answer rate', stamina: 'minutes of speech', pace: 'words / minute' }[stat];
++ const shown = (raw) => (METRIC_FOR_STAT[stat](raw) || 0);
const ranked = roster.order.map(id => ({ id, value: METRIC_FOR_STAT[stat](roster.byId[id].raw) || 0, shown: shown(roster.byId[id].raw), rating: roster.byId[id].ratings[stat] })).sort((x, y) => y.value - x.value);
const position = ranked.findIndex(r => r.id === userId) + 1;
const top = ranked.slice(0, 3).map(r => ({ user: roster.byId[r.id].user, fighter_name: (parseJson(profileRow(r.id)?.persona_json) || fallbackPersona(roster.byId[r.id])).fighter_name, value: Number(Number(r.shown).toFixed(1)), rating: r.rating }));
-- return { stat, label: STAT_META[stat].label, desc: STAT_META[stat].desc, unit, rating: entry.ratings[stat], value: Number(Number(shown(entry.raw)).toFixed(1)), position, roster_size: roster.order.length, weight: STAT_WEIGHTS[stat], series, top, voice: stat === 'mic' ? entry.raw.voice : undefined };
++ return { stat, label: STAT_META[stat].label, desc: STAT_META[stat].desc, unit, rating: entry.ratings[stat], value: Number(Number(shown(entry.raw)).toFixed(2)), position, roster_size: roster.order.length, weight: STAT_WEIGHTS[stat], series, top, voice: ['mouth', 'stamina', 'pace'].includes(stat) ? entry.raw.voice : undefined, mic: entry.raw.mic };
}
/** Live fighters with what the transcript last heard — the "on the mic now" strip. */
@@@ -615,19 +616,21 @@ function liveFighters() {
const byUser = new Map();
for (const s of live) { if (!roster.byId[s.user_id]) continue; const cur = byUser.get(s.user_id); if (!cur || (s.viewer_count || 0) > (cur.viewer_count || 0)) byUser.set(s.user_id, s); }
let thumbs = null; try { thumbs = require('../media-proxy/live-thumbs'); } catch { /* */ }
-- const board = require('./board'), beef = require('./beef');
++ const mic = require('./mic'), beef = require('./beef');
++ let listener = null; try { listener = require('./listener'); } catch { /* */ }
return [...byUser.values()].map(s => {
const c = cardFor(s.user_id, roster, { includeRaw: false });
let hotMic = null;
-- try { const r = db.all(`SELECT text, start_sec FROM stream_timeline_events WHERE stream_id = ? AND kind = 'speech' AND LENGTH(text) > 15 ORDER BY start_sec DESC LIMIT 5`, [s.id]).find(row => !isBannedText(row.text)); if (r) hotMic = { text: r.text, start_sec: Math.floor(r.start_sec) }; } catch { /* */ }
++ try { const r = db.all(`SELECT text, start_sec, vod_id FROM stream_timeline_events WHERE stream_id = ? AND kind = 'speech' AND LENGTH(text) > 15 ORDER BY start_sec DESC LIMIT 5`, [s.id]).find(row => !isBannedText(row.text)); if (r) hotMic = { text: r.text, start_sec: Math.floor(r.start_sec), vod_id: r.vod_id || null }; } catch { /* */ }
const transcribed = !!db.get(`SELECT 1 FROM stream_timeline_events WHERE stream_id = ? AND kind = 'speech' AND created_at >= datetime('now', '-30 minutes') LIMIT 1`, [s.id]);
-- const t = board.activeTopicFor(s.user_id);
++ let ears = null; try { const cs = listener ? listener.consoleState(s.user_id) : null; if (cs && cs.listening) ears = { focus: cs.focus ? { target_id: cs.focus.target_id, target: cs.focus.target, hits: cs.focus.hits, lock_seconds_left: cs.focus.lock_seconds_left } : null, pending_words: cs.pending_mic_words + (cs.focus ? cs.focus.pending_words : 0) }; } catch { ears = null; }
++ let lastMoment = null; try { lastMoment = mic.latestFor(s.user_id); } catch { lastMoment = null; }
return {
user: c.user, rank: c.rank, ratings: c.ratings, record: c.record, image_url: c.image_url, level: c.level.level,
persona: { fighter_name: c.persona.fighter_name, title: c.persona.title, class: c.persona.class, taunt: c.persona.taunt },
-- stream: { id: s.id, title: s.title, category: s.category, viewer_count: s.viewer_count || 0, started_at: s.started_at },
++ stream: { id: s.id, title: s.title, category: s.category, viewer_count: s.viewer_count || 0, started_at: s.started_at, slug: s.managed_stream_slug || null, managed_stream_id: s.managed_stream_id || null },
thumbnail_url: thumbs ? (thumbs.getCurrentLiveThumbnailUrl(s.id) || null) : null,
-- hot_mic: hotMic, transcribed, active_topic: t ? { id: t.id, text: t.text } : null, open_beefs: beef.openBeefsFor(s.user_id).length,
++ hot_mic: hotMic, transcribed, ears, last_moment: lastMoment, open_beefs: beef.openBeefsFor(s.user_id).length,
};
}).sort((x, y) => y.ratings.power - x.ratings.power);
}
@@@ -643,13 +646,13 @@ function status() {
ensureTables();
const roster = loadRoster();
const counts = db.get(`SELECT SUM(persona_json IS NOT NULL) AS personas, SUM(image_path IS NOT NULL) AS images, SUM(quotes_json IS NOT NULL) AS quotes FROM arena_profiles`) || {};
-- let beefs = {}, topics = 0;
-- try { beefs = db.get(`SELECT SUM(status = 'open') AS open, SUM(status = 'resolved') AS resolved FROM arena_beefs`) || {}; topics = db.get(`SELECT COUNT(*) AS n FROM arena_topics WHERE status = 'open'`)?.n || 0; } catch { /* */ }
++ let beefs = {}, moments = 0;
++ try { beefs = db.get(`SELECT SUM(status = 'open') AS open, SUM(status = 'resolved') AS resolved FROM arena_beefs`) || {}; moments = db.get(`SELECT COUNT(*) AS n FROM arena_mic_moments WHERE created_at >= datetime('now', '-1 day')`)?.n || 0; } catch { /* */ }
return {
-- enabled: arenaEnabled(), ai: aiOn(), image_generation: imageGenAvailable(), image_model: imageGenAvailable() ? String(setting('ai_image_model', 'gpt-image-1')) : null,
++ mode: 'battle-cam', enabled: arenaEnabled(), ai: aiOn(), image_generation: imageGenAvailable(), image_model: imageGenAvailable() ? String(setting('ai_image_model', 'gpt-image-1')) : null,
roster: roster.order.length, with_voice_data: roster.order.filter(id => roster.byId[id].raw.voice.has_data).length,
personas: counts.personas || 0, quotes: counts.quotes || 0, images: counts.images || 0,
-- beefs_open: beefs.open || 0, beefs_resolved: beefs.resolved || 0, topics_open: topics, live_fighters: liveFighters().length,
++ beefs_open: beefs.open || 0, beefs_resolved: beefs.resolved || 0, mic_moments_24h: moments, live_fighters: liveFighters().length,
listener: (() => { try { return require('./listener').TICK_MS; } catch { return null; } })(), active_days: ACTIVE_DAYS,
};
}
@@@ -658,6 +661,6 @@ module.exports = {
ensureTables, arenaEnabled, aiOn, imageGenAvailable, loadRoster, listFighters, getFighter, getStatDetail, liveFighters,
generatePersona, generateQuotes, generateImage, voterKeyFor, status, publicUser,
getFighterImageUrl: (userId) => imageUrlFor(profileRow(userId)),
-- STAT_KEYS, STAT_META, STAT_WEIGHTS, ARENA_DIR, TALK_BONUS_MAX,
++ STAT_KEYS, STAT_META, STAT_WEIGHTS, ARENA_DIR, TALK_BONUS_MAX, ACTIVE_DAYS, MIC_WINDOW_DAYS,
_computeRatings: computeRatings, _fallbackPersona: fallbackPersona, _voiceStatsFor: voiceStatsFor, _quoteCandidates: quoteCandidates, _isBannedText: isBannedText, _talkBonus: talkBonus,
};
ddiff --git a/server/arena/beef.js b/server/arena/beef.js
iindex eb3ad640..39125397 100644
---- a/server/arena/beef.js
++++ b/server/arena/beef.js
@@@ -6,7 +6,8 @@
*
* score → every judged hit aimed at the other side adds its quality (0–10) to
* your side of the tug-of-war. Chat adds crowd points with `!hype` in
-- * your chat (one per person per beef, max CROWD_MAX).
++ * your chat (one per person per beef, max CROWD_MAX) — that is the ONLY
++ * thing chat can do; every point of substance comes from the mic.
* clock → after every hit the OTHER side is on the clock: RESPONSE_LIVE_MIN
* minutes if they are live, RESPONSE_OFFLINE_HOURS hours if not (the
* clock tightens to the live window the moment they go live). Miss the
@@@ -78,7 +79,7 @@ function ensureTables() {
function parseJson(t, f = null) { try { return t ? JSON.parse(t) : f; } catch { return f; } }
function arena() { return require('./arena-service'); }
--function board() { return require('./board'); }
++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) {
@@@ -134,17 +135,15 @@ function recordHit(speakerId, targetId, hit) {
if (!beef) {
const endsAt = sqlDate(Date.now() + MAX_BEEF_HOURS * 3600 * 1000);
const history = rivalry(speakerId, targetId);
-- const bounty = board().openBountyOn(targetId);
-- db.run(`INSERT INTO arena_beefs (a_user_id, b_user_id, on_clock, clock_until, ends_at, feed_json, opener_line, rematch, bounty_topic_id, headline) VALUES (?, ?, 'b', ?, ?, '[]', ?, ?, ?, ?)`,
-- [speakerId, targetId, clockFor(targetId), endsAt, String(hit.best_line || '').slice(0, 220), history.fights > 0 ? 1 : 0, bounty?.id || null, templateHeadline('open', { a: nameOf(speakerId), b: nameOf(targetId), line: hit.best_line, rematch: history.fights > 0 })]);
++ db.run(`INSERT INTO arena_beefs (a_user_id, b_user_id, on_clock, clock_until, ends_at, feed_json, opener_line, rematch, bounty_topic_id, headline) VALUES (?, ?, 'b', ?, ?, '[]', ?, ?, NULL, ?)`,
++ [speakerId, targetId, clockFor(targetId), endsAt, String(hit.best_line || '').slice(0, 220), history.fights > 0 ? 1 : 0, templateHeadline('open', { a: nameOf(speakerId), b: nameOf(targetId), line: hit.best_line, rematch: history.fights > 0 })]);
beef = openBeefBetween(speakerId, targetId);
opened = true;
-- board().addXp(speakerId, XP_BEEF_OPEN, 'beef_open', beef.id);
-- console.log(`[Arena] beef #${beef.id} opened: user ${speakerId} → user ${targetId}${history.fights ? ' (rematch)' : ''}${bounty ? ' (bounty!)' : ''}`);
-- try { const pr = require('./progress'); pr.event(`user:${speakerId}`, 'beef', `Started a beef with ${nameOf(targetId)}${history.fights ? ' (rematch)' : ''}`, { detail: hit.best_line, url: `/arena/beef/${beef.id}` }); pr.event(`user:${targetId}`, 'beef', `${nameOf(speakerId)} came for you${history.fights ? ' again' : ''}`, { detail: hit.best_line, url: `/arena/beef/${beef.id}` }); } catch { /* */ }
++ mic().addXp(speakerId, XP_BEEF_OPEN, 'beef_open', beef.id);
++ console.log(`[Arena] beef #${beef.id} opened: user ${speakerId} → user ${targetId}${history.fights ? ' (rematch)' : ''}`);
try { require('./notify').arenaNotify(targetId, { type: 'beef_open', title: `${nameOf(speakerId)} has words for you${history.fights ? ' — rematch' : ''}`, message: `${hit.best_line ? `“${String(hit.best_line).slice(0, 120)}” — ` : ''}answer on your own stream within ${isLive(targetId) ? `${RESPONSE_LIVE_MIN} min` : `${RESPONSE_OFFLINE_HOURS} h`} or forfeit.`, icon: '🥊', url: `/arena/beef/${beef.id}`, key: `open:${beef.id}`, senderId: speakerId, senderName: nameOf(speakerId) }); } catch { /* */ }
const id = beef.id;
-- headlineFor('open', { a: nameOf(speakerId), b: nameOf(targetId), opening_line: hit.best_line, about: hit.about, rematch: history.fights > 0, record_between: history, bounty: !!bounty }).then(h => { if (h) db.run('UPDATE arena_beefs SET headline = ? WHERE id = ?', [h, id]); }).catch(() => {});
++ headlineFor('open', { a: nameOf(speakerId), b: nameOf(targetId), opening_line: hit.best_line, about: hit.about, rematch: history.fights > 0, record_between: history }).then(h => { if (h) db.run('UPDATE arena_beefs SET headline = ? WHERE id = ?', [h, id]); }).catch(() => {});
}
const side = beef.a_user_id === speakerId ? 'a' : 'b';
const other = side === 'a' ? 'b' : 'a';
@@@ -155,12 +154,11 @@ function recordHit(speakerId, targetId, hit) {
db.run(`UPDATE arena_beefs SET score_${side} = score_${side} + ?, hits_${side} = hits_${side} + 1, last_${side}_at = CURRENT_TIMESTAMP, responded = CASE WHEN ? THEN 1 ELSE responded END,
on_clock = ?, clock_until = ?, feed_json = ? WHERE id = ?`,
[quality, side === 'b' ? 1 : 0, other, clockFor(otherId), feed, beef.id]);
-- // Bounty: anyone with a bounty on their head pays double to whoever collects.
-- const bounty = board().openBountyOn(targetId);
-- board().addXp(speakerId, quality * XP_BEEF_HIT * (bounty ? 2 : 1), 'beef_hit', beef.id, { beefHit: true, line: hit.best_line, lineScore: quality, lineVodId: hit.vod_id, lineSec: hit.sec });
++ mic().addXp(speakerId, quality * XP_BEEF_HIT, 'beef_hit', beef.id, { beefHit: true, line: hit.best_line, lineScore: quality, lineVodId: hit.vod_id, lineSec: hit.sec });
++ // The hit is also a line in the shit-talk feed (VOD-linked, with who it was aimed at).
++ try { mic().addMoment({ userId: speakerId, streamId: hit.stream_id || null, vodId: hit.vod_id || null, sec: hit.sec ?? null, kind: 'beef_hit', targetUserId: targetId, beefId: beef.id, aimedAt: nameOf(targetId), text: hit.best_line || hit.about || '', about: hit.about || null, quality, announcer: hit.announcer || null }); } catch { /* */ }
if (!opened) { try { require('./notify').arenaNotify(otherId, { type: firstResponse ? 'beef_answered' : 'beef_hit', title: `${nameOf(speakerId)} ${firstResponse ? 'answered' : 'hit back'} (${quality}/10)`, message: `${hit.best_line ? `“${String(hit.best_line).slice(0, 120)}” — ` : ''}you're on the clock now.`, icon: '🔥', url: `/arena/beef/${beef.id}`, key: `hit:${beef.id}:${beef.hits_a + beef.hits_b}`, senderId: speakerId, senderName: nameOf(speakerId) }); } catch { /* */ } }
-- if (bounty) board().recordBountyHit(speakerId, bounty, quality, hit.best_line || null);
-- return { beef: db.get('SELECT * FROM arena_beefs WHERE id = ?', [beef.id]), opened, side, first_response: firstResponse, bounty: !!bounty };
++ return { beef: db.get('SELECT * FROM arena_beefs WHERE id = ?', [beef.id]), opened, side, first_response: firstResponse };
}
function hype(beefId, side, voterKey) {
@@@ -173,7 +171,7 @@ function hype(beefId, side, voterKey) {
const ins = db.run('INSERT OR IGNORE INTO arena_beef_hype (beef_id, side, voter_key) VALUES (?, ?, ?)', [beefId, side, voterKey]);
const n = db.get('SELECT COUNT(*) AS n FROM arena_beef_hype WHERE beef_id = ? AND side = ?', [beefId, side])?.n || 0;
db.run(`UPDATE arena_beefs SET crowd_${side} = ? WHERE id = ?`, [Math.min(CROWD_MAX, n), beefId]);
-- if (ins.changes) board().addXp(sideUser, board().XP_HYPE, 'hype', beefId);
++ if (ins.changes) mic().addXp(sideUser, mic().XP_HYPE, 'hype', beefId);
return { added: !!ins.changes, hypers: n, crowd: Math.min(CROWD_MAX, n) };
}
@@@ -189,9 +187,8 @@ function resolve(beef, resolution, winnerId) {
const ctx = { a: nameOf(beef.a_user_id), b: nameOf(beef.b_user_id), winner: winnerId ? nameOf(winnerId) : null, loser: loserId ? nameOf(loserId) : null, score_a: t.a, score_b: t.b, upset: !!upset, streak: winnerId ? streakFor(winnerId) + 1 : 0 };
const feed = pushFeed(beef, { kind: resolution === 'forfeit' ? 'forfeit' : 'end', side: winnerId == null ? null : (winnerId === beef.a_user_id ? 'a' : 'b'), text: null, upset: !!upset });
db.run(`UPDATE arena_beefs SET status = 'resolved', resolution = ?, winner_user_id = ?, resolved_at = CURRENT_TIMESTAMP, on_clock = NULL, clock_until = NULL, feed_json = ?, upset = ?, result_headline = ? WHERE id = ? AND status = 'open'`, [resolution, winnerId, feed, upset, templateHeadline(resolution === 'forfeit' ? 'forfeit' : 'score', ctx), beef.id]);
-- if (winnerId) board().addXp(winnerId, XP_BEEF_WIN + (upset ? 20 : 0), upset ? 'beef_upset_win' : 'beef_win', beef.id);
++ if (winnerId) mic().addXp(winnerId, XP_BEEF_WIN + (upset ? 20 : 0), upset ? 'beef_upset_win' : 'beef_win', beef.id);
console.log(`[Arena] beef #${beef.id} resolved: ${resolution}${winnerId ? ` winner user ${winnerId}` : ' draw'}${upset ? ' UPSET' : ''}`);
-- try { const pr = require('./progress'); for (const uid of [beef.a_user_id, beef.b_user_id]) { const won = winnerId === uid; pr.event(`user:${uid}`, 'beef_over', winnerId == null ? `Draw with ${nameOf(uid === beef.a_user_id ? beef.b_user_id : beef.a_user_id)}` : won ? `Beat ${nameOf(loserId)}${upset ? ' — UPSET' : ''}${resolution === 'forfeit' ? ' (they ducked)' : ''}` : `Lost to ${nameOf(winnerId)}${resolution === 'forfeit' ? ' (ran out the clock)' : ''}`, { detail: `${t.a}–${t.b}`, url: `/arena/beef/${beef.id}` }); pr.check(`user:${uid}`); } } catch { /* */ }
try { const n = require('./notify'); for (const uid of [beef.a_user_id, beef.b_user_id]) { const won = winnerId === uid, draw = winnerId == null; n.arenaNotify(uid, { type: 'beef_over', title: draw ? `Draw with ${nameOf(uid === beef.a_user_id ? beef.b_user_id : beef.a_user_id)}` : won ? `You won the beef with ${nameOf(loserId)}${upset ? ' — UPSET' : ''}` : `${nameOf(winnerId)} won the beef${resolution === 'forfeit' ? ' — you ran out the clock' : ' on points'}`, message: won ? `+${XP_BEEF_WIN + (upset ? 20 : 0)} XP. ${t.a}–${t.b}.` : `${t.a}–${t.b}. Say their name on mic for a rematch.`, icon: won ? '🏆' : draw ? '🤝' : '💀', url: `/arena/beef/${beef.id}`, key: `over:${beef.id}` }); } } catch { /* */ }
const id = beef.id;
headlineFor(resolution, ctx).then(h => { if (h) db.run('UPDATE arena_beefs SET result_headline = ? WHERE id = ?', [h, id]); }).catch(() => {});
@@@ -220,7 +217,7 @@ function rivalry(u1, u2) {
function rivalriesFor(userId, limit = 5) {
ensureTables();
const opps = db.all(`SELECT CASE WHEN a_user_id = ? THEN b_user_id ELSE a_user_id END AS opp, COUNT(*) AS n FROM arena_beefs WHERE (a_user_id = ? OR b_user_id = ?) GROUP BY opp HAVING n >= 2 ORDER BY n DESC LIMIT ?`, [userId, userId, userId, limit]);
-- const b = board();
++ const b = mic();
const roster = arena().loadRoster();
return opps.map(o => { const r = rivalry(userId, o.opp); return { opponent: b.fighterBrief(o.opp, roster), fights: r.fights + (openBeefBetween(userId, o.opp) ? 1 : 0), wins: r.wins_1, losses: r.wins_2, open: !!openBeefBetween(userId, o.opp), receipts: r.receipts.slice(0, 2) }; });
}
@@@ -267,7 +264,7 @@ function recentWins(userId, days = 7) {
function beefView(beef, roster) {
const t = totals(beef);
-- const b = board();
++ const b = mic();
const clockMs = beef.clock_until ? Date.parse(beef.clock_until + 'Z') - Date.now() : null;
return {
id: beef.id, status: beef.status, resolution: beef.resolution,
@@@ -278,7 +275,7 @@ function beefView(beef, roster) {
clock_is_live_window: beef.on_clock ? isLive(beef.on_clock === 'a' ? beef.a_user_id : beef.b_user_id) : false,
responded: !!beef.responded,
opener_line: beef.opener_line, headline: beef.headline, result_headline: beef.result_headline,
-- rematch: !!beef.rematch, upset: !!beef.upset, bounty: !!beef.bounty_topic_id,
++ rematch: !!beef.rematch, upset: !!beef.upset,
history: rivalry(beef.a_user_id, beef.b_user_id),
streaks: { a: streakFor(beef.a_user_id), b: streakFor(beef.b_user_id) },
feed: parseJson(beef.feed_json, []).slice(-14),
ddiff --git a/server/arena/board.js b/server/arena/board.js
ddeleted file mode 100644
iindex 34b778ca..00000000
---- a/server/arena/board.js
++++ /dev/null
@@@ -1,839 +0,0 @@
--/**
-- * OpenVibe.Live — Arena Board: living lore profiles of whatever the community is on about
-- *
-- * topic → a SUBJECT people are actually talking about (chat, on stream, or both). Topics
-- * are discovered from the global chat + live transcripts (AI every few minutes,
-- * keyword bursts without AI), or started by a signed-in user (`!topic …` / page,
-- * one per person and per IP per 24 h; the AI rewrites it into a proper subject).
-- * Each topic has KEYWORDS; every chat message or transcript line that matches
-- * becomes a MOMENT on the topic (chat lines, on-mic lines with a VOD deep link).
-- * lore → the AI rewrites the topic's lore as moments pile up: who brought it up, who
-- * said what, who is on which end of it, how it escalated. Tabloid voice, names
-- * names, quotes the receipts. Templated when AI is off. Lore persists in the
-- * archive after the topic cools off.
-- * join in → a fighter on the roster "talks on" a topic (button / naturally by saying its
-- * keywords on mic). The listener judges their chunks; each judged hit is a moment
-- * with a quality score → XP → Trash Level. Viewers pile on from chat: their lines
-- * become moments, they climb the "yappers" ladder.
-- * bounty → a fighter the community keeps naming: every beef hit on them pays double.
-- *
-- * No voting anywhere. Heat = mentions + hype + fighters talking in the last hour.
-- * Token discipline: discovery only runs when there is new material, lore only when ≥ 3 new
-- * moments landed (and ≥ 8 min since the last rewrite), one refine call per user-made topic.
-- */
--'use strict';
--
--const crypto = require('crypto');
--const db = require('../db/database');
--const llm = require('../ai/llm');
--
--const TOPIC_TTL_HOURS = 36;
--const TOPIC_MAX_LEN = 140;
--const XP_PER_LEVEL = 50;
--const XP_JOIN = 10;
--const XP_HYPE = 2;
--const MAX_OPEN_TOPICS = 24;
--const KIND_TTL_HOURS = { topic: TOPIC_TTL_HOURS, bounty: 6 };
--const HOT_THRESHOLD = 12;
--const MENTION_COOLDOWN_SEC = 45; // one raw on-mic mention moment per stream per topic per window
--const LORE_MIN_NEW_MOMENTS = 3;
--const LORE_MIN_INTERVAL_MS = 8 * 60 * 1000;
--const DISCOVER_INTERVAL_MS = 5 * 60 * 1000;
--const DISCOVER_MIN_NEW_LINES = 8; // don't spend a call on a dead room
--const SCAN_WINDOW_MIN = 30;
--function scanWindowMin() { try { const v = Number(db.getSetting('arena_discover_window_min')); return v > 0 ? Math.min(v, 24 * 60) : SCAN_WINDOW_MIN; } catch { return SCAN_WINDOW_MIN; } }
--const USER_TOPIC_COOLDOWN_HOURS = 24;
--
--const STOPWORDS = new Set(('the a an and or but if then than that this these those there here what when where which who whom why how all any both each few more most other some such no nor not only own same so too very can will just should now is are was were be been being have has had having do does did doing would could might must shall may of at by for with about against between into through during before after above below to from up down in out on off over under again further once i me my myself we our ours you your yours he him his she her hers it its they them their what its lol lmao omg yeah yes okay ok like get got gets going go went thing things stuff really actually literally bro bruh dude guys chat stream streaming streamer live today tonight right know think mean want make made say said says people time way good bad new old big long still even also back much many'
--).split(/\s+/));
--
--let _ready = false;
--function ensureTables() {
-- if (_ready) return;
-- db.run(`CREATE TABLE IF NOT EXISTS arena_topics (
-- id INTEGER PRIMARY KEY AUTOINCREMENT,
-- text TEXT NOT NULL,
-- hint TEXT,
-- created_by TEXT NOT NULL,
-- creator_user_id INTEGER,
-- creator_name TEXT,
-- status TEXT NOT NULL DEFAULT 'open',
-- angles_json TEXT,
-- joins INTEGER DEFAULT 0,
-- hits INTEGER DEFAULT 0,
-- conquered INTEGER DEFAULT 0,
-- last_activity_at DATETIME DEFAULT CURRENT_TIMESTAMP,
-- created_at DATETIME DEFAULT CURRENT_TIMESTAMP
-- )`);
-- db.run(`CREATE TABLE IF NOT EXISTS arena_topic_members (
-- topic_id INTEGER NOT NULL,
-- user_id INTEGER NOT NULL,
-- active INTEGER DEFAULT 1,
-- conquered_at DATETIME,
-- joined_at DATETIME DEFAULT CURRENT_TIMESTAMP,
-- PRIMARY KEY (topic_id, user_id)
-- )`);
-- db.run(`CREATE TABLE IF NOT EXISTS arena_topic_hype (
-- topic_id INTEGER NOT NULL,
-- user_id INTEGER NOT NULL,
-- voter_key TEXT NOT NULL,
-- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
-- PRIMARY KEY (topic_id, user_id, voter_key)
-- )`);
-- db.run(`CREATE TABLE IF NOT EXISTS arena_topic_moments (
-- id INTEGER PRIMARY KEY AUTOINCREMENT,
-- topic_id INTEGER NOT NULL,
-- kind TEXT NOT NULL, -- 'speech' | 'chat'
-- source TEXT NOT NULL, -- 'mention' | 'judge' | 'chat' | 'seed'
-- user_id INTEGER,
-- username TEXT,
-- stream_id INTEGER,
-- vod_id INTEGER,
-- sec INTEGER,
-- text TEXT NOT NULL,
-- quality REAL,
-- about TEXT,
-- created_at DATETIME DEFAULT CURRENT_TIMESTAMP
-- )`);
-- db.run('CREATE INDEX IF NOT EXISTS idx_arena_moments_topic ON arena_topic_moments (topic_id, created_at)');
-- for (const col of ['chatter_key TEXT', 'source_platform TEXT', 'anon_id TEXT', 'thread_id INTEGER', 'said_at DATETIME']) { try { db.run(`ALTER TABLE arena_topic_moments ADD COLUMN ${col}`); } catch { /* exists */ } }
-- db.run('UPDATE arena_topic_moments SET said_at = created_at WHERE said_at IS NULL');
-- // Threads: the sub-angles INSIDE an umbrella subject ("Cat enema drama" → "the vet bill", "the bot roasts", "global vs local").
-- db.run(`CREATE TABLE IF NOT EXISTS arena_topic_threads (
-- id INTEGER PRIMARY KEY AUTOINCREMENT,
-- topic_id INTEGER NOT NULL,
-- name TEXT NOT NULL,
-- hint TEXT,
-- keywords_json TEXT,
-- moments INTEGER DEFAULT 0,
-- last_at DATETIME,
-- created_at DATETIME DEFAULT CURRENT_TIMESTAMP
-- )`);
-- db.run('CREATE INDEX IF NOT EXISTS idx_arena_threads_topic ON arena_topic_threads (topic_id)');
-- db.run('CREATE INDEX IF NOT EXISTS idx_arena_moments_chatter ON arena_topic_moments (chatter_key, id)');
-- db.run(`CREATE TABLE IF NOT EXISTS arena_trash_levels (
-- user_id INTEGER PRIMARY KEY,
-- xp INTEGER DEFAULT 0,
-- level INTEGER DEFAULT 1,
-- angles_cleared INTEGER DEFAULT 0,
-- topics_conquered INTEGER DEFAULT 0,
-- beef_hits INTEGER DEFAULT 0,
-- best_line TEXT,
-- best_line_vod_id INTEGER,
-- best_line_sec INTEGER,
-- best_line_score REAL DEFAULT 0,
-- updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
-- )`);
-- db.run(`CREATE TABLE IF NOT EXISTS arena_xp_log (
-- id INTEGER PRIMARY KEY AUTOINCREMENT,
-- user_id INTEGER NOT NULL,
-- amount INTEGER NOT NULL,
-- reason TEXT NOT NULL,
-- ref_id INTEGER,
-- created_at DATETIME DEFAULT CURRENT_TIMESTAMP
-- )`);
-- for (const col of ["kind TEXT NOT NULL DEFAULT 'topic'", 'target_user_id INTEGER', 'headline TEXT', 'heat REAL DEFAULT 0', 'source_note TEXT', 'expires_at DATETIME', 'resolved_json TEXT',
-- 'keywords_json TEXT', 'lore TEXT', 'tagline TEXT', 'lore_updated_at DATETIME', 'lore_moment_count INTEGER DEFAULT 0', 'chat_mentions INTEGER DEFAULT 0', 'mic_mentions INTEGER DEFAULT 0', 'last_mention_at DATETIME', 'origin_json TEXT', 'creator_ip_hash TEXT', 'submitted_text TEXT', 'peak_heat REAL DEFAULT 0']) {
-- try { db.run(`ALTER TABLE arena_topics ADD COLUMN ${col}`); } catch { /* exists */ }
-- }
-- for (const col of ['topic_moments INTEGER DEFAULT 0', 'topics_joined INTEGER DEFAULT 0']) { try { db.run(`ALTER TABLE arena_trash_levels ADD COLUMN ${col}`); } catch { /* exists */ } }
-- db.run('CREATE INDEX IF NOT EXISTS idx_arena_xp_log_user ON arena_xp_log (user_id, created_at)');
-- _ready = true;
--}
--
--function aiOn() { try { return llm.isEnabled() && llm.withinBudget(); } catch { return false; } }
--function parseJson(t, f = null) { try { return t ? JSON.parse(t) : f; } catch { return f; } }
--function arena() { return require('./arena-service'); }
--function clip(t, n) { return String(t || '').replace(/\s+/g, ' ').trim().slice(0, n); }
--function levelFor(xp) { return 1 + Math.floor((Number(xp) || 0) / XP_PER_LEVEL); }
--function ipHash(ip) { if (!ip) return null; let salt = ''; try { salt = String(db.getSetting('arena_vote_salt') || process.env.JWT_SECRET || 'arena'); } catch { salt = 'arena'; } return crypto.createHash('sha256').update(`${salt}:${ip}`).digest('hex').slice(0, 24); }
--
--// ── Trash Level / XP ─────────────────────────────────────────
--
--function levelRow(userId) {
-- ensureTables();
-- return db.get('SELECT * FROM arena_trash_levels WHERE user_id = ?', [userId]) || { user_id: userId, xp: 0, level: 1, beef_hits: 0, topic_moments: 0, topics_joined: 0, best_line_score: 0 };
--}
--
--function addXp(userId, amount, reason, refId = null, extra = {}) {
-- ensureTables();
-- amount = Math.round(Number(amount) || 0);
-- if (amount <= 0) return levelRow(userId);
-- const before = levelRow(userId);
-- db.run(`INSERT INTO arena_trash_levels (user_id, xp, level) VALUES (?, ?, ?)
-- ON CONFLICT(user_id) DO UPDATE SET xp = xp + excluded.xp, level = ?, updated_at = CURRENT_TIMESTAMP`,
-- [userId, amount, levelFor(amount), levelFor((before.xp || 0) + amount)]);
-- db.run('INSERT INTO arena_xp_log (user_id, amount, reason, ref_id) VALUES (?, ?, ?, ?)', [userId, amount, reason, refId]);
-- const sets = [];
-- if (extra.moment) sets.push('topic_moments = topic_moments + 1');
-- if (extra.joined) sets.push('topics_joined = topics_joined + 1');
-- if (extra.beefHit) sets.push('beef_hits = beef_hits + 1');
-- if (sets.length) db.run(`UPDATE arena_trash_levels SET ${sets.join(', ')} WHERE user_id = ?`, [userId]);
-- if (extra.line && (extra.lineScore || 0) > (before.best_line_score || 0)) {
-- db.run('UPDATE arena_trash_levels SET best_line = ?, best_line_vod_id = ?, best_line_sec = ?, best_line_score = ? WHERE user_id = ?', [String(extra.line).slice(0, 220), extra.lineVodId || null, extra.lineSec ?? null, extra.lineScore, userId]);
-- }
-- const after = levelRow(userId);
-- if (after.level > before.level) { console.log(`[Arena] user ${userId} → Trash Level ${after.level}`); try { require('./progress').event(`user:${userId}`, 'level', `Trash Level ${after.level}`, { detail: `${after.xp} XP` }); } catch { /* */ } }
-- try { arena().loadRoster(true); } catch { /* */ }
-- if (!reason.startsWith('ach_')) { try { require('./progress').check(`user:${userId}`); } catch (e) { console.warn('[Arena] progress:', e.message); } }
-- return { ...after, leveled_up: after.level > before.level, gained: amount };
--}
--
--function recentXp(userId, days = 7) {
-- ensureTables();
-- return db.get(`SELECT COALESCE(SUM(amount), 0) AS xp FROM arena_xp_log WHERE user_id = ? AND created_at >= datetime('now', ?)`, [userId, `-${days} days`])?.xp || 0;
--}
--
--function levelView(userId) {
-- const r = levelRow(userId);
-- const xp = r.xp || 0;
-- return {
-- level: levelFor(xp), xp, xp_into_level: xp - (levelFor(xp) - 1) * XP_PER_LEVEL, xp_per_level: XP_PER_LEVEL, next_level_xp: levelFor(xp) * XP_PER_LEVEL,
-- topic_moments: r.topic_moments || 0, topics_joined: r.topics_joined || 0, beef_hits: r.beef_hits || 0,
-- recent_xp: recentXp(userId),
-- best_line: r.best_line ? { text: r.best_line, vod_id: r.best_line_vod_id, sec: r.best_line_sec, score: r.best_line_score } : null,
-- };
--}
--
--// ── Keywords + matching ──────────────────────────────────────
--
--function cleanTopicText(text) {
-- const t = clip(text, TOPIC_MAX_LEN);
-- if (t.length < 4) throw new Error('Topic is too short');
-- return t;
--}
--
--/** Significant words of a subject: ≥ 4 letters, not a stopword, lowercased. */
--function keywordsFromText(text) {
-- const words = String(text || '').toLowerCase().replace(/[^a-z0-9@_\s'-]/g, ' ').split(/\s+/).map(w => w.replace(/^@/, '').replace(/'s$/, '')).filter(w => w.length >= 4 && !STOPWORDS.has(w));
-- return [...new Set(words)].slice(0, 8);
--}
--// Words that appear on every stream on this site — worthless as subject keywords.
--const GENERIC_KW = new Set('mic mics chat chats chatter chatters quote quotes banter chaos drama dramas bill bills ban bans banned timeout timeouts mods mod moderation stream streams streamer streamers streaming live clip clips vod vods game games gaming joke jokes roast roasts roasting beef beefs arena hype crowd viewers viewer subs vibes vibe energy moment moments clash community site global room rooms talk talking said saying mention mentions callout callouts feud feuds war wars saga controversy controversies takes take rant rants'.split(' '));
--function normalizeKeywords(list) {
-- // Single plain English words ("mic", "banter", "chaos", "bill") match every stream on the site and manufacture
-- // phantom moments — only distinctive single words survive; phrases are always fine.
-- let COMMON = null; try { COMMON = require('./names').COMMON; } catch { COMMON = new Set(); }
-- return [...new Set((Array.isArray(list) ? list : []).map(k => String(k || '').toLowerCase().replace(/^@/, '').replace(/\s+/g, ' ').trim()).filter(k => k.length >= 3 && !STOPWORDS.has(k) && (k.includes(' ') || (!COMMON.has(k) && !GENERIC_KW.has(k)))))].slice(0, 10);
--}
--function keywordRegex(k) {
-- // Loose stem: "pakistanis" matches "pakistani", "tents" matches "tent", "yapping" still needs its own keyword.
-- const stem = k.length > 4 && !/ss$/.test(k) ? k.replace(/(?:ies|es|s)$/, (m) => (m === 'ies' ? 'y' : '')) : k;
-- const esc = stem.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\s+/g, '\\s+');
-- return new RegExp(`(?:^|[^a-z0-9])${esc}(?:s|es|ies|ing|ed)?(?![a-z0-9])`, 'i');
--}
--let _kwCache = { at: 0, list: [] };
--function openTopicMatchers() {
-- if (Date.now() - _kwCache.at < 20 * 1000) return _kwCache.list;
-- const list = [];
-- for (const t of db.all(`SELECT id, text, kind, target_user_id, keywords_json FROM arena_topics WHERE status = 'open'`)) {
-- const kws = normalizeKeywords(parseJson(t.keywords_json, []));
-- if (kws.length) list.push({ id: t.id, kind: t.kind, keywords: kws, res: kws.map(keywordRegex) });
-- }
-- _kwCache = { at: Date.now(), list };
-- return list;
--}
--function invalidateMatchers() { _kwCache.at = 0; }
--/** Open topics whose keywords appear in `text`. */
--function matchTopics(text) {
-- const s = String(text || '');
-- if (s.length < 3) return [];
-- return openTopicMatchers().filter(m => m.res.some(re => re.test(s))).map(m => ({ id: m.id, kind: m.kind, keywords: m.keywords }));
--}
--
--// ── Topics ───────────────────────────────────────────────────
--
--function createTopic({ text, hint = null, createdBy, creatorUserId = null, creatorName = null, creatorIp = null, kind = 'topic', targetUserId = null, headline = null, sourceNote = null, keywords = null, tagline = null, origin = null, submittedText = null, threads = null }) {
-- ensureTables();
-- const clean = cleanTopicText(text);
-- kind = ['topic', 'bounty'].includes(kind) ? kind : 'topic';
-- if (kind === 'bounty' && !targetUserId) throw new Error('A bounty needs a target');
-- if (kind === 'bounty' && db.get(`SELECT id FROM arena_topics WHERE status = 'open' AND kind = 'bounty' AND target_user_id = ?`, [targetUserId])) throw new Error('There is already a bounty on them');
-- const dup = db.get(`SELECT id FROM arena_topics WHERE status = 'open' AND LOWER(text) = LOWER(?)`, [clean]);
-- if (dup) throw new Error('That topic is already on the board');
-- const threadList = (Array.isArray(threads) ? threads : []).map(t => ({ name: clip(t?.name, 40), hint: t?.hint ? clip(t.hint, 90) : null, keywords: normalizeKeywords(t?.keywords || []) })).filter(t => t.name).slice(0, 6);
-- for (const t of threadList) if (!t.keywords.length) t.keywords = keywordsFromText(t.name);
-- let kws = normalizeKeywords([...(Array.isArray(keywords) ? keywords : []), ...threadList.flatMap(t => t.keywords)]);
-- if (!kws.length) kws = keywordsFromText(clean);
-- // A bounty matches ONLY the target's name (never generic words like "bounty" — that's a quest in half the games people stream).
-- if (kind === 'bounty' && targetUserId) { const u = db.getUserById(targetUserId); if (u) { const nm = require('./names'); kws = normalizeKeywords([u.username, u.display_name, ...nm.variants(u.username), ...nm.variants(u.display_name)]); } }
-- if (kind === 'topic' && kws.length >= 2) {
-- for (const m of openTopicMatchers()) { if (m.kind === 'topic' && m.keywords.filter(k => kws.includes(k)).length >= 2) throw new Error('That subject is already on the board'); }
-- }
-- const open = db.get(`SELECT COUNT(*) AS n FROM arena_topics WHERE status = 'open'`)?.n || 0;
-- if (open >= MAX_OPEN_TOPICS) archiveStale(true);
-- const expires = new Date(Date.now() + (KIND_TTL_HOURS[kind] || TOPIC_TTL_HOURS) * 3600 * 1000).toISOString().replace('T', ' ').slice(0, 19);
-- db.run(`INSERT INTO arena_topics (text, hint, created_by, creator_user_id, creator_name, creator_ip_hash, kind, target_user_id, headline, source_note, expires_at, keywords_json, tagline, origin_json, submitted_text, last_mention_at)
-- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)`,
-- [clean, hint ? clip(hint, 120) : null, createdBy, creatorUserId, creatorName ? clip(creatorName, 40) : null, ipHash(creatorIp), kind, targetUserId, headline ? clip(headline, 160) : null, sourceNote ? clip(sourceNote, 80) : null, expires, JSON.stringify(kws), tagline ? clip(tagline, 120) : null, origin ? JSON.stringify(origin) : null, submittedText ? clip(submittedText, 200) : null]);
-- const row = db.get('SELECT * FROM arena_topics ORDER BY id DESC LIMIT 1');
-- if (kind === 'bounty' && targetUserId) { try { require('./notify').arenaNotify(targetUserId, { type: 'bounty', title: `Chat put a bounty on you`, message: `${creatorName || 'The community'}: double XP for anyone who talks shit about you on stream for the next ${KIND_TTL_HOURS.bounty} h. Answer on mic.`, icon: '💰', url: `/arena/topic/${row.id}`, key: `bounty:${row.id}` }); } catch { /* */ } }
-- for (const t of threadList) db.run('INSERT INTO arena_topic_threads (topic_id, name, hint, keywords_json) VALUES (?, ?, ?, ?)', [row.id, t.name, t.hint, JSON.stringify(t.keywords)]);
-- invalidateMatchers();
-- console.log(`[Arena] ${kind} #${row.id} by ${createdBy}${creatorName ? ' ' + creatorName : ''}: "${clean}" [${kws.join(', ')}]${threadList.length ? ` threads: ${threadList.map(t => t.name).join(' | ')}` : ''}`);
-- return row;
--}
--
--/** One user-made topic per person and per IP per 24 h. Throws with a friendly message. */
--function assertCanSubmit(userId, ip) {
-- ensureTables();
-- if (!userId) throw new Error('Sign in to start a topic');
-- const since = `-${USER_TOPIC_COOLDOWN_HOURS} hours`;
-- const mine = db.get(`SELECT created_at FROM arena_topics WHERE creator_user_id = ? AND created_by IN ('viewer', 'streamer', 'chat') AND created_at >= datetime('now', ?) ORDER BY id DESC LIMIT 1`, [userId, since]);
-- if (mine) throw new Error(`One topic per person per ${USER_TOPIC_COOLDOWN_HOURS} h — yours is still cooking`);
-- const h = ipHash(ip);
-- if (h) {
-- const same = db.get(`SELECT created_at FROM arena_topics WHERE creator_ip_hash = ? AND created_by IN ('viewer', 'streamer', 'chat') AND created_at >= datetime('now', ?) ORDER BY id DESC LIMIT 1`, [h, since]);
-- if (same) throw new Error(`One topic per ${USER_TOPIC_COOLDOWN_HOURS} h from this connection`);
-- }
--}
--
--const REFINE_SCHEMA = { name: 'arena_refine_topic', schema: { type: 'object', additionalProperties: false, required: ['subject', 'headline', 'tagline', 'hint', 'keywords', 'threads', 'reject'], properties: {
-- subject: { type: 'string', description: 'the umbrella subject, 1–4 words, punchy, dumb-funny' },
-- headline: { type: 'string', description: 'tabloid headline ≤ 90 chars, inflammatory and stupid in a good way' },
-- tagline: { type: 'string', description: '≤ 100 chars, who submitted it and why everyone should care' },
-- hint: { type: 'string', description: '≤ 90 chars: how to pile on / bait for streamers' },
-- keywords: { type: 'array', minItems: 2, maxItems: 8, items: { type: 'string' }, description: 'lowercase words/slang people would actually type or say about it' },
-- threads: { type: 'array', minItems: 2, maxItems: 4, items: { type: 'object', additionalProperties: false, required: ['name', 'keywords', 'hint'], properties: { name: { type: 'string', description: '2–5 words, a sub-angle inside the subject' }, keywords: { type: 'array', minItems: 1, maxItems: 4, items: { type: 'string' } }, hint: { type: 'string', description: '≤ 70 chars' } } } },
-- reject: { type: 'boolean', description: 'true ONLY if it is a direct threat of violence, sexual content about a minor, or doxxing' },
--} } };
--const REFINE_SYSTEM = `A user typed a topic for the shit-talking board of a live-streaming site. Rewrite it into a proper board SUBJECT that looks good and baits people: a short, VAGUE umbrella subject (1–4 words), a tabloid headline, a tagline crediting the submitter, a hint on how to pile on, the keywords people would actually type or say about it (slang, variants, misspellings), and 2–4 THREADS — the specific angles inside it people will argue. Make it dumber, funnier and more provocative than what they typed; keep their actual meaning. Slurs, profanity and taboo topics are allowed and never a reason to reject — this platform does not censor language. Reject ONLY direct threats of violence, sexual content about minors, or doxxing. Output only the JSON.`;
--
--/** A user-submitted topic → the AI makes it look good (one call). Falls back to the raw text. */
--async function submitTopic({ text, userId, ip, creatorName, onRoster = false }) {
-- assertCanSubmit(userId, ip);
-- const raw = cleanTopicText(text);
-- if (arena()._isBannedText(raw)) throw new Error('That crosses the line (threats, minors, doxxing)');
-- let refined = null;
-- if (aiOn()) {
-- try {
-- const r = await llm.complete({ role: 'chat', kind: 'arena_refine_topic', source: 'arena', ownerUserId: userId, system: REFINE_SYSTEM, user: JSON.stringify({ submitted: raw, by: creatorName }), json: REFINE_SCHEMA, maxTokens: 300, temperature: 0.95, timeoutMs: 20000 });
-- if (r && r.json) { if (r.json.reject) throw new Error('That crosses the line (threats, minors, doxxing)'); refined = r.json; }
-- } catch (e) { if (/crosses the line/.test(e.message)) throw e; console.warn('[Arena] refine topic:', e.message); }
-- }
-- const mc = mergeCandidate(refined?.subject || raw, refined?.keywords || keywordsFromText(raw));
-- if (mc) {
-- const r = foldInto(mc.id, { subject: refined?.subject || raw, keywords: refined?.keywords || [], hint: refined?.hint || null, threads: refined?.threads || [] });
-- const existing = db.get('SELECT * FROM arena_topics WHERE id = ?', [mc.id]);
-- console.log(`[Arena] ${creatorName}'s "${raw}" folded into subject #${mc.id} (${mc.why})`);
-- return { ...existing, folded: true, threads_added: r.threads_added };
-- }
-- const t = createTopic({ text: refined?.subject || raw, hint: refined?.hint || null, createdBy: onRoster ? 'streamer' : 'viewer', creatorUserId: userId, creatorName, creatorIp: ip, headline: refined?.headline || null, tagline: refined?.tagline || `Put up by ${creatorName}`, sourceNote: creatorName, keywords: refined?.keywords || null, threads: refined?.threads || null, submittedText: raw });
-- backfillMoments([t]);
-- try { require('./chatters').onSubjectStarted(`user:${userId}`, t.id, { display: creatorName }); require('./progress').event(`user:${userId}`, 'subject', `Started “${t.headline || t.text}”`, { url: `/arena/topic/${t.id}` }); } catch { /* */ }
-- return t;
--}
--
--/** A moment: something someone said (chat or on mic) about the topic. */
--function addMoment(topicId, { kind, source, userId = null, username = null, streamId = null, vodId = null, sec = null, text, quality = null, about = null, platform = null, anonId = null, saidAt = null }) {
-- ensureTables();
-- const t = db.get(`SELECT id, kind, status, heat FROM arena_topics WHERE id = ?`, [topicId]);
-- if (!t || t.status !== 'open') return null;
-- const body = clip(text, 240);
-- if (!body || arena()._isBannedText(body)) return null;
-- const chatters = require('./chatters');
-- const chatterKey = chatters.keyFor({ user_id: userId, username, anon_id: anonId, source_platform: platform });
-- const threadId = threadFor(topicId, body);
-- // When the line was actually SAID (chat timestamp / stream start + offset) — not when we filed it.
-- let said = saidAt ? String(saidAt).replace('T', ' ').replace(/\.\d+Z?$/, '').replace('Z', '') : null;
-- if (!said && kind === 'speech' && streamId && sec != null) { const st = db.get('SELECT started_at FROM streams WHERE id = ?', [streamId]); if (st?.started_at) { const ms = Date.parse(String(st.started_at).replace(' ', 'T') + 'Z'); if (!isNaN(ms)) said = new Date(ms + sec * 1000).toISOString().replace('T', ' ').slice(0, 19); } }
-- db.run(`INSERT INTO arena_topic_moments (topic_id, kind, source, user_id, username, stream_id, vod_id, sec, text, quality, about, chatter_key, source_platform, anon_id, thread_id, said_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE(?, CURRENT_TIMESTAMP))`,
-- [topicId, kind, source, userId, username ? clip(username, 60) : null, streamId, vodId, sec, body, quality, about ? clip(about, 80) : null, chatterKey, platform, anonId, threadId, said]);
-- if (threadId) db.run('UPDATE arena_topic_threads SET moments = moments + 1, last_at = CURRENT_TIMESTAMP WHERE id = ?', [threadId]);
-- db.run(`UPDATE arena_topics SET hits = hits + 1, ${kind === 'chat' ? 'chat_mentions = chat_mentions + 1' : 'mic_mentions = mic_mentions + 1'}, last_mention_at = MAX(COALESCE(last_mention_at, ''), COALESCE(?, CURRENT_TIMESTAMP)), last_activity_at = CURRENT_TIMESTAMP WHERE id = ?`, [said, topicId]);
-- if (kind === 'speech' && userId && arena().loadRoster().byId[userId]) {
-- const m = db.get('SELECT 1 FROM arena_topic_members WHERE topic_id = ? AND user_id = ?', [topicId, userId]);
-- if (!m) { db.run('INSERT INTO arena_topic_members (topic_id, user_id, active) VALUES (?, ?, 1)', [topicId, userId]); db.run('UPDATE arena_topics SET joins = joins + 1 WHERE id = ?', [topicId]); addXp(userId, XP_JOIN, 'topic_joined', topicId, { joined: true }); }
-- }
-- const m = db.get('SELECT * FROM arena_topic_moments ORDER BY id DESC LIMIT 1');
-- // Yapper XP for chat lines (seeded backfills count too — they are real lines people typed).
-- if (kind === 'chat' && chatterKey) { try { chatters.onMoment(m, t, { hot: (t.heat || 0) >= HOT_THRESHOLD }); } catch (e) { console.warn('[Arena] yapper xp:', e.message); } }
-- return m;
--}
--
--/**
-- * Is this (subject text, keywords) really an existing open subject? Same story = fold in as a thread.
-- * • shares a distinctive keyword that is a roster name (a subject about goosely already exists)
-- * • shares ≥ 2 keywords, or ≥ 1 multi-word keyword
-- * • significant words of the subject text overlap ≥ 50 % (jaccard) with an open subject's
-- */
--function mergeCandidate(text, keywords, { exclude = null } = {}) {
-- const kws = normalizeKeywords(keywords || []);
-- const words = new Set(keywordsFromText(text));
-- // Roster people named in the text/keywords — a subject that already stars them is the same story.
-- const namedVariants = new Set();
-- try {
-- const roster = arena().loadRoster(); const nm = require('./names');
-- const entries = nm.rosterEntries(roster);
-- const hay = `${text} ${kws.join(' ')}`;
-- for (const h of nm.findMentions(hay, entries, { fuzzy: false })) { for (const v of nm.variants(roster.byId[h.userId].user.username)) namedVariants.add(v); const first = nm.splitHandle(roster.byId[h.userId].user.username)[0]; if (first) namedVariants.add(first); }
-- } catch { /* */ }
-- for (const m of openTopicMatchers()) {
-- if (exclude && m.id === exclude) continue;
-- const shared = m.keywords.filter(k => kws.includes(k));
-- const star = m.keywords.find(k => namedVariants.has(k));
-- if (star) return { id: m.id, why: `about ${star}` };
-- if (shared.length >= 2 || shared.some(k => k.includes(' '))) return { id: m.id, why: `shares ${shared.join(', ')}` };
-- const t = db.get('SELECT text FROM arena_topics WHERE id = ?', [m.id]);
-- const other = new Set(keywordsFromText(t?.text || ''));
-- const inter = [...words].filter(w => other.has(w)).length;
-- const union = new Set([...words, ...other]).size;
-- if (words.size && other.size && inter / union >= 0.5) return { id: m.id, why: 'same words' };
-- }
-- return null;
--}
--/** Fold a would-be subject into an existing one as a thread (its keywords widen the subject's). */
--function foldInto(existingId, { subject, keywords, hint, threads }) {
-- const list = [{ name: subject, keywords: keywords || [], hint: hint || null }, ...(Array.isArray(threads) ? threads : [])];
-- // skip threads that are really another open subject
-- const keep = list.filter(t => { const mc = mergeCandidate(t.name, t.keywords, { exclude: existingId }); return !mc; });
-- const added = upsertThreads(existingId, keep);
-- const merged = normalizeKeywords([...(parseJson(db.get('SELECT keywords_json FROM arena_topics WHERE id = ?', [existingId])?.keywords_json, []) || []), ...normalizeKeywords(keywords || []), ...keep.flatMap(t => normalizeKeywords(t.keywords || []))]);
-- db.run('UPDATE arena_topics SET keywords_json = ?, last_activity_at = CURRENT_TIMESTAMP WHERE id = ?', [JSON.stringify(merged), existingId]);
-- invalidateMatchers();
-- return { id: existingId, threads_added: added };
--}
--
--/** Which thread of a subject a line belongs to (first thread whose keywords match), or null. */
--let _threadCache = { at: 0, byTopic: new Map() };
--function threadsOf(topicId) {
-- if (Date.now() - _threadCache.at > 20 * 1000) { _threadCache = { at: Date.now(), byTopic: new Map() }; }
-- if (!_threadCache.byTopic.has(topicId)) _threadCache.byTopic.set(topicId, db.all('SELECT * FROM arena_topic_threads WHERE topic_id = ? ORDER BY id ASC', [topicId]).map(t => ({ ...t, res: normalizeKeywords(parseJson(t.keywords_json, [])).map(keywordRegex) })));
-- return _threadCache.byTopic.get(topicId);
--}
--function threadFor(topicId, text) { for (const t of threadsOf(topicId)) if (t.res.some(re => re.test(text))) return t.id; return null; }
--function upsertThreads(topicId, threads) {
-- const existing = db.all('SELECT id, name, keywords_json FROM arena_topic_threads WHERE topic_id = ?', [topicId]);
-- let added = 0;
-- for (const t of (Array.isArray(threads) ? threads : []).slice(0, 8)) {
-- const name = clip(t?.name, 40); if (!name) continue;
-- const kws = normalizeKeywords(t?.keywords || []); if (!kws.length) kws.push(...keywordsFromText(name));
-- const same = existing.find(e => e.name.toLowerCase() === name.toLowerCase() || normalizeKeywords(parseJson(e.keywords_json, [])).some(k => kws.includes(k)));
-- if (same) { const merged = normalizeKeywords([...(parseJson(same.keywords_json, []) || []), ...kws]); db.run('UPDATE arena_topic_threads SET keywords_json = ?, hint = COALESCE(?, hint) WHERE id = ?', [JSON.stringify(merged), t?.hint ? clip(t.hint, 90) : null, same.id]); continue; }
-- if (existing.length + added >= 6) continue;
-- db.run('INSERT INTO arena_topic_threads (topic_id, name, hint, keywords_json) VALUES (?, ?, ?, ?)', [topicId, name, t?.hint ? clip(t.hint, 90) : null, JSON.stringify(kws)]); added++;
-- }
-- if (added) { _threadCache.at = 0; }
-- // Re-file moments that have no thread yet.
-- for (const m of db.all('SELECT id, text FROM arena_topic_moments WHERE topic_id = ? AND thread_id IS NULL ORDER BY id DESC LIMIT 200', [topicId])) { const tid = threadFor(topicId, m.text); if (tid) { db.run('UPDATE arena_topic_moments SET thread_id = ? WHERE id = ?', [tid, m.id]); db.run('UPDATE arena_topic_threads SET moments = moments + 1 WHERE id = ?', [tid]); } }
-- return added;
--}
--
--/** Raw on-mic mention (no judge): one per stream per topic per cooldown. */
--function noteMicMention(topicId, { userId, username, streamId, vodId, sec, text }) {
-- const recent = db.get(`SELECT id FROM arena_topic_moments WHERE topic_id = ? AND stream_id = ? AND kind = 'speech' AND created_at >= datetime('now', ?)`, [topicId, streamId, `-${MENTION_COOLDOWN_SEC} seconds`]);
-- if (recent) return null;
-- return addMoment(topicId, { kind: 'speech', source: 'mention', userId, username, streamId, vodId, sec, text });
--}
--
--async function joinTopic(topicId, userId) {
-- ensureTables();
-- const topic = db.get(`SELECT * FROM arena_topics WHERE id = ? AND status = 'open'`, [topicId]);
-- if (!topic) throw new Error('That topic is gone');
-- if (!arena().loadRoster().byId[userId]) throw new Error('Only fighters on the roster can talk on a topic — stream once and come back');
-- db.run('UPDATE arena_topic_members SET active = 0 WHERE user_id = ? AND active = 1', [userId]);
-- const existing = db.get('SELECT * FROM arena_topic_members WHERE topic_id = ? AND user_id = ?', [topicId, userId]);
-- if (existing) db.run('UPDATE arena_topic_members SET active = 1 WHERE topic_id = ? AND user_id = ?', [topicId, userId]);
-- else { db.run('INSERT INTO arena_topic_members (topic_id, user_id) VALUES (?, ?)', [topicId, userId]); db.run('UPDATE arena_topics SET joins = joins + 1 WHERE id = ?', [topicId]); addXp(userId, XP_JOIN, 'topic_joined', topicId, { joined: true }); }
-- db.run('UPDATE arena_topics SET last_activity_at = CURRENT_TIMESTAMP WHERE id = ?', [topicId]);
-- return { topic };
--}
--function leaveTopic(userId) { ensureTables(); db.run('UPDATE arena_topic_members SET active = 0 WHERE user_id = ? AND active = 1', [userId]); }
--function activeTopicFor(userId) {
-- ensureTables();
-- return db.get(`SELECT t.* FROM arena_topic_members m JOIN arena_topics t ON t.id = m.topic_id WHERE m.user_id = ? AND m.active = 1 AND t.status = 'open' ORDER BY m.joined_at DESC LIMIT 1`, [userId]) || null;
--}
--
--/** A judged on-mic chunk about a topic: { on_topic, quality, best_line, about, flagged } → judged moment + XP. */
--const MIN_JUDGED_QUALITY = 4;
--function applyTopicJudgement(userId, topic, judgement, lineRef) {
-- if (judgement.flagged || !judgement.on_topic) return { applied: false };
-- const quality = Math.max(0, Math.min(10, Number(judgement.quality) || 0));
-- if (quality < MIN_JUDGED_QUALITY) return { applied: false, reason: 'too weak' };
-- const user = db.getUserById(userId);
-- const m = addMoment(topic.id, { kind: 'speech', source: 'judge', userId, username: user?.username || null, streamId: lineRef?.stream_id || null, vodId: lineRef?.vod_id || null, sec: lineRef?.sec ?? null, text: judgement.best_line || judgement.about || 'talked on it', quality, about: judgement.about || null });
-- if (!m) return { applied: false };
-- const xp = Math.round(quality * 0.8);
-- const lvl = addXp(userId, xp, 'topic_hit', topic.id, { moment: true, line: judgement.best_line, lineScore: quality, lineVodId: lineRef?.vod_id, lineSec: lineRef?.sec });
-- return { applied: true, quality, xp, level: lvl, moment_id: m.id };
--}
--
--function hypeTopic(topicId, userId, voterKey) {
-- ensureTables();
-- if (voterKey === `user:${userId}`) throw new Error("You can't hype yourself");
-- const ins = db.run('INSERT OR IGNORE INTO arena_topic_hype (topic_id, user_id, voter_key) VALUES (?, ?, ?)', [topicId, userId, voterKey]);
-- if (ins.changes) { addXp(userId, XP_HYPE, 'hype', topicId); db.run('UPDATE arena_topics SET last_activity_at = CURRENT_TIMESTAMP WHERE id = ?', [topicId]); try { require('./chatters').onHype(voterKey, topicId); } catch { /* */ } }
-- const n = db.get('SELECT COUNT(*) AS n FROM arena_topic_hype WHERE topic_id = ? AND user_id = ?', [topicId, userId])?.n || 0;
-- return { added: !!ins.changes, hypers: n };
--}
--
--/** Heat = the last hour: on-mic moments ×3, chat moments ×1, hype, fighters talking ×4. */
--function computeHeat(topicId) {
-- const m = db.get(`SELECT SUM(kind = 'speech') AS mic, SUM(kind = 'chat') AS chat FROM arena_topic_moments WHERE topic_id = ? AND created_at >= datetime('now', '-60 minutes')`, [topicId]) || {};
-- const hype = db.get(`SELECT COUNT(*) AS n FROM arena_topic_hype WHERE topic_id = ? AND created_at >= datetime('now', '-60 minutes')`, [topicId])?.n || 0;
-- const talking = db.get(`SELECT COUNT(*) AS n FROM arena_topic_members WHERE topic_id = ? AND active = 1`, [topicId])?.n || 0;
-- const heat = (m.mic || 0) * 3 + (m.chat || 0) + hype + talking * 4;
-- const prev = db.get('SELECT heat, creator_user_id, headline, text FROM arena_topics WHERE id = ?', [topicId]);
-- db.run('UPDATE arena_topics SET heat = ?, peak_heat = MAX(COALESCE(peak_heat, 0), ?) WHERE id = ?', [heat, heat, topicId]);
-- if (prev && (prev.heat || 0) < HOT_THRESHOLD && heat >= HOT_THRESHOLD) {
-- try { const n = require('./notify'); const who = new Set([prev.creator_user_id, ...db.all('SELECT user_id FROM arena_topic_members WHERE topic_id = ?', [topicId]).map(r => r.user_id), ...db.all(`SELECT user_id FROM arena_topic_moments WHERE topic_id = ? AND user_id IS NOT NULL GROUP BY user_id`, [topicId]).map(r => r.user_id)].filter(Boolean)); for (const uid of who) n.arenaNotify(uid, { type: 'hot', title: `“${String(prev.headline || prev.text).slice(0, 60)}” is HOT`, message: `Your subject is the loudest thing on the board right now (${heat} heat).`, icon: '🔥', url: `/arena/topic/${topicId}`, key: `hot:${topicId}` }); } catch { /* */ }
-- }
-- return heat;
--}
--
--// ── Bounties ─────────────────────────────────────────────────
--
--function openBountyOn(targetUserId) {
-- ensureTables();
-- return db.get(`SELECT * FROM arena_topics WHERE status = 'open' AND kind = 'bounty' AND target_user_id = ?`, [targetUserId]) || null;
--}
--function recordBountyHit(hitterId, bounty, quality, line = null) {
-- addXp(hitterId, Math.round(quality), 'bounty_hit', bounty.id);
-- const u = db.getUserById(hitterId);
-- if (line) addMoment(bounty.id, { kind: 'speech', source: 'judge', userId: hitterId, username: u?.username || null, text: line, quality });
-- else db.run('UPDATE arena_topics SET hits = hits + 1, last_activity_at = CURRENT_TIMESTAMP WHERE id = ?', [bounty.id]);
--}
--
--/** Bounties settle when they expire; topics just cool off into the archive (their lore stays). */
--function resolveExpired() {
-- ensureTables();
-- const results = [];
-- for (const t of db.all(`SELECT * FROM arena_topics WHERE status = 'open' AND kind = 'bounty' AND expires_at IS NOT NULL AND expires_at <= datetime('now')`)) {
-- const top = db.get(`SELECT user_id, SUM(amount) AS s FROM arena_xp_log WHERE reason = 'bounty_hit' AND ref_id = ? GROUP BY user_id ORDER BY s DESC LIMIT 1`, [t.id]);
-- const resolved = { kind: 'bounty', claimed_by: top?.user_id || null, total: top?.s || 0, headline: top?.user_id ? `${nameOf(top.user_id)} collected the bounty on ${nameOf(t.target_user_id)}` : `Nobody came for ${nameOf(t.target_user_id)}. Cowards.` };
-- if (top?.user_id) addXp(top.user_id, 40, 'bounty_claimed', t.id);
-- db.run(`UPDATE arena_topics SET status = 'resolved', resolved_json = ? WHERE id = ?`, [JSON.stringify(resolved), t.id]);
-- db.run('UPDATE arena_topic_members SET active = 0 WHERE topic_id = ?', [t.id]);
-- results.push({ id: t.id, ...resolved });
-- console.log(`[Arena] bounty #${t.id} resolved: ${resolved.headline}`);
-- }
-- if (results.length) invalidateMatchers();
-- return results;
--}
--
--function archiveStale(force = false) {
-- ensureTables();
-- const r = db.run(`UPDATE arena_topics SET status = 'archived' WHERE status = 'open' AND kind = 'topic' AND last_activity_at < datetime('now', ?)`, [`-${TOPIC_TTL_HOURS} hours`]);
-- if (force) {
-- const extra = db.all(`SELECT id FROM arena_topics WHERE status = 'open' ORDER BY heat ASC, last_activity_at ASC`).slice(0, Math.max(0, (db.get(`SELECT COUNT(*) AS n FROM arena_topics WHERE status = 'open'`)?.n || 0) - MAX_OPEN_TOPICS + 1));
-- for (const row of extra) db.run(`UPDATE arena_topics SET status = 'archived' WHERE id = ?`, [row.id]);
-- }
-- if (r.changes || force) { db.run(`UPDATE arena_topic_members SET active = 0 WHERE topic_id IN (SELECT id FROM arena_topics WHERE status != 'open')`); invalidateMatchers(); }
--}
--
--function nameOf(userId) {
-- try { return fighterBrief(userId, arena().loadRoster()).fighter_name; } catch { const u = db.getUserById(userId); return u ? (u.display_name || u.username) : `user${userId}`; }
--}
--
--// ── Bots are not chatters ────────────────────────────────────
--// AI viewers mark their rows with source_platform 'ai'; channel AI bots post as real accounts, so
--// their usernames come from channel_ai_bots / ai_chatbot_configs (cached a minute).
--let _bots = { at: 0, names: new Set(), ids: new Set() };
--function botSet() {
-- if (Date.now() - _bots.at < 60 * 1000) return _bots;
-- const names = new Set(), ids = new Set();
-- for (const t of ['channel_ai_bots', 'ai_chatbot_configs']) {
-- try { for (const r of db.all(`SELECT * FROM ${t}`)) { for (const k of ['username', 'bot_username', 'name', 'bot_name', 'display_name']) if (r[k]) names.add(String(r[k]).toLowerCase()); for (const k of ['bot_user_id', 'user_id']) if (r[k] && k === 'bot_user_id') ids.add(Number(r[k])); } } catch { /* table shape varies */ }
-- }
-- // Admin list for bots that post from real accounts (site setting, comma-separated usernames).
-- try { for (const n of String(db.getSetting('arena_bot_usernames') || '').split(/[,\s]+/)) if (n) names.add(n.toLowerCase()); } catch { /* */ }
-- _bots = { at: Date.now(), names, ids };
-- return _bots;
--}
--function isBotChatter(row) {
-- if (!row) return false;
-- if (String(row.source_platform || '') === 'ai') return true;
-- const b = botSet();
-- if (row.user_id && b.ids.has(Number(row.user_id))) return true;
-- return !!row.username && b.names.has(String(row.username).toLowerCase());
--}
--
--// ── Discovery: what is the community actually talking about? ─
--
--let _scan = { lastChatId: null, lastDiscoverAt: 0, lastDiscoverSeen: 0 };
--
--/** Every minute: new chat messages → moments on matching topics. */
--function scanChat() {
-- ensureTables();
-- if (_scan.lastChatId == null) { _scan.lastChatId = db.get('SELECT COALESCE(MAX(id), 0) AS id FROM chat_messages')?.id || 0; return { scanned: 0, moments: 0 }; }
-- const rows = db.all(`SELECT id, stream_id, user_id, username, message, source_platform, anon_id, timestamp FROM chat_messages WHERE id > ? AND COALESCE(is_deleted, 0) = 0 AND message IS NOT NULL AND COALESCE(message_type, 'chat') = 'chat' AND COALESCE(source_platform, '') != 'ai' AND LENGTH(message) BETWEEN 6 AND 300 ORDER BY id ASC LIMIT 400`, [_scan.lastChatId]);
-- if (!rows.length) return { scanned: 0, moments: 0 };
-- _scan.lastChatId = rows[rows.length - 1].id;
-- let moments = 0;
-- for (const r of rows) {
-- if (/^!/.test(r.message)) continue;
-- if (isBotChatter(r)) continue;
-- for (const t of matchTopics(r.message)) { if (addMoment(t.id, { kind: 'chat', source: 'chat', userId: r.user_id, username: r.username, streamId: r.stream_id, text: r.message, platform: r.source_platform || null, anonId: r.anon_id || null, saidAt: r.timestamp })) moments++; }
-- }
-- return { scanned: rows.length, moments };
--}
--
--const DISCOVER_SCHEMA = { name: 'arena_discover', schema: { type: 'object', additionalProperties: false, required: ['pulse', 'topics', 'bounties'], properties: {
-- pulse: { type: 'string', description: 'One sentence ≤ 140 chars: what the community is on about right now, dumb hype-caster voice, name names, funny' },
-- topics: { type: 'array', minItems: 0, maxItems: 4, items: { type: 'object', additionalProperties: false, required: ['merge_into_id', 'subject', 'headline', 'tagline', 'keywords', 'threads', 'source', 'hint'], properties: {
-- merge_into_id: { type: 'integer', description: 'if this is really one of the subjects already on the board (same story, same people, same drama), that subject\'s id — its threads get added there instead of making a duplicate. 0 for a genuinely new subject.' },
-- subject: { type: 'string', description: 'The UMBRELLA subject, 1–4 words, a noun: the thing, person, group, joke or drama — e.g. "Cat enema drama", "Pakistanis", "Goosely", "Tent streams". Vague on purpose; the detail lives in threads.' },
-- headline: { type: 'string', description: 'Tabloid headline ≤ 70 chars, inflammatory, stupid-funny' },
-- tagline: { type: 'string', description: '≤ 80 chars: where it stands — who started it, who is on which end' },
-- keywords: { type: 'array', minItems: 2, maxItems: 6, items: { type: 'string' }, description: 'lowercase words/short phrases people use for the umbrella subject (slang, misspellings)' },
-- threads: { type: 'array', minItems: 2, maxItems: 4, items: { type: 'object', additionalProperties: false, required: ['name', 'keywords', 'hint'], properties: { name: { type: 'string', description: 'a sub-angle inside the subject, 2–5 words ("the vet bill", "the bot roasts", "global vs local")' }, keywords: { type: 'array', minItems: 1, maxItems: 4, items: { type: 'string' } }, hint: { type: 'string', description: '≤ 70 chars, how to pile on this thread' } } }, description: 'the 2–4 threads people are actually arguing inside the subject' },
-- source: { type: 'string', description: '≤ 6 words: where it came from ("global chat", "Goosely\'s stream")' },
-- hint: { type: 'string', description: '≤ 70 chars, how to pile on' },
-- } } },
-- bounties: { type: 'array', minItems: 0, maxItems: 1, items: { type: 'object', additionalProperties: false, required: ['target_username', 'headline', 'why'], properties: { target_username: { type: 'string', description: 'exact username from the snapshot the community keeps naming/dunking on' }, headline: { type: 'string' }, why: { type: 'string', description: '≤ 90 chars' } } } },
--} } };
--const DISCOVER_SYSTEM = `You are the community consciousness of a live-streaming site's Arena — a regular who has been in this chat for years, not a copywriter. VOICE RULES: write the way the people in the sample lines type — same slang, same casing (if chat is lowercase, you are lowercase), short, blunt, funny. No tabloid adjective soup, no invented compound words (no "roastmare", no "chaos escalates"), no exclamation marks for hype, no "epic"/"wild"/"erupts"/"explodes". A headline should read like something a chatter would actually type as a title ("goosely is losing the cat enema war", "nobody asked about the vet bill"), ≤ 60 chars. Use people's names exactly as they appear. You get what people ACTUALLY said in the last while: global + stream chat lines and what streamers said on mic, plus the subjects already on the board. Find the UMBRELLA SUBJECTS the community is really on about — a group of people, a person, a running joke, an argument, a product, a drama, a meme — the more provocative, taboo, petty or stupid the better; nothing generic, nothing wholesome. Keep subjects VAGUE and short (1–4 words) and put the detail into 2–4 THREADS inside each (the specific angles people are arguing). Ignore small talk. ONE story = ONE subject: if what people are saying is the same drama / the same people as a subject already on the board (listed with ids), set merge_into_id to that id and give the NEW threads only — never create a second subject about the same thing ("goosely drama" when "cat enema drama" already stars goosely is a duplicate). Never make a subject about the Arena itself — not the board, a bounty, a beef, yap levels or XP ("goosely bounty" is the bounty, not a subject). For each: the subject, a tabloid headline, a tagline (who started it, who is where on it), the keywords/slang people use for it, the threads, the source, and a hint on how to pile on. If the community keeps naming one specific streamer (dunking, defending, calling out), add ONE bounty on that exact username. Profanity, disrespect, slurs and trolling are welcome and are never a reason to skip a subject — this platform does not censor language. Hard line: no direct threats of violence, nothing sexual about minors, no doxxing. Return empty arrays if nothing real is going on. Output only the JSON.`;
--
--function discoverInput() {
-- const out = { at: new Date().toISOString() };
-- try { out.chat = db.all(`SELECT c.username, c.message, c.stream_id, c.user_id, c.source_platform FROM chat_messages c WHERE COALESCE(c.is_deleted, 0) = 0 AND COALESCE(c.source_platform, '') != 'ai' AND COALESCE(c.message_type, 'chat') = 'chat' AND c.timestamp >= datetime('now', ?) AND LENGTH(c.message) BETWEEN 8 AND 200 AND c.message NOT LIKE '!%' ORDER BY c.id DESC LIMIT 80`, [`-${scanWindowMin()} minutes`]).reverse().filter(r => !isBotChatter(r) && !arena()._isBannedText(r.message)).map(r => clip(`[${r.stream_id ? 'stream' : 'global'}] ${r.username}: ${r.message}`, 180)); } catch { out.chat = []; }
-- try { out.on_mic = db.all(`SELECT u.username, e.text FROM stream_timeline_events e JOIN users u ON u.id = e.user_id WHERE e.kind = 'speech' AND e.created_at >= datetime('now', ?) AND LENGTH(e.text) BETWEEN 25 AND 220 ORDER BY e.id DESC LIMIT 60`, [`-${scanWindowMin()} minutes`]).reverse().filter(r => !arena()._isBannedText(r.text)).map(r => clip(`${r.username}: ${r.text}`, 200)); } catch { out.on_mic = []; }
-- try { out.chat_timeline = db.all(`SELECT label, detail FROM chat_timeline_events WHERE created_at >= datetime('now', '-6 hours') ORDER BY id DESC LIMIT 8`).map(r => clip(`${r.label}: ${r.detail || ''}`, 140)); } catch { out.chat_timeline = []; }
-- try { const g = db.getChatAiSummary('global', 0, 'rolling'); if (g) { const ov = parseJson(g.overview, null); out.global_chat_overview = clip(ov ? (ov.today || ov.alltime) : g.overview, 500); } } catch { /* */ }
-- try { const roster = arena().loadRoster(); out.roster_usernames = roster.order.map(id => roster.byId[id].user.username); } catch { out.roster_usernames = []; }
-- out.already_on_board = db.all(`SELECT id, text, headline, keywords_json FROM arena_topics WHERE status = 'open' ORDER BY id DESC LIMIT 20`).map(r => ({ id: r.id, subject: r.text, headline: r.headline, keywords: parseJson(r.keywords_json, []) || [], threads: db.all('SELECT name FROM arena_topic_threads WHERE topic_id = ?', [r.id]).map(x => x.name) }));
-- return out;
--}
--
--/** No AI: a word (≥ 4 letters, not a stopword) said by ≥ 3 people ≥ 4 times in the window becomes a subject. */
--function heuristicDiscover(input) {
-- const counts = new Map();
-- for (const l of [...(input.chat || []), ...(input.on_mic || [])]) {
-- const body = l.replace(/^\[[^\]]*\]\s*/, '');
-- const who = body.split(':')[0];
-- const seen = new Set();
-- for (const w of keywordsFromText(body.replace(/^[^:]+:\s*/, ''))) { if (seen.has(w)) continue; seen.add(w); const c = counts.get(w) || { n: 0, people: new Set() }; c.n++; c.people.add(who); counts.set(w, c); }
-- }
-- const existing = new Set(openTopicMatchers().flatMap(m => m.keywords));
-- return [...counts.entries()].filter(([w, c]) => c.n >= 4 && c.people.size >= 3 && !existing.has(w)).sort((a, b) => b[1].n - a[1].n).slice(0, 2)
-- .map(([w, c]) => ({ subject: w.charAt(0).toUpperCase() + w.slice(1), headline: `Everyone is suddenly on about "${w}"`, tagline: `${c.people.size} people, ${c.n} mentions in ${SCAN_WINDOW_MIN} minutes`, keywords: [w], threads: [{ name: `why ${w}`, keywords: [w], hint: 'Say it on mic. Make it worse.' }], source: 'chat burst', hint: 'Say it on mic. Make it worse.' }));
--}
--
--let _pulse = { text: null, at: null, sources: [] };
--function loadPulse() { if (_pulse.at) return _pulse; try { const p = JSON.parse(db.getState('arena_pulse') || 'null'); if (p && p.text) _pulse = p; } catch { /* */ } return _pulse; }
--function savePulse() { try { db.setState('arena_pulse', JSON.stringify(_pulse)); } catch { /* */ } }
--/** Every few minutes, only when there is new material: turn what was said into new subjects. */
--async function discoverTopics({ force = false } = {}) {
-- ensureTables();
-- archiveStale();
-- if (!force && Date.now() - _scan.lastDiscoverAt < DISCOVER_INTERVAL_MS) return { skipped: true };
-- const input = discoverInput();
-- const seen = input.chat.length + input.on_mic.length;
-- if (!force && (seen < DISCOVER_MIN_NEW_LINES || seen === _scan.lastDiscoverSeen)) { _scan.lastDiscoverAt = Date.now(); return { skipped: true, quiet: true }; }
-- _scan.lastDiscoverAt = Date.now(); _scan.lastDiscoverSeen = seen;
-- if (!seen) return { made: 0, quiet: true };
-- let topics = [], bounties = [];
-- if (aiOn()) {
-- try {
-- const r = await llm.complete({ role: 'summary', kind: 'arena_discover', source: 'arena', system: DISCOVER_SYSTEM, user: JSON.stringify(input), json: DISCOVER_SCHEMA, maxTokens: 700, temperature: 0.9, timeoutMs: 30000 });
-- if (!r || !r.json) console.warn('[Arena] discover: model returned no usable JSON', r && r.text ? String(r.text).slice(0, 200) : '');
-- if (r && r.json) { topics = r.json.topics || []; bounties = r.json.bounties || []; if (r.json.pulse) { _pulse = { text: clip(r.json.pulse, 200), at: Date.now(), sources: ['chat', 'on_mic'].filter(k => input[k].length) }; savePulse(); } }
-- } catch (e) { console.warn('[Arena] discover:', e.message); }
-- } else {
-- topics = heuristicDiscover(input);
-- }
-- let made = 0, folded = 0;
-- const created = [];
-- for (const t of topics.slice(0, 4)) {
-- try {
-- if (/\b(bounty|bounties|beef|beefs|arena|yap level|yappers?|trash level|the board)\b/i.test(`${t.subject} ${(t.keywords || []).join(' ')}`)) { console.log(`[Arena] skipped meta subject "${t.subject}"`); continue; }
-- const target = (t.merge_into_id && db.get(`SELECT id FROM arena_topics WHERE id = ? AND status = 'open'`, [t.merge_into_id])) ? { id: t.merge_into_id, why: 'model said so' } : mergeCandidate(t.subject, t.keywords);
-- if (target) { const r = foldInto(target.id, t); folded++; console.log(`[Arena] "${t.subject}" folded into subject #${target.id} (${target.why}; +${r.threads_added} thread(s))`); continue; }
-- created.push(createTopic({ text: t.subject, hint: t.hint, createdBy: 'community', creatorName: clip(t.source || 'the community', 40), headline: t.headline, tagline: t.tagline, sourceNote: t.source, keywords: t.keywords, threads: t.threads })); made++;
-- } catch (e) { console.warn('[Arena] discover create:', e.message); }
-- }
-- for (const b of bounties.slice(0, 1)) {
-- try {
-- const u = db.getUserByUsername(String(b.target_username || '').replace(/^@/, ''));
-- if (!u || !arena().loadRoster().byId[u.id]) continue;
-- created.push(createTopic({ text: `Bounty: ${nameOf(u.id)}`, hint: clip(b.why, 90), createdBy: 'community', creatorName: 'the community', kind: 'bounty', targetUserId: u.id, headline: b.headline, tagline: b.why, sourceNote: 'community' })); made++;
-- } catch { /* dup */ }
-- }
-- if (created.length) backfillMoments(created, { windowMin: scanWindowMin() * 4 });
-- if (made || folded) console.log(`[Arena] discovered ${made} subject(s), folded ${folded} into existing ones (last ${scanWindowMin()} min)`);
-- return { made, folded, pulse: _pulse.text };
--}
--
--/** Seed a fresh topic with the lines from the window that match it, so it never starts empty. */
--function backfillMoments(topics, { windowMin = null } = {}) {
-- const win = Math.max(30, Number(windowMin) || scanWindowMin() * 4);
-- const chat = db.all(`SELECT id, stream_id, user_id, username, message, source_platform, anon_id, timestamp FROM chat_messages WHERE COALESCE(is_deleted, 0) = 0 AND COALESCE(message_type, 'chat') = 'chat' AND COALESCE(source_platform, '') != 'ai' AND timestamp >= datetime('now', ?) AND LENGTH(message) BETWEEN 6 AND 300 ORDER BY id DESC LIMIT 600`, [`-${win} minutes`]).reverse();
-- const speech = db.all(`SELECT e.stream_id, e.user_id, e.vod_id, e.start_sec, e.text, e.created_at, u.username FROM stream_timeline_events e LEFT JOIN users u ON u.id = e.user_id WHERE e.kind = 'speech' AND e.created_at >= datetime('now', ?) ORDER BY e.id DESC LIMIT 400`, [`-${win} minutes`]).reverse();
-- let seeded = 0;
-- for (const t of topics) {
-- const kws = normalizeKeywords(parseJson(t.keywords_json, [])).map(keywordRegex);
-- let n = 0;
-- for (const c of chat) if (!isBotChatter(c) && !/^!/.test(c.message) && kws.some(re => re.test(c.message))) { if (addMoment(t.id, { kind: 'chat', source: 'seed', userId: c.user_id, username: c.username, streamId: c.stream_id, text: c.message, platform: c.source_platform || null, anonId: c.anon_id || null, saidAt: c.timestamp })) n++; }
-- for (const s of speech) if (kws.some(re => re.test(s.text || ''))) { if (addMoment(t.id, { kind: 'speech', source: 'seed', userId: s.user_id, username: s.username, streamId: s.stream_id, vodId: s.vod_id, sec: Math.max(0, Math.floor(s.start_sec) - 2), text: s.text, saidAt: s.created_at })) n++; }
-- if (n) console.log(`[Arena] topic #${t.id} seeded with ${n} moment(s) from the last ${win} min`);
-- seeded += n;
-- }
-- return seeded;
--}
--function pulse() { const p = loadPulse(); return { text: p.text, at: p.at ? new Date(p.at).toISOString() : null, sources: p.sources || [] }; }
--
--// ── Lore ─────────────────────────────────────────────────────
--
--const LORE_SCHEMA = { name: 'arena_lore', schema: { type: 'object', additionalProperties: false, required: ['lore', 'headline', 'tagline', 'keywords', 'threads'], properties: {
-- lore: { type: 'string', description: 'SHORT: 2–4 punchy sentences, ≤ 450 characters total. The story so far: who brought it up, the wildest line (quote it verbatim with the username), who is on which end, the latest twist. Tabloid / fight-promoter voice, dumb, petty, funny. No filler, no recap of every line.' },
-- headline: { type: 'string', description: 'Updated tabloid headline ≤ 70 chars' },
-- tagline: { type: 'string', description: '≤ 80 chars: where it stands right now' },
-- keywords: { type: 'array', minItems: 2, maxItems: 10, items: { type: 'string' }, description: 'updated lowercase keywords/slang people use for it (keep the good existing ones)' },
-- threads: { type: 'array', minItems: 1, maxItems: 5, items: { type: 'object', additionalProperties: false, required: ['name', 'keywords', 'hint'], properties: { name: { type: 'string', description: '2–5 words' }, keywords: { type: 'array', minItems: 1, maxItems: 4, items: { type: 'string' } }, hint: { type: 'string', description: '≤ 70 chars' } } }, description: 'the threads (sub-angles) inside this subject — keep the existing ones by name, add new angles that emerged in the new moments' },
--} } };
--const LORE_SYSTEM = `You write and update the LORE of a subject the community of a live-streaming site is talking about — as a regular of this chat, not a copywriter. VOICE RULES: match the way the moments are typed (slang, casing, rhythm; lowercase if chat is lowercase), blunt, dry, funny; quote real lines instead of describing them; use the site's own words; no tabloid adjective soup, no invented compound words, no "erupts"/"explodes"/"chaos"/"saga"/"epic", no exclamation marks. Use what the chat AI knows about the people involved (their known bits, grudges, history) to make it specific to THEM. You get the subject, the previous lore (if any), and the newest moments: chat lines and on-mic lines with usernames. Rewrite the lore SHORT (2–4 sentences, under 450 characters): who started it, the wildest line (verbatim, with username), who is on which end, the latest twist. Drop everything else. Also return the THREADS inside the subject: keep the existing thread names, add a new one only when a genuinely new angle shows up in the new moments. Voice: tabloid meets fight promoter meets the dumbest guy in the group chat; provocative and funny; take the piss out of everyone. This platform does not censor language: slurs, profanity and taboo talk are allowed and should be quoted as said. Hard line: no direct threats of violence, nothing sexual about minors, no doxxing. Output only the JSON.`;
--
--/** What the site already knows: how the community types, the global chat AI overview, and the chat AI's profile of the people in these moments. Cheap (all cached rows), no calls. */
--function communityContext(moments) {
-- const out = {};
-- try { out.how_this_chat_types = db.all(`SELECT message, username, user_id, source_platform FROM chat_messages WHERE COALESCE(is_deleted, 0) = 0 AND COALESCE(source_platform, '') != 'ai' AND COALESCE(message_type, 'chat') = 'chat' AND LENGTH(message) BETWEEN 8 AND 140 AND message NOT LIKE '!%' ORDER BY id DESC LIMIT 40`).filter(r => !isBotChatter(r)).slice(0, 25).map(r => r.message); } catch { out.how_this_chat_types = []; }
-- try { const g = db.getChatAiSummary('global', 0, 'rolling'); if (g) { const ov = parseJson(g.overview, null); out.global_chat_overview = clip(ov ? (ov.today || ov.alltime) : g.overview, 500); out.global_chat_memory = clip(g.memory_json, 400); } } catch { /* */ }
-- try {
-- const chatters = require('./chatters');
-- const seen = new Set(); const people = [];
-- for (const m of moments || []) {
-- const key = m.chatter_key || chatters.keyFor(m); if (!key || seen.has(key) || people.length >= 5) continue; seen.add(key);
-- const p = chatters.parseKey(key); let ai = null;
-- try { if (p.kind === 'user') ai = db.getChatAiSummary('user', p.user_id, 'rolling'); else if (p.kind === 'anon') ai = db.getChatAiSummary('anon', p.anon_num, 'rolling'); else if (p.kind === 'relay' && db.getRelayUser) { const ru = db.getRelayUser(p.platform, p.username); if (ru) ai = db.getChatAiSummary('relay', ru.id, 'rolling'); } } catch { /* */ }
-- if (ai) { const ov = parseJson(ai.overview, null); people.push({ name: m.username, known_for: clip(ov ? (ov.alltime || ov.today) : ai.overview, 260), memory: clip(ai.memory_json, 200) }); }
-- }
-- if (people.length) out.what_the_chat_ai_knows_about_these_people = people;
-- } catch { /* */ }
-- return out;
--}
--
--function templateLore(topic, moments, members) {
-- const first = moments[moments.length - 1];
-- const best = moments.filter(m => m.quality != null).sort((a, b) => b.quality - a.quality)[0] || moments.find(m => m.kind === 'speech') || moments[0];
-- const chatters = [...new Set(moments.filter(m => m.kind === 'chat').map(m => m.username).filter(Boolean))];
-- const parts = [];
-- if (first) parts.push(`It started ${first.kind === 'chat' ? 'in chat' : 'on mic'} when ${first.username || 'someone'} said “${clip(first.text, 120)}”.`);
-- if (chatters.length) parts.push(`${chatters.length === 1 ? chatters[0] : `${chatters.slice(0, 3).join(', ')}${chatters.length > 3 ? ` and ${chatters.length - 3} others` : ''}`} piled on from chat.`);
-- if (members.length) parts.push(`${members.map(m => m.fighter_name).slice(0, 3).join(', ')} took it on stream.`);
-- if (best && best !== first) parts.push(`Best line so far, ${best.username || 'unknown'}: “${clip(best.text, 120)}”.`);
-- if (!parts.length) parts.push('Nobody has said anything about it yet. Be first.');
-- return parts.join(' ');
--}
--
--/** Rewrite a topic's lore from its moments (AI when on, template otherwise). */
--async function buildLore(topicId, { force = false } = {}) {
-- ensureTables();
-- const topic = db.get('SELECT * FROM arena_topics WHERE id = ?', [topicId]);
-- if (!topic) return null;
-- const total = db.get('SELECT COUNT(*) AS n FROM arena_topic_moments WHERE topic_id = ?', [topicId])?.n || 0;
-- const fresh = total - (topic.lore_moment_count || 0);
-- const stale = !topic.lore_updated_at || Date.now() - Date.parse(topic.lore_updated_at + 'Z') > LORE_MIN_INTERVAL_MS;
-- if (!force && (fresh < LORE_MIN_NEW_MOMENTS || (!stale && topic.lore))) return { skipped: true };
-- const roster = arena().loadRoster();
-- const moments = db.all('SELECT * FROM arena_topic_moments WHERE topic_id = ? ORDER BY id DESC LIMIT 30', [topicId]);
-- const members = db.all('SELECT user_id FROM arena_topic_members WHERE topic_id = ?', [topicId]).map(m => fighterBrief(m.user_id, roster));
-- let out = null;
-- if (aiOn() && moments.length) {
-- try {
-- const r = await llm.complete({ role: 'summary', kind: 'arena_lore', source: 'arena', system: LORE_SYSTEM, json: LORE_SCHEMA, maxTokens: 600, temperature: 0.85, timeoutMs: 30000,
-- user: JSON.stringify({ subject: topic.text, kind: topic.kind, bounty_target: topic.target_user_id ? nameOf(topic.target_user_id) : null, started_by: topic.creator_name || topic.created_by, source: topic.source_note, previous_lore: topic.lore, previous_headline: topic.headline, keywords: parseJson(topic.keywords_json, []), existing_threads: threadsOf(topic.id).map(t => t.name), ...communityContext(moments),
-- fighters_on_it: members.map(m => m.fighter_name), moments: moments.slice().reverse().map(m => ({ who: m.username || 'anon', where: m.kind === 'chat' ? 'chat' : 'on mic', text: m.text, quality: m.quality })) }) });
-- if (r && r.json && r.json.lore) out = r.json;
-- } catch (e) { console.warn('[Arena] lore:', e.message); }
-- }
-- if (!out) out = { lore: templateLore(topic, moments, members), headline: topic.headline, tagline: topic.tagline, keywords: parseJson(topic.keywords_json, []) };
-- const kws = normalizeKeywords([...(parseJson(topic.keywords_json, []) || []), ...(out.keywords || [])]);
-- db.run('UPDATE arena_topics SET lore = ?, headline = COALESCE(?, headline), tagline = COALESCE(?, tagline), keywords_json = ?, lore_updated_at = CURRENT_TIMESTAMP, lore_moment_count = ? WHERE id = ?',
-- [clip(out.lore, 600), out.headline ? clip(out.headline, 120) : null, out.tagline ? clip(out.tagline, 100) : null, JSON.stringify(kws), total, topicId]);
-- try { if (Array.isArray(out.threads) && out.threads.length) { upsertThreads(topicId, out.threads.filter(t => !mergeCandidate(t.name, t.keywords, { exclude: topicId }))); const merged = normalizeKeywords([...kws, ...threadsOf(topicId).flatMap(t => parseJson(t.keywords_json, []) || [])]); db.run('UPDATE arena_topics SET keywords_json = ? WHERE id = ?', [JSON.stringify(merged), topicId]); } } catch (e) { console.warn('[Arena] threads:', e.message); }
-- invalidateMatchers();
-- try { require('./chatters').onLore(topic, out.lore, moments); } catch (e) { console.warn('[Arena] lore quotes xp:', e.message); }
-- return { lore: out.lore, headline: out.headline, tagline: out.tagline };
--}
--
--/** Sweep: refresh lore where enough new moments landed (a few per call so the budget stays sane). */
--async function loreSweep(max = 3) {
-- ensureTables();
-- const due = db.all(`SELECT id, lore_moment_count, lore_updated_at, (SELECT COUNT(*) FROM arena_topic_moments m WHERE m.topic_id = t.id) AS n FROM arena_topics t WHERE status = 'open' ORDER BY heat DESC`)
-- .filter(t => (t.n - (t.lore_moment_count || 0)) >= LORE_MIN_NEW_MOMENTS && (!t.lore_updated_at || Date.now() - Date.parse(t.lore_updated_at + 'Z') > LORE_MIN_INTERVAL_MS)).slice(0, max);
-- let n = 0;
-- for (const t of due) { try { const r = await buildLore(t.id); if (r && !r.skipped) n++; } catch (e) { console.warn('[Arena] lore sweep:', e.message); } }
-- return n;
--}
--
--// ── Views ────────────────────────────────────────────────────
--
--function fighterBrief(userId, roster) {
-- const f = roster.byId[userId];
-- 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}`,
-- 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]),
-- level: levelFor(levelRow(userId).xp || 0),
-- };
--}
--function momentView(m) { return { id: m.id, kind: m.kind, source: m.source, user_id: m.user_id, username: m.username, chatter_key: m.chatter_key || null, platform: m.source_platform || null, thread_id: m.thread_id || null, stream_id: m.stream_id, vod_id: m.vod_id, sec: m.sec, text: m.text, quality: m.quality, about: m.about, at: m.said_at || m.created_at, filed_at: m.created_at }; }
--
--function topicView(topic, roster, { detail = false } = {}) {
-- const members = db.all(`SELECT m.*, (SELECT COUNT(*) FROM arena_topic_moments x WHERE x.topic_id = m.topic_id AND x.user_id = m.user_id AND x.kind = 'speech') AS moments, (SELECT COALESCE(SUM(quality), 0) FROM arena_topic_moments x WHERE x.topic_id = m.topic_id AND x.user_id = m.user_id) AS score FROM arena_topic_members m WHERE m.topic_id = ? ORDER BY score DESC, moments DESC`, [topic.id]);
-- const heat = topic.heat || 0;
-- const last = db.get('SELECT * FROM arena_topic_moments WHERE topic_id = ? ORDER BY COALESCE(said_at, created_at) DESC, id DESC LIMIT 1', [topic.id]);
-- const best = db.get('SELECT * FROM arena_topic_moments WHERE topic_id = ? AND quality IS NOT NULL ORDER BY quality DESC, id DESC LIMIT 1', [topic.id]);
-- const out = {
-- id: topic.id, text: topic.text, hint: topic.hint, created_by: topic.created_by, creator_name: topic.creator_name, status: topic.status,
-- kind: topic.kind || 'topic', target: topic.target_user_id ? fighterBrief(topic.target_user_id, roster) : null,
-- headline: topic.headline, tagline: topic.tagline, lore: topic.lore, lore_updated_at: topic.lore_updated_at, keywords: parseJson(topic.keywords_json, []) || [],
-- source_note: topic.source_note, heat: Math.round(heat), hot: heat >= HOT_THRESHOLD,
-- expires_at: topic.expires_at && topic.kind === 'bounty' ? new Date(topic.expires_at + 'Z').toISOString() : null, resolved: parseJson(topic.resolved_json),
-- mentions: { chat: topic.chat_mentions || 0, mic: topic.mic_mentions || 0, total: (topic.chat_mentions || 0) + (topic.mic_mentions || 0) },
-- chatters: db.get(`SELECT COUNT(DISTINCT COALESCE(username, user_id)) AS n FROM arena_topic_moments WHERE topic_id = ? AND kind = 'chat'`, [topic.id])?.n || 0,
-- threads: db.all('SELECT * FROM arena_topic_threads WHERE topic_id = ? ORDER BY moments DESC, id ASC', [topic.id]).map(t => ({ id: t.id, name: t.name, hint: t.hint, moments: t.moments || 0, last_at: t.last_at, keywords: parseJson(t.keywords_json, []) || [] })),
-- talking_now: members.filter(m => m.active).map(m => fighterBrief(m.user_id, roster)),
-- fighters: members.slice(0, 12).map(m => ({ ...fighterBrief(m.user_id, roster), active: !!m.active, moments: m.moments, score: Number((m.score || 0).toFixed(1)) })),
-- last_moment: last ? momentView(last) : null, best_moment: best ? momentView(best) : null,
-- created_at: topic.created_at, last_activity_at: topic.last_activity_at, last_mention_at: topic.last_mention_at,
-- };
-- if (detail) {
-- out.submitted_text = topic.submitted_text || null;
-- out.moments = db.all(`SELECT * FROM arena_topic_moments WHERE topic_id = ? AND (quality IS NULL OR quality >= ${MIN_JUDGED_QUALITY}) ORDER BY COALESCE(said_at, created_at) DESC, id DESC LIMIT 80`, [topic.id]).map(momentView);
-- out.top_chatters = db.all(`SELECT username, chatter_key, COUNT(*) AS n FROM arena_topic_moments WHERE topic_id = ? AND kind = 'chat' AND username IS NOT NULL GROUP BY COALESCE(chatter_key, username) ORDER BY n DESC LIMIT 6`, [topic.id]).map(c => { let lvl = null; try { const r = require('./chatters').row(c.chatter_key); if (r) lvl = { level: r.level, title: require('./chatters').titleFor(r.level) }; } catch { /* */ } return { ...c, ...(lvl || {}) }; });
-- out.best_lines = db.all(`SELECT * FROM arena_topic_moments WHERE topic_id = ? AND quality IS NOT NULL ORDER BY quality DESC, id DESC LIMIT 6`, [topic.id]).map(momentView);
-- for (const f of out.fighters) { const bm = db.get(`SELECT * FROM arena_topic_moments WHERE topic_id = ? AND user_id = ? AND kind = 'speech' ORDER BY COALESCE(quality, -1) DESC, id DESC LIMIT 1`, [topic.id, f.user.id]); f.best = bm ? momentView(bm) : null; }
-- }
-- return out;
--}
--
--function boardView() {
-- ensureTables();
-- archiveStale();
-- resolveExpired();
-- const roster = arena().loadRoster();
-- const topics = db.all(`SELECT * FROM arena_topics WHERE status = 'open' ORDER BY id DESC LIMIT ?`, [MAX_OPEN_TOPICS]);
-- for (const t of topics) t.heat = computeHeat(t.id);
-- topics.sort((x, y) => (y.heat - x.heat) || (Date.parse(y.last_activity_at) - Date.parse(x.last_activity_at)));
-- const archive = db.all(`SELECT * FROM arena_topics WHERE status IN ('archived', 'resolved') AND (lore IS NOT NULL OR hits > 0) ORDER BY id DESC LIMIT 10`).map(t => topicView(t, roster));
-- return { open: topics.map(t => topicView(t, roster)), archive, pulse: pulse(), hot_threshold: HOT_THRESHOLD, cooldown_hours: USER_TOPIC_COOLDOWN_HOURS };
--}
--
--function topicDetail(topicId) {
-- ensureTables();
-- const topic = db.get('SELECT * FROM arena_topics WHERE id = ?', [topicId]);
-- if (!topic) return null;
-- if (topic.status === 'open') topic.heat = computeHeat(topic.id);
-- return topicView(topic, arena().loadRoster(), { detail: true });
--}
--
--function levelsLeaderboard(limit = 10) {
-- ensureTables();
-- const roster = arena().loadRoster();
-- return db.all('SELECT * FROM arena_trash_levels WHERE xp > 0 ORDER BY xp DESC LIMIT ?', [limit]).map(r => ({ ...fighterBrief(r.user_id, roster), xp: r.xp, level: levelFor(r.xp), topic_moments: r.topic_moments || 0, topics_joined: r.topics_joined || 0, beef_hits: r.beef_hits || 0, best_line: r.best_line ? { text: r.best_line, vod_id: r.best_line_vod_id, sec: r.best_line_sec } : null }));
--}
--
--/** Viewers who keep the subjects alive from chat (moments in the last 7 days). */
--function yappersLeaderboard(limit = 10) { return require('./chatters').leaderboard(limit); }
--
--module.exports = {
-- ensureTables, createTopic, submitTopic, assertCanSubmit, joinTopic, leaveTopic, activeTopicFor, threadsOf, threadFor, upsertThreads, isBotChatter, mergeCandidate, foldInto, applyTopicJudgement, hypeTopic, addMoment, noteMicMention, matchTopics, keywordsFromText,
-- addXp, levelRow, levelView, levelFor, recentXp, boardView, topicDetail, levelsLeaderboard, yappersLeaderboard, fighterBrief, archiveStale,
-- scanChat, discoverTopics, discoverInput, heuristicDiscover, backfillMoments, buildLore, loreSweep, templateLore, pulse, nameOf,
-- computeHeat, resolveExpired, openBountyOn, recordBountyHit, HOT_THRESHOLD, KIND_TTL_HOURS, XP_PER_LEVEL, XP_JOIN, XP_HYPE, TOPIC_TTL_HOURS, SCAN_WINDOW_MIN, USER_TOPIC_COOLDOWN_HOURS,
-- _resetScan: () => { _scan = { lastChatId: null, lastDiscoverAt: 0, lastDiscoverSeen: 0 }; invalidateMatchers(); },
--};
ddiff --git a/server/arena/chatters.js b/server/arena/chatters.js
ddeleted file mode 100644
iindex 2f2b3896..00000000
---- a/server/arena/chatters.js
++++ /dev/null
@@@ -1,362 +0,0 @@
--/**
-- * chatters.js — Yapper profiles: XP, levels, titles, streaks and an AI "yap card" for EVERYONE who
-- * chats — signed-in OpenVibe users, anonymous viewers (anon<N>) and chat relayed from other
-- * platforms ([Twitch] bob, [Kick] bob, [RS] bob) — keyed by one polymorphic chatter key:
-- *
-- * user:<users.id> anon:<N> relay:<platform>:<username lowercased>
-- *
-- * XP comes from the board (no extra AI calls): a chat line that lands as a moment on a subject,
-- * more when the subject is HOT, a bonus the first time you touch a subject, starting a subject,
-- * being quoted in a subject's lore, hyping, and a daily streak of showing up. Levels use the same
-- * curve as the game (1 + floor(sqrt(xp / 25))). OpenVibe accounts get site-wide OpenCoins on every
-- * level-up (idempotent per level, through the network wallet); anon/relay chatters get the level,
-- * the title and the card — the coins need an account.
-- *
-- * The yap card (title, blurb, catchphrase, "known for") is written by the AI from the chat AI's
-- * existing profile of the chatter (chat_ai_summaries: user / anon / relay scopes) + their moments —
-- * only for level ≥ CARD_MIN_LEVEL, at most once a day per chatter, a few per housekeeping tick.
-- */
--'use strict';
--
--const db = require('../db/database');
--const llm = require('../ai/llm');
--
--const XP_MOMENT = 3;
--const XP_MOMENT_HOT = 6;
--const XP_FIRST_ON_SUBJECT = 4;
--const XP_SUBJECT_STARTED = 15;
--const XP_QUOTED_IN_LORE = 10;
--const XP_HYPE = 1;
--const XP_STREAK_DAY = 5; // × min(streak, 7)
--const COINS_PER_LEVEL = 10; // OpenCoins on level-up = level × this (accounts only)
--const CARD_MIN_LEVEL = 3;
--const CARD_TTL_MS = 24 * 3600_000;
--const CARD_BATCH = 4;
--
--const TITLES = [[1, 'Lurker'], [2, 'Chatter'], [3, 'Yapper'], [5, 'Instigator'], [7, 'Ragebaiter'], [9, 'Menace'], [12, 'Main Character'], [15, 'Community Consciousness'], [20, 'Final Boss']];
--
--let _ready = false;
--function ensureTables() {
-- if (_ready) return;
-- db.run(`CREATE TABLE IF NOT EXISTS chatter_profiles (
-- key TEXT PRIMARY KEY,
-- kind TEXT NOT NULL, -- 'user' | 'anon' | 'relay'
-- user_id INTEGER,
-- platform TEXT,
-- display_name TEXT,
-- xp INTEGER DEFAULT 0,
-- level INTEGER DEFAULT 1,
-- moments INTEGER DEFAULT 0,
-- subjects INTEGER DEFAULT 0,
-- subjects_started INTEGER DEFAULT 0,
-- quoted INTEGER DEFAULT 0,
-- hypes INTEGER DEFAULT 0,
-- streak INTEGER DEFAULT 0,
-- best_streak INTEGER DEFAULT 0,
-- last_day TEXT,
-- best_line TEXT,
-- best_line_topic_id INTEGER,
-- card_json TEXT,
-- card_at DATETIME,
-- card_xp INTEGER DEFAULT 0,
-- coins_paid_level INTEGER DEFAULT 0,
-- first_seen DATETIME DEFAULT CURRENT_TIMESTAMP,
-- last_seen DATETIME DEFAULT CURRENT_TIMESTAMP
-- )`);
-- db.run(`CREATE TABLE IF NOT EXISTS chatter_xp_log (
-- id INTEGER PRIMARY KEY AUTOINCREMENT,
-- key TEXT NOT NULL,
-- amount INTEGER NOT NULL,
-- reason TEXT NOT NULL,
-- ref_id INTEGER,
-- created_at DATETIME DEFAULT CURRENT_TIMESTAMP
-- )`);
-- db.run('CREATE INDEX IF NOT EXISTS idx_chatter_xp_key ON chatter_xp_log (key, created_at)');
-- db.run(`CREATE TABLE IF NOT EXISTS chatter_subjects (
-- key TEXT NOT NULL,
-- topic_id INTEGER NOT NULL,
-- moments INTEGER DEFAULT 0,
-- first_at DATETIME DEFAULT CURRENT_TIMESTAMP,
-- last_at DATETIME DEFAULT CURRENT_TIMESTAMP,
-- PRIMARY KEY (key, topic_id)
-- )`);
-- _ready = true;
--}
--
--function aiOn() { try { return llm.isEnabled() && llm.withinBudget(); } catch { return false; } }
--function parseJson(t, f = null) { try { return t ? JSON.parse(t) : f; } catch { return f; } }
--function clip(t, n) { return String(t || '').replace(/\s+/g, ' ').trim().slice(0, n); }
--function levelFor(xp) { return 1 + Math.floor(Math.sqrt(Math.max(0, Number(xp) || 0) / 25)); }
--function xpForLevel(level) { return Math.max(0, (level - 1) ** 2 * 25); }
--function titleFor(level) { let t = TITLES[0][1]; for (const [l, name] of TITLES) if (level >= l) t = name; return t; }
--function today() { return new Date().toISOString().slice(0, 10); }
--
--// ── Keys ─────────────────────────────────────────────────────
--
--const RELAY_LABELS = { twitch: 'twitch', kick: 'kick', yt: 'youtube', youtube: 'youtube', rs: 'rs', robotstreamer: 'rs' };
--/** The chatter key for a chat_messages-shaped row / moment: { user_id, username, anon_id, source_platform }. */
--function keyFor(m) {
-- if (!m) return null;
-- if (m.user_id) return `user:${m.user_id}`;
-- const uname = String(m.username || '');
-- const relay = uname.match(/^\[([A-Za-z]+)\]\s+(.+)$/);
-- if (relay && (m.source_platform || RELAY_LABELS[relay[1].toLowerCase()])) return `relay:${(m.source_platform || RELAY_LABELS[relay[1].toLowerCase()] || relay[1]).toLowerCase()}:${relay[2].trim().toLowerCase()}`;
-- const anon = String(m.anon_id || (/^anon\d+$/i.test(uname) ? uname : '')).match(/^anon(\d+)$/i);
-- if (anon) return `anon:${anon[1]}`;
-- if (uname) return `name:${uname.toLowerCase()}`;
-- return null;
--}
--function parseKey(key) {
-- const [kind, ...rest] = String(key || '').split(':');
-- if (kind === 'user') return { kind, user_id: Number(rest[0]) };
-- if (kind === 'anon') return { kind, anon_num: Number(rest[0]), anon_id: `anon${rest[0]}` };
-- if (kind === 'relay') return { kind, platform: rest[0], username: rest.slice(1).join(':') };
-- if (kind === 'name') return { kind, username: rest.join(':') };
-- return null;
--}
--function displayFor(key, fallback = null) {
-- const p = parseKey(key);
-- if (!p) return fallback || 'someone';
-- if (p.kind === 'user') { const u = db.getUserById(p.user_id); return u ? (u.display_name || u.username) : (fallback || `user${p.user_id}`); }
-- if (p.kind === 'anon') return fallback || p.anon_id;
-- if (p.kind === 'relay') return fallback || `[${p.platform}] ${p.username}`;
-- return fallback || p.username;
--}
--
--// ── XP ───────────────────────────────────────────────────────
--
--function row(key) { ensureTables(); return db.get('SELECT * FROM chatter_profiles WHERE key = ?', [key]) || null; }
--function ensureRow(key, { display = null } = {}) {
-- const p = parseKey(key);
-- if (!p) return null;
-- const existing = row(key);
-- if (existing) { if (display && display !== existing.display_name) db.run('UPDATE chatter_profiles SET display_name = ? WHERE key = ?', [clip(display, 60), key]); return existing; }
-- db.run('INSERT OR IGNORE INTO chatter_profiles (key, kind, user_id, platform, display_name) VALUES (?, ?, ?, ?, ?)', [key, p.kind, p.kind === 'user' ? p.user_id : null, p.kind === 'relay' ? p.platform : null, clip(display || displayFor(key), 60)]);
-- return row(key);
--}
--
--/** Add XP; handles level-ups (coins for accounts, an announcement in the room when a streamId is given). */
--function addXp(key, amount, reason, refId = null, { display = null, streamId = null } = {}) {
-- ensureTables();
-- amount = Math.round(Number(amount) || 0);
-- const before = ensureRow(key, { display });
-- if (!before || amount <= 0) return { leveled_up: false, level: before ? before.level : 1, gained: 0 };
-- const xp = (before.xp || 0) + amount;
-- const level = levelFor(xp);
-- db.run('UPDATE chatter_profiles SET xp = ?, level = ?, last_seen = CURRENT_TIMESTAMP WHERE key = ?', [xp, level, key]);
-- db.run('INSERT INTO chatter_xp_log (key, amount, reason, ref_id) VALUES (?, ?, ?, ?)', [key, amount, reason, refId]);
-- const leveled = level > (before.level || 1);
-- if (leveled) onLevelUp(key, before.level || 1, level, { streamId });
-- if (!String(reason).startsWith('ach_')) { try { require('./progress').check(key, { streamId }); } catch (e) { console.warn('[Arena] progress:', e.message); } }
-- return { leveled_up: leveled, level, xp, gained: amount, title: titleFor(level) };
--}
--
--function onLevelUp(key, from, to, { streamId = null } = {}) {
-- const p = parseKey(key);
-- const name = displayFor(key);
-- console.log(`[Arena] yapper ${name} (${key}) → level ${to} ${titleFor(to)}`);
-- try { require('./progress').event(key, 'level', `Yap level ${to} — ${titleFor(to)}`); } catch { /* */ }
-- // Site-wide OpenCoins for OpenVibe accounts — one payout per level, idempotent in the wallet.
-- if (p && p.kind === 'user') {
-- try {
-- const r = row(key);
-- const paidTo = r?.coins_paid_level || 0;
-- let total = 0;
-- for (let l = Math.max(paidTo + 1, from + 1); l <= to; l++) total += l * COINS_PER_LEVEL;
-- if (total > 0) payCoins(key, p.user_id, name, total, to);
-- } catch (e) { console.warn('[Arena] yap coins:', e.message); }
-- }
-- if (p && p.kind === 'user') { try { require('./notify').arenaNotify(p.user_id, { type: 'yap_level', title: `Yap level ${to} — ${titleFor(to)}`, message: `${to * COINS_PER_LEVEL} OpenCoins banked for the level. Keep typing about the board's subjects.`, icon: '⬆️', url: `/arena/chatter/${encodeURIComponent(key)}`, key: `lvl:${to}` }); } catch { /* */ } }
-- // Tell the room.
-- try {
-- const chat = require('../chat/chat-server');
-- const line = `⬆️ ${name} hit Yap Level ${to} — ${titleFor(to)}${p && p.kind === 'user' ? ` (+${to * COINS_PER_LEVEL} OpenCoins)` : ''}. Keep talking. ${(() => { try { const c = require('../config'); return `${String(c.baseUrl || '').replace(/\/$/, '')}/arena/chatter/${encodeURIComponent(key)}`; } catch { return ''; } })()}`;
-- if (streamId && chat.broadcastToStream) chat.broadcastToStream(streamId, { type: 'system', message: line });
-- } catch { /* chat server not up in tests */ }
--}
--
--/** A chat line landed as a moment on a subject. */
--/** Credit level-up coins; only marked paid once the wallet confirms (idempotent per level, so retries are safe). */
--function payCoins(key, userId, name, total, level) {
-- try {
-- const opencoins = require('../monetization/opencoins');
-- return Promise.resolve(opencoins.credit(userId, total, 'arena_yap_level', `live:arena_yap_level:${userId}:${level}`, { level })).then(res => {
-- if (res) { db.run('UPDATE chatter_profiles SET coins_paid_level = ? WHERE key = ?', [level, key]); console.log(`[Arena] ${name}: +${total} OpenCoins for reaching yap level ${level}`); }
-- return !!res;
-- }).catch(e => { console.warn('[Arena] yap coins:', e.message); return false; });
-- } catch (e) { console.warn('[Arena] yap coins:', e.message); return Promise.resolve(false); }
--}
--/** Housekeeping: retry unpaid level-ups (wallet down, account linked later). A few per call. */
--async function settleCoins(max = 5) {
-- ensureTables();
-- let n = 0;
-- for (const r of db.all(`SELECT key, user_id, display_name, level, coins_paid_level FROM chatter_profiles WHERE kind = 'user' AND user_id IS NOT NULL AND level > coins_paid_level ORDER BY last_seen DESC LIMIT ?`, [max])) {
-- let total = 0; for (let l = (r.coins_paid_level || 0) + 1; l <= r.level; l++) total += l * COINS_PER_LEVEL;
-- if (total > 0 && await payCoins(r.key, r.user_id, r.display_name || r.key, total, r.level)) n++;
-- }
-- return n;
--}
--
--function onMoment(moment, topic, { hot = false } = {}) {
-- if (!moment || moment.kind !== 'chat') return null;
-- try { if (require('./board').isBotChatter(moment)) return null; } catch { /* */ }
-- const key = moment.chatter_key || keyFor(moment);
-- if (!key) return null;
-- ensureRow(key, { display: moment.username });
-- const first = !db.get('SELECT 1 FROM chatter_subjects WHERE key = ? AND topic_id = ?', [key, topic.id]);
-- db.run(`INSERT INTO chatter_subjects (key, topic_id, moments) VALUES (?, ?, 1) ON CONFLICT(key, topic_id) DO UPDATE SET moments = moments + 1, last_at = CURRENT_TIMESTAMP`, [key, topic.id]);
-- let xp = hot ? XP_MOMENT_HOT : XP_MOMENT;
-- if (first) xp += XP_FIRST_ON_SUBJECT;
-- xp += streakBonus(key);
-- db.run(`UPDATE chatter_profiles SET moments = moments + 1, subjects = (SELECT COUNT(*) FROM chatter_subjects s WHERE s.key = chatter_profiles.key), best_line = CASE WHEN best_line IS NULL OR LENGTH(?) > LENGTH(best_line) THEN ? ELSE best_line END, best_line_topic_id = CASE WHEN best_line IS NULL OR LENGTH(?) > LENGTH(best_line) THEN ? ELSE best_line_topic_id END WHERE key = ?`,
-- [moment.text, clip(moment.text, 200), moment.text, topic.id, key]);
-- return addXp(key, xp, hot ? 'moment_hot' : (first ? 'moment_first' : 'moment'), topic.id, { display: moment.username, streamId: moment.stream_id || null });
--}
--
--/** Daily streak: the first moment of a day extends the streak (or restarts it) and pays a bonus. */
--function streakBonus(key) {
-- const r = row(key);
-- const d = today();
-- if (!r || r.last_day === d) return 0;
-- const yesterday = new Date(Date.now() - 86400_000).toISOString().slice(0, 10);
-- const streak = r.last_day === yesterday ? (r.streak || 0) + 1 : 1;
-- db.run('UPDATE chatter_profiles SET streak = ?, best_streak = MAX(best_streak, ?), last_day = ? WHERE key = ?', [streak, streak, d, key]);
-- return XP_STREAK_DAY * Math.min(streak, 7);
--}
--
--function onSubjectStarted(key, topicId, { display = null } = {}) {
-- if (!key) return null;
-- db.run('UPDATE chatter_profiles SET subjects_started = subjects_started + 1 WHERE key = ?', [key]);
-- return addXp(key, XP_SUBJECT_STARTED, 'subject_started', topicId, { display });
--}
--function onHype(voterKey, topicId) {
-- // voter keys from the API are user:<id> / anon:<anonId> / ip:<…>; only user/anon map to a chatter.
-- const m = String(voterKey || '').match(/^(user:\d+|anon:\d+)$/);
-- if (!m) return null;
-- db.run('UPDATE chatter_profiles SET hypes = hypes + 1 WHERE key = ?', [m[1]]);
-- return addXp(m[1], XP_HYPE, 'hype', topicId);
--}
--/** After a lore rewrite: every chatter whose name appears in the lore text gets quoted-XP (once per rewrite). */
--function onLore(topic, loreText, moments) {
-- const text = String(loreText || '').toLowerCase();
-- if (!text) return 0;
-- const seen = new Set();
-- let n = 0;
-- for (const m of moments || []) {
-- if (m.kind !== 'chat') continue;
-- const key = m.chatter_key || keyFor(m);
-- if (!key || seen.has(key)) continue;
-- const name = String(m.username || '').replace(/^\[[^\]]+\]\s+/, '').toLowerCase();
-- if (name.length >= 3 && text.includes(name)) { seen.add(key); db.run('UPDATE chatter_profiles SET quoted = quoted + 1 WHERE key = ?', [key]); addXp(key, XP_QUOTED_IN_LORE, 'quoted_in_lore', topic.id, { display: m.username }); n++; try { require('./progress').event(key, 'quoted', `Quoted in “${topic.headline || topic.text}”`, { url: `/arena/topic/${topic.id}` }); } catch { /* */ } const p = parseKey(key); if (p?.kind === 'user') { try { require('./notify').arenaNotify(p.user_id, { type: 'quoted', title: `You made the lore of “${String(topic.headline || topic.text).slice(0, 60)}”`, message: `+${XP_QUOTED_IN_LORE} XP for getting quoted.`, icon: '📜', url: `/arena/topic/${topic.id}`, key: `quoted:${topic.id}` }); } catch { /* */ } } }
-- }
-- return n;
--}
--
--// ── Yap card (AI, cheap and rare) ────────────────────────────
--
--const CARD_SCHEMA = { name: 'arena_yap_card', schema: { type: 'object', additionalProperties: false, required: ['title', 'blurb', 'catchphrase', 'known_for', 'archetype'], properties: {
-- title: { type: 'string', description: 'a ridiculous 2–5 word title for this chatter based on what they actually do in chat' },
-- blurb: { type: 'string', description: '2–3 sentences, tabloid voice, dumb-funny, about who they are in chat: their bits, grudges, subjects, timing' },
-- catchphrase: { type: 'string', description: 'one line in THEIR typing style (casing, punctuation, slang) they would actually send' },
-- known_for: { type: 'array', minItems: 2, maxItems: 4, items: { type: 'string' }, description: '≤ 6 words each' },
-- archetype: { type: 'string', description: 'one of: instigator, hype man, lore keeper, contrarian, one-liner, spammer, moralist, lurker-turned-menace, brand ambassador, glazer' },
--} } };
--const CARD_SYSTEM = `You write the "yap card" for a chatter on a live-streaming site — a trading-card style profile of who they are IN CHAT. You get the chat AI's notes about them (overview, long-term memory, timeline), their best lines that landed on the community board, the subjects they pile onto, and their streak. Be specific and funny; take the piss; this platform does not censor language. Copy their typing style for the catchphrase. Hard line: no direct threats of violence, nothing sexual about minors, no doxxing. Output only the JSON.`;
--
--function chatAiFor(key) {
-- const p = parseKey(key);
-- try {
-- if (p.kind === 'user') return db.getChatAiSummary('user', p.user_id, 'rolling');
-- if (p.kind === 'anon') return db.getChatAiSummary('anon', p.anon_num, 'rolling');
-- if (p.kind === 'relay') { const ru = db.getRelayUser ? db.getRelayUser(p.platform, p.username) : null; return ru ? db.getChatAiSummary('relay', ru.id, 'rolling') : null; }
-- } catch { /* */ }
-- return null;
--}
--
--async function buildCard(key, { force = false } = {}) {
-- const r = row(key);
-- if (!r) return null;
-- if (!force) {
-- if (r.level < CARD_MIN_LEVEL) return { skipped: 'level' };
-- if (r.card_json && r.card_at && Date.now() - Date.parse(r.card_at + 'Z') < CARD_TTL_MS) return { skipped: 'fresh' };
-- if (r.card_json && (r.xp - (r.card_xp || 0)) < 25) return { skipped: 'no change' };
-- }
-- if (!aiOn()) return { skipped: 'ai off' };
-- const ai = chatAiFor(key);
-- const ov = ai ? parseJson(ai.overview, null) : null;
-- const lines = db.all(`SELECT m.text, t.text AS subject FROM arena_topic_moments m JOIN arena_topics t ON t.id = m.topic_id WHERE m.chatter_key = ? AND m.kind = 'chat' ORDER BY m.id DESC LIMIT 12`, [key]);
-- const subjects = db.all('SELECT t.text, s.moments FROM chatter_subjects s JOIN arena_topics t ON t.id = s.topic_id WHERE s.key = ? ORDER BY s.moments DESC LIMIT 6', [key]);
-- let out = null;
-- try {
-- const res = await llm.complete({ role: 'chat', kind: 'arena_yap_card', source: 'arena', ownerUserId: r.user_id || null, system: CARD_SYSTEM, json: CARD_SCHEMA, maxTokens: 320, temperature: 0.95, timeoutMs: 25000,
-- user: JSON.stringify({ name: r.display_name, kind: r.kind, level: r.level, title: titleFor(r.level), streak: r.streak, chat_ai_overview: ov ? clip([ov.alltime, ov.today].filter(Boolean).join(' '), 900) : (ai ? clip(ai.overview, 900) : null), chat_ai_memory: ai ? clip(ai.memory_json, 700) : null, chat_ai_timeline: ai ? (parseJson(ai.timeline_json, []) || []).slice(-8).map(e => clip(typeof e === 'string' ? e : `${e.label || ''}: ${e.detail || ''}`, 140)) : [], best_lines: lines.map(l => `[${l.subject}] ${l.text}`), subjects: subjects.map(s => `${s.text} (${s.moments})`) }) });
-- if (res && res.json && res.json.title) out = res.json;
-- } catch (e) { console.warn('[Arena] yap card:', e.message); }
-- if (!out) return { skipped: 'failed' };
-- db.run('UPDATE chatter_profiles SET card_json = ?, card_at = CURRENT_TIMESTAMP, card_xp = xp WHERE key = ?', [JSON.stringify(out), key]);
-- console.log(`[Arena] yap card for ${r.display_name}: "${out.title}"`);
-- if (r.kind === 'user' && r.user_id) { try { require('./notify').arenaNotify(r.user_id, { type: 'yap_card', title: `Your yap card: “${String(out.title).slice(0, 60)}”`, message: String(out.blurb || '').slice(0, 160), icon: '🃏', url: `/arena/chatter/${encodeURIComponent(key)}`, key: `card:${new Date().toISOString().slice(0, 10)}` }); } catch { /* */ } }
-- return out;
--}
--
--async function cardSweep(max = CARD_BATCH) {
-- ensureTables();
-- if (!aiOn()) return 0;
-- const due = db.all(`SELECT key FROM chatter_profiles WHERE level >= ? AND (card_json IS NULL OR card_at IS NULL OR card_at < datetime('now', '-24 hours')) AND (card_json IS NULL OR xp - card_xp >= 25) ORDER BY xp DESC LIMIT ?`, [CARD_MIN_LEVEL, max]);
-- let n = 0;
-- for (const d of due) { try { const r = await buildCard(d.key); if (r && !r.skipped) n++; } catch { /* */ } }
-- return n;
--}
--
--// ── Views ────────────────────────────────────────────────────
--
--function view(r, { detail = false } = {}) {
-- if (!r) return null;
-- const level = r.level || levelFor(r.xp);
-- const nextXp = xpForLevel(level + 1), curXp = xpForLevel(level);
-- const p = parseKey(r.key) || {};
-- const user = p.kind === 'user' ? db.getUserById(p.user_id) : null;
-- const out = {
-- key: r.key, kind: r.kind, platform: r.platform || null, name: r.display_name || displayFor(r.key),
-- user: user ? { id: user.id, username: user.username, display_name: user.display_name, avatar_url: user.avatar_url, profile_color: user.profile_color } : null,
-- level, title: titleFor(level), xp: r.xp || 0, xp_into_level: (r.xp || 0) - curXp, xp_for_next: nextXp - curXp, next_level_xp: nextXp,
-- moments: r.moments || 0, subjects: r.subjects || 0, subjects_started: r.subjects_started || 0, quoted: r.quoted || 0, hypes: r.hypes || 0,
-- streak: r.last_day === today() || r.last_day === new Date(Date.now() - 86400_000).toISOString().slice(0, 10) ? (r.streak || 0) : 0, best_streak: r.best_streak || 0,
-- best_line: r.best_line ? { text: r.best_line, topic_id: r.best_line_topic_id } : null,
-- card: parseJson(r.card_json, null), card_at: r.card_at, coins: p.kind === 'user', first_seen: r.first_seen, last_seen: r.last_seen,
-- titles: TITLES.map(([l, t]) => ({ level: l, title: t, xp: xpForLevel(l) })),
-- };
-- if (detail) {
-- out.recent_moments = db.all(`SELECT m.*, t.text AS subject, t.headline AS subject_headline FROM arena_topic_moments m JOIN arena_topics t ON t.id = m.topic_id WHERE m.chatter_key = ? ORDER BY m.id DESC LIMIT 40`, [r.key]).map(m => ({ id: m.id, topic_id: m.topic_id, subject: m.subject, subject_headline: m.subject_headline, text: m.text, at: m.created_at, stream_id: m.stream_id }));
-- out.top_subjects = db.all('SELECT t.id, t.text, t.headline, t.status, s.moments FROM chatter_subjects s JOIN arena_topics t ON t.id = s.topic_id WHERE s.key = ? ORDER BY s.moments DESC LIMIT 8', [r.key]);
-- out.xp_log = db.all('SELECT amount, reason, ref_id, created_at FROM chatter_xp_log WHERE key = ? ORDER BY id DESC LIMIT 30', [r.key]);
-- const ai = chatAiFor(r.key);
-- if (ai) { const ov = parseJson(ai.overview, null); out.chat_ai = { overview: ov ? (ov.today || ov.alltime) : clip(ai.overview, 600), memory: clip(ai.memory_json, 500) }; }
-- out.rank = (db.get('SELECT COUNT(*) AS n FROM chatter_profiles WHERE xp > ?', [r.xp || 0])?.n || 0) + 1;
-- }
-- return out;
--}
--function profile(key) { ensureTables(); return view(row(key), { detail: true }); }
--function leaderboard(limit = 10, { days = null } = {}) {
-- ensureTables();
-- if (days) {
-- const rows = db.all(`SELECT key, SUM(amount) AS gained FROM chatter_xp_log WHERE created_at >= datetime('now', ?) GROUP BY key ORDER BY gained DESC LIMIT ?`, [`-${days} days`, limit]);
-- return rows.map(x => ({ ...view(row(x.key)), gained: x.gained }));
-- }
-- return db.all('SELECT * FROM chatter_profiles WHERE xp > 0 ORDER BY xp DESC LIMIT ?', [limit]).map(r => view(r));
--}
--function count() { ensureTables(); return db.get('SELECT COUNT(*) AS n FROM chatter_profiles WHERE xp > 0')?.n || 0; }
--
--const XP_CHECKIN = 5;
--/** Daily Arena check-in for signed-in users: +5 XP once a day (+ streak bonus via the normal path). */
--function checkin(userId, { display = null } = {}) {
-- ensureTables();
-- const key = `user:${userId}`;
-- ensureRow(key, { display });
-- const d = today();
-- if (db.get(`SELECT 1 FROM chatter_xp_log WHERE key = ? AND reason = 'checkin' AND created_at >= ?`, [key, `${d} 00:00:00`])) return { already: true, ...view(row(key)) };
-- const bonus = streakBonus(key);
-- const r = addXp(key, XP_CHECKIN + bonus, 'checkin', null, { display });
-- return { already: false, gained: XP_CHECKIN + bonus, ...r, ...view(row(key)) };
--}
--module.exports = { ensureTables, checkin, settleCoins, payCoins, keyFor, parseKey, displayFor, levelFor, xpForLevel, titleFor, addXp, onMoment, onSubjectStarted, onHype, onLore, buildCard, cardSweep, profile, leaderboard, count, view, row, TITLES, XP_MOMENT, XP_MOMENT_HOT, XP_FIRST_ON_SUBJECT, XP_SUBJECT_STARTED, XP_QUOTED_IN_LORE, COINS_PER_LEVEL, CARD_MIN_LEVEL };
ddiff --git a/server/arena/listener.js b/server/arena/listener.js
iindex 03042cad..eaba0401 100644
---- a/server/arena/listener.js
++++ b/server/arena/listener.js
@@@ -1,24 +1,23 @@
/**
-- * OpenVibe.Live — Arena Listener (the ears)
++ * OpenVibe.Live — Arena Listener (the ears) — Battle Cam mode
*
-- * Every TICK_MS, for every LIVE fighter whose stream is being transcribed, read the new
-- * speech lines and route them:
++ * Every TICK_MS, for every LIVE fighter whose stream is being transcribed, read the new speech
++ * lines. Everything the Arena is comes from here; chat, votes and submissions do not exist.
*
-- * mentions → a line that names another fighter (username, display name, fighter name,
-- * "@name") starts/extends a MENTION BUFFER for that target; the next few
-- * lines without a name stay in it (people keep ranting after the name drop).
-- * Once the buffer has ≥ JUDGE_MIN_WORDS and ≥ JUDGE_MIN_INTERVAL_MS passed,
-- * the beef judge decides whether it was trash talk AIMED AT that fighter.
-- * Yes → beef.recordHit() (opens the beef, scores it, starts the other side's
-- * clock). See beef.js.
-- * topic → everything else goes to the streamer's ACTIVE BOARD TOPIC (if they picked
-- * one): the topic judge says which angle (if any) the chunk addressed and how
-- * well → board.applyTopicJudgement() (progress, XP, levels).
++ * name-drop → a line that names another fighter (however the transcriber spelled it — see
++ * names.js) LOCKS the ears on that fighter. Everything said while locked goes to
++ * the BEEF JUDGE with the earlier context; a judged hit → beef.recordHit() (opens
++ * the beef, scores it, puts the other side on the clock). See beef.js.
++ * free talk → everything said while NOT locked pools for the MIC JUDGE: is this shit talk at
++ * all (roast, rant, callout, brag, disrespect)? Who is it aimed at? If the judge's
++ * `aimed_at` resolves to a roster fighter, it is a callout and feeds a beef like a
++ * name-drop would; otherwise it lands in the shit-talk feed as a `trash` moment
++ * and pays Trash Level XP (mic.js).
*
-- * Bounded: at most one judge call per stream per JUDGE_MIN_INTERVAL_MS; nothing happens
-- * for streams nobody is talking on; the slur filter voids chunks before any model call.
-- * State lives in memory (offsets are re-seeded from "now" on restart, so a restart never
-- * replays old speech).
++ * Bounded: at most one judge call per stream per JUDGE_MIN_INTERVAL_MS; nothing happens for
++ * streams nobody is talking on; the behaviour filter voids chunks before any model call.
++ * State lives in memory (offsets are re-seeded from "now" on restart, so a restart never replays
++ * old speech).
*/
'use strict';
@@@ -28,23 +27,20 @@ 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 BUFFER_MAX_CHARS = 1400;
--const state = new Map(); // streamId → { userId, lastOffset, lastJudgeAt, mention: { [targetId]: { lines, lastAt } }, topic: { lines } }
++const state = new Map(); // streamId → { userId, lastOffset, lastJudgeAt, focus, mic: { lines } }
function aiOn() { try { return llm.isEnabled() && llm.withinBudget(); } catch { return false; } }
function arena() { return require('./arena-service'); }
function beef() { return require('./beef'); }
--function board() { return require('./board'); }
++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; }
// ── Aliases: who can be called out, by which names ───────────
--// Every predicted spoken form of every roster name (see names.js): camelCase and snake_case
--// split the way a transcriber hears them, digits and decorations dropped, leet undone, plus the
--// persona's AI-written "spoken_as" list (nicknames, misspellings). Matching is exact → fuzzy →
--// phonetic, so "Matticus", "japanese-old-guy" and "goose lee" all resolve.
--
const names = require('./names');
let _aliasCache = { at: 0, list: [] };
function aliases(roster) {
@@@ -53,15 +49,8 @@ function aliases(roster) {
_aliasCache = { at: Date.now(), list };
return list;
}
--
--/** User ids mentioned in a line (never the speaker). */
--function mentionsIn(text, speakerId, roster) {
-- return names.findMentions(text, aliases(roster), { excludeUserId: speakerId }).map(m => m.userId);
--}
--/** Same, with how each was matched — for the console. */
--function mentionsDetailed(text, speakerId, roster) {
-- return names.findMentions(text, aliases(roster), { excludeUserId: speakerId });
--}
++function mentionsIn(text, speakerId, roster) { return names.findMentions(text, aliases(roster), { excludeUserId: speakerId }).map(m => m.userId); }
++function mentionsDetailed(text, speakerId, roster) { return names.findMentions(text, aliases(roster), { excludeUserId: speakerId }); }
// ── Judges ───────────────────────────────────────────────────
@@@ -81,27 +70,30 @@ const BEEF_SCHEMA = {
},
},
};
--const BEEF_SYSTEM = `You judge live streamer-vs-streamer shit talk. You get a target fighter's name(s), what the speaker ALREADY said about them a moment ago (context), and the newest ~60 seconds of the speaker's speech-to-text. The target may not be named again in the new speech — decide from context whether they are STILL talking about that person ("he", "his chat", "that guy", "the beef", the same story) or have moved on to something else. DEFAULT TO about_target=false: gameplay chatter, mumbling and generic sentences are NOT about them even if their name was said a minute ago; if how_the_name_was_matched is not "exact" (a sound-alike), be extra strict. Then decide if the speech is talking shit AT or ABOUT the target (roast, callout, disrespect, bragging over them, trolling them) versus neutral mention (shout-out, thanks, gameplay chatter). Savage, profane, petty and personal is GOOD and scores high — creativity, specificity and quotability score highest; lazy generic insults score low. Pick the best verbatim line, summarise what was said about the target in ≤ 10 words. Forgive transcription noise and misheard names. 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 (addresses, phone numbers, private info). Output only the JSON.`;
++const BEEF_SYSTEM = `You judge live streamer-vs-streamer shit talk (battle-cam style). You get a target fighter's name(s), what the speaker ALREADY said about them a moment ago (context), and the newest ~60 seconds of the speaker's speech-to-text. The target may not be named again in the new speech — decide from context whether they are STILL talking about that person ("he", "his chat", "that guy", "the beef", the same story) or have moved on. DEFAULT TO about_target=false: gameplay chatter, mumbling and generic sentences are NOT about them even if their name was said a minute ago; if how_the_name_was_matched is not "exact" (a sound-alike), be extra strict. Then decide if the speech is talking shit AT or ABOUT the target (roast, callout, disrespect, bragging over them, trolling them) versus a neutral mention (shout-out, thanks, gameplay chatter). Savage, profane, petty and personal is GOOD and scores high — creativity, specificity and quotability score highest; lazy generic insults score low. Pick the best verbatim line, summarise what was said about the target in ≤ 10 words. Forgive transcription noise and misheard names. 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 (addresses, phone numbers, private info). Output only the JSON.`;
--const TOPIC_SCHEMA = {
-- name: 'arena_topic_judgement',
++const MIC_SCHEMA = {
++ name: 'arena_mic_judgement',
schema: {
type: 'object', additionalProperties: false,
-- required: ['on_topic', 'quality', 'best_line', 'about', 'flagged'],
++ required: ['is_trash_talk', 'quality', 'best_line', 'about', 'aimed_at', 'announcer', 'flagged'],
properties: {
-- on_topic: { type: 'boolean', description: 'true if the speech is actually about the SUBJECT (talking shit, taking a side, riffing, ranting, a bit) — not just a passing word' },
-- quality: { type: 'integer', minimum: 0, maximum: 10, description: 'how good it is: savage, specific, funny, quotable = high; generic = low' },
++ 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)' },
-- about: { type: 'string', description: '≤ 10 words: what they said about it' },
++ 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)' },
flagged: { type: 'boolean', description: 'ONLY for direct threats of violence, sexual content about minors, or doxxing — never for offensive language' },
},
},
};
--const TOPIC_SYSTEM = `You judge a live streamer talking about a SUBJECT the community is on about (a person, a group, a joke, a drama…). You get the subject, its current lore, the keywords people use for it, and the last ~60 seconds of the streamer's speech-to-text. DEFAULT TO on_topic=false. Say true ONLY if a stranger reading the speech alone would say "they are clearly talking about that subject" — it names the subject, a keyword, or unmistakably continues it. Gameplay chatter, mumbling, "I'm not sure", "I'm going to the store", reading chat, generic talk → false, quality 0. When true, score the quality (savage, specific, petty, funny, quotable = high; generic = low), pick the best verbatim line and summarise in ≤ 10 words. Forgive transcription noise but never invent a connection. 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 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 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 } = {}) {
const t = text.toLowerCase();
-- const spicy = /\b(clown|weak|scared|duck|ducking|trash|garbage|mid|washed|bum|ratio|cook|cooked|better than|can't|cannot|never|nobody|beat|fraud|ass|bet|catch (these|this)|come see|fight me|square up|run it|talk (that|your)|cope|seethe|cry|loser|bozo|goofy|clown)\b/.test(t);
++ const spicy = SPICY.test(t);
const pronouns = /\b(he|him|his|she|her|they|them|their|that (guy|dude|man|girl|streamer)|this (guy|dude|man|girl|streamer)|the (guy|dude|beef)|bro's|bros)\b/.test(t);
const aboutTarget = named || pronouns;
const excl = (text.match(/!/g) || []).length;
@@@ -109,23 +101,22 @@ function heuristicBeef(text, targetNames, { named = true } = {}) {
const sentences = text.split(/(?<=[.!?])\s+/).filter(Boolean);
const namedLines = sentences.filter(l => targetNames.some(n => l.toLowerCase().includes(n)));
const pick = (namedLines.length ? namedLines : sentences).sort((a, b) => b.length - a.length)[0] || text;
-- return { about_target: aboutTarget, aimed_at_target: aboutTarget && spicy, quality, best_line: pick.trim().slice(0, 200), about: text.split(/\s+/).slice(0, 8).join(' '), flagged: false, _fallback: true };
++ return { about_target: aboutTarget, aimed_at_target: aboutTarget && spicy, quality, best_line: pick.trim().slice(0, 200), about: text.split(/\s+/).slice(0, 8).join(' '), announcer: '', flagged: false, _fallback: true };
}
--
--function heuristicTopic(text, topic) {
-- const kws = (parseJson(topic.keywords_json, []) || []).map(k => String(k).toLowerCase());
++function heuristicMic(text) {
const t = text.toLowerCase();
-- const hits = kws.filter(k => t.includes(k)).length;
-- const spicy = /\b(clown|weak|scared|trash|garbage|mid|washed|bum|ratio|cook|cooked|never|nobody|fraud|ass|bet|shut up|cope|seethe|cry|worst|best|hate|love)\b/.test(t);
++ const spicy = SPICY.test(t);
const excl = (text.match(/!/g) || []).length;
-- const quality = Math.min(10, 2 + (spicy ? 3 : 0) + excl + Math.min(3, hits));
++ const aimed = /\b(chat|you guys|y'all|yall|all of you)\b/.test(t) ? 'chat' : /\bmods?\b/.test(t) ? 'the mods' : /\b(twitch|kick|youtube) streamers?\b/.test(t) ? 'other streamers' : '';
++ const quality = spicy ? Math.min(10, 4 + excl + (words(text) > 30 ? 1 : 0) + (/\b(never|nobody|fraud|washed|cooked|ratio)\b/.test(t) ? 1 : 0)) : 0;
const sentences = text.split(/(?<=[.!?])\s+/).filter(Boolean);
-- const named = sentences.filter(l => kws.some(k => l.toLowerCase().includes(k)));
-- return { on_topic: hits > 0, quality, best_line: ((named.length ? named : sentences).sort((a, b) => b.length - a.length)[0] || text).trim().slice(0, 200), about: text.split(/\s+/).slice(0, 8).join(' '), flagged: false, _fallback: true };
++ 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 };
}
async function judgeBeef(speakerId, targetId, text, roster, { context = null, named = true, how = 'exact' } = {}) {
-- if (arena()._isBannedText(text)) return { about_target: false, aimed_at_target: false, quality: 0, best_line: '', about: 'voided', flagged: true };
++ if (arena()._isBannedText(text)) return { about_target: false, aimed_at_target: false, quality: 0, best_line: '', about: 'voided', announcer: '', flagged: true };
const tf = roster.byId[targetId];
const targetNames = [tf.user.username, tf.user.display_name, (parseJson(db.get('SELECT persona_json FROM arena_profiles WHERE user_id = ?', [targetId])?.persona_json) || {}).fighter_name].filter(Boolean);
const spokenForms = [...new Set(targetNames.flatMap(n => names.variants(n)))].slice(0, 8);
@@@ -143,17 +134,17 @@ async function judgeBeef(speakerId, targetId, text, roster, { context = null, na
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 };
}
--async function judgeTopic(speakerId, topic, text) {
-- if (arena()._isBannedText(text)) return { on_topic: false, quality: 0, best_line: '', about: 'voided', flagged: true };
++async function judgeMic(speakerId, text) {
++ if (arena()._isBannedText(text)) return { is_trash_talk: false, quality: 0, best_line: '', about: 'voided', aimed_at: '', announcer: '', flagged: true };
let j = null;
if (aiOn()) {
try {
-- const r = await llm.complete({ role: 'chat', kind: 'arena_topic_judge', source: 'arena', ownerUserId: speakerId, system: TOPIC_SYSTEM, user: JSON.stringify({ subject: topic.text, headline: topic.headline, lore: topic.lore ? String(topic.lore).slice(0, 500) : null, keywords: parseJson(topic.keywords_json, []), speech: text }), json: TOPIC_SCHEMA, maxTokens: 200, temperature: 0.4, timeoutMs: 25000 });
++ const r = await llm.complete({ role: 'chat', kind: 'arena_mic_judge', source: 'arena', ownerUserId: speakerId, system: MIC_SYSTEM, user: JSON.stringify({ speech: text }), json: MIC_SCHEMA, maxTokens: 240, temperature: 0.4, timeoutMs: 25000 });
if (r && r.json && typeof r.json.quality === 'number') j = r.json;
-- } catch (e) { console.warn('[Arena] topic judge:', e.message); }
++ } catch (e) { console.warn('[Arena] mic judge:', e.message); }
}
-- if (!j) j = heuristicTopic(text, topic);
-- return { on_topic: !!j.on_topic && !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), flagged: !!j.flagged, fallback: !!j._fallback };
++ 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 };
}
// ── Tick ─────────────────────────────────────────────────────
@@@ -162,12 +153,10 @@ function liveTranscribedStreams(roster) {
return db.all(`SELECT s.id, s.user_id, s.started_at FROM streams s WHERE s.is_live = 1 AND EXISTS (SELECT 1 FROM stream_timeline_events e WHERE e.stream_id = s.id AND e.kind = 'speech' AND e.created_at >= datetime('now', '-30 minutes'))`)
.filter(s => roster.byId[s.user_id]);
}
--
function streamOffsetNow(stream) {
const startedMs = stream.started_at ? Date.parse(String(stream.started_at).replace(' ', 'T') + 'Z') : Date.now();
return Math.max(0, (Date.now() - startedMs) / 1000);
}
--
function bufferText(lines) { return lines.map(l => l.t.replace(/^\s*(?:>>|--?)\s*/, '').trim()).filter(Boolean).join(' ').replace(/\s+/g, ' ').slice(-BUFFER_MAX_CHARS); }
function lineRefFor(lines, bestLine) {
const needle = String(bestLine || '').toLowerCase().slice(0, 30);
@@@ -177,17 +166,14 @@ function lineRefFor(lines, bestLine) {
/**
* Per-stream focus: after a fighter's name is said, the listener LOCKS ON to that fighter. Every
-- * following line goes to the beef judge with the earlier context, so "…and his chat is 12 alts,
-- * he's scared of the smoke" still counts without the name being repeated. Each judged hit
-- * extends the lock (FOCUS_EXTEND_MS); two chunks in a row that are not about the target, or
-- * FOCUS_MAX_MS since the last time they were actually named, drop it. A different name-drop
-- * switches focus (the pending chunk is judged first if it is big enough).
++ * following line goes to the beef judge with the earlier context. Each judged hit extends the lock
++ * (FOCUS_EXTEND_MS); two chunks in a row that are not about the target, or FOCUS_MAX_MS since the
++ * last actual name-drop, drop it. A different name-drop switches focus.
*/
--const FOCUS_TAIL_MS = 2 * 60 * 1000; // how long a bare name-drop keeps the ears on the target
--const FOCUS_EXTEND_MS = 3 * 60 * 1000; // every hit while locked extends the lock by this
--const FOCUS_MAX_MS = 20 * 60 * 1000; // hard cap without a fresh name-drop
++const FOCUS_TAIL_MS = 2 * 60 * 1000;
++const FOCUS_EXTEND_MS = 3 * 60 * 1000;
++const FOCUS_MAX_MS = 20 * 60 * 1000;
const FOCUS_MISSES_TO_DROP = 2;
--const SUBJECT_TAIL_MS = 2 * 60 * 1000; // a subject keyword said on mic keeps the subject judge on it this long (hits extend)
function newFocus(targetId, now, how) { return { targetId, since: now, namedAt: now, lockUntil: now + FOCUS_TAIL_MS, lines: [], misses: 0, hits: 0, context: null, how }; }
@@@ -200,19 +186,17 @@ async function judgeFocus(stream, roster, st, events, { reason }) {
st.lastJudgeAt = Date.now();
const j = await judgeBeef(stream.user_id, f.targetId, text, roster, { context: f.context, named, how: f.how });
const now = Date.now();
-- // A hit must actually be a hit: exact name-drops need quality ≥ 3, sound-alike matches ≥ 5 before anything opens.
const minQ = f.hits > 0 ? 3 : (f.how === 'exact' ? 3 : 5);
if (j.aimed_at_target && j.quality >= minQ) {
const ref = lineRefFor(lines, j.best_line);
-- const res = beef().recordHit(stream.user_id, f.targetId, { quality: j.quality, best_line: j.best_line, about: j.about, announcer: j.announcer, vod_id: ref.vod_id, sec: ref.sec });
++ const res = beef().recordHit(stream.user_id, f.targetId, { quality: j.quality, best_line: j.best_line, about: j.about, announcer: j.announcer, vod_id: ref.vod_id, sec: ref.sec, stream_id: stream.id });
f.hits++; f.misses = 0; f.lockUntil = Math.min(now + FOCUS_EXTEND_MS, f.namedAt + FOCUS_MAX_MS);
f.context = `${f.context ? f.context + ' | ' : ''}${j.about}${j.best_line ? ` ("${j.best_line.slice(0, 120)}")` : ''}`.slice(-600);
-- st.lastBeefJudgement = { at: new Date().toISOString(), target_id: f.targetId, ...j, opened: res?.opened, bounty: res?.bounty, named, reason };
++ st.lastBeefJudgement = { at: new Date().toISOString(), target_id: f.targetId, ...j, opened: res?.opened, named, reason };
events.push({ kind: 'beef_hit', streamId: stream.id, speakerId: stream.user_id, targetId: f.targetId, opened: res?.opened, quality: j.quality, line: j.best_line, named, continued: !named });
return true;
}
if (j.about_target) {
-- // Still on the subject but not shit talk (a story, a shout-out) — keep listening a little.
f.misses = 0; f.lockUntil = Math.min(Math.max(f.lockUntil, now + FOCUS_TAIL_MS / 2), f.namedAt + FOCUS_MAX_MS);
f.context = `${f.context ? f.context + ' | ' : ''}(neutral) ${j.about}`.slice(-600);
st.lastBeefJudgement = { at: new Date().toISOString(), target_id: f.targetId, ...j, named, reason };
@@@ -226,33 +210,44 @@ async function judgeFocus(stream, roster, st, events, { reason }) {
return true;
}
++/** Free talk (not locked on anyone): is it shit talk at all, and at whom? */
++async function judgeFreeTalk(stream, roster, st, events) {
++ const lines = st.mic.lines; st.mic.lines = [];
++ const text = bufferText(lines);
++ st.lastJudgeAt = Date.now();
++ const j = await judgeMic(stream.user_id, text);
++ 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);
++ // "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) {
++ const res = beef().recordHit(stream.user_id, target.userId, { quality: j.quality, best_line: j.best_line, about: j.about, announcer: j.announcer, vod_id: ref.vod_id, sec: ref.sec, stream_id: stream.id });
++ st.lastMicJudgement.target_id = target.userId; st.lastMicJudgement.opened = res?.opened;
++ events.push({ kind: 'beef_hit', streamId: stream.id, speakerId: stream.user_id, targetId: target.userId, opened: res?.opened, quality: j.quality, line: j.best_line, named: false, callout: true });
++ return;
++ }
++ const m = mic().addMoment({ userId: stream.user_id, streamId: stream.id, vodId: ref.vod_id, sec: ref.sec, kind: 'trash', aimedAt: j.aimed_at || null, text: j.best_line || text.slice(0, 220), about: j.about, quality: j.quality, announcer: j.announcer });
++ if (m) events.push({ kind: 'mic_hit', streamId: stream.id, speakerId: stream.user_id, quality: j.quality, line: j.best_line, aimedAt: j.aimed_at, momentId: m.id });
++}
++
async function tickStream(stream, roster, events) {
let st = state.get(stream.id);
-- if (!st) { st = { userId: stream.user_id, lastOffset: streamOffsetNow(stream) - 20, lastJudgeAt: 0, focus: null, topic: { lines: [] } }; state.set(stream.id, st); }
++ if (!st) { st = { userId: stream.user_id, lastOffset: streamOffsetNow(stream) - 20, lastJudgeAt: 0, focus: null, mic: { lines: [] } }; state.set(stream.id, st); }
const rows = db.all(`SELECT text, start_sec, vod_id FROM stream_timeline_events WHERE stream_id = ? AND kind = 'speech' AND start_sec > ? ORDER BY start_sec ASC LIMIT 100`, [stream.id, st.lastOffset]);
if (rows.length) st.lastOffset = rows[rows.length - 1].start_sec;
const now = Date.now();
if (st.focus && now > st.focus.lockUntil) {
-- // Lock expired: judge whatever is pending if it is worth a call, then let go.
if (st.focus.lines.length && st.focus.lines.reduce((n, l) => n + words(l.t), 0) >= JUDGE_MIN_WORDS && now - st.lastJudgeAt >= JUDGE_MIN_INTERVAL_MS) await judgeFocus(stream, roster, st, events, { reason: 'lock expired' });
if (st.focus) { events.push({ kind: 'focus_dropped', streamId: stream.id, targetId: st.focus.targetId, hits: st.focus.hits, why: 'timeout' }); st.focus = null; }
}
for (const r of rows) {
const line = { t: String(r.text || ''), s: Math.floor(r.start_sec), v: r.vod_id || null, named: false };
-- // Board subjects said on mic → a moment on the topic (raw mention; the judge scores the chunk later).
-- try {
-- for (const t of board().matchTopics(line.t)) {
-- const m = board().noteMicMention(t.id, { userId: stream.user_id, username: roster.byId[stream.user_id]?.user?.username || null, streamId: stream.id, vodId: line.v, sec: Math.max(0, line.s - 2), text: line.t });
-- if (m) events.push({ kind: 'topic_mention', streamId: stream.id, speakerId: stream.user_id, topicId: t.id });
-- st.lastTopic = { id: t.id, at: now, lockUntil: now + SUBJECT_TAIL_MS, misses: 0 };
-- }
-- } catch (e) { console.warn('[Arena] topic match:', e.message); }
const mentions = mentionsDetailed(line.t, stream.user_id, roster);
if (mentions.length) {
-- const m = mentions[0]; // best match (exact > fuzzy > phonetic)
++ const m = mentions[0];
line.named = true;
if (st.focus && st.focus.targetId !== m.userId) {
-- // Switching targets: judge the pending chunk on the old target first if it is big enough.
if (st.focus.lines.reduce((n, l) => n + words(l.t), 0) >= JUDGE_MIN_WORDS) await judgeFocus(stream, roster, st, events, { reason: 'target switch' });
st.focus = null;
}
@@@ -260,38 +255,23 @@ async function tickStream(stream, roster, events) {
else { st.focus.namedAt = now; st.focus.lockUntil = Math.max(st.focus.lockUntil, now + FOCUS_TAIL_MS); st.focus.misses = 0; }
st.focus.lines.push(line);
} else if (st.focus) {
-- st.focus.lines.push(line); // locked on: everything they say goes to the target's judge
++ st.focus.lines.push(line);
} else {
-- st.topic.lines.push(line);
++ st.mic.lines.push(line);
}
}
if (st.focus && st.focus.lines.length > 80) st.focus.lines = st.focus.lines.slice(-80);
-- if (st.topic.lines.length > 60) st.topic.lines = st.topic.lines.slice(-60);
++ if (st.mic.lines.length > 60) st.mic.lines = st.mic.lines.slice(-60);
if (now - st.lastJudgeAt < JUDGE_MIN_INTERVAL_MS) return;
-- // 1) Focused target first (a callout is the interesting thing).
++ // 1) Locked on a fighter → the beef judge (a callout is the interesting thing).
if (st.focus && st.focus.lines.reduce((n, l) => n + words(l.t), 0) >= JUDGE_MIN_WORDS) {
await judgeFocus(stream, roster, st, events, { reason: st.focus.lines.some(l => l.named) ? 'name-drop' : 'continuation' });
return; // one judge call per stream per tick
}
--
-- // 2) Board subject: only while a subject keyword was actually said recently (the lock) — never "whatever they touched once".
-- let topic = null;
-- if (st.lastTopic && now < (st.lastTopic.lockUntil || 0)) topic = db.get(`SELECT * FROM arena_topics WHERE id = ? AND status = 'open'`, [st.lastTopic.id]);
-- if (!topic && st.topic.lines.length > 40) st.topic.lines = st.topic.lines.slice(-20);
-- if (topic && st.topic.lines.reduce((n, l) => n + words(l.t), 0) >= JUDGE_MIN_WORDS) {
-- const lines = st.topic.lines; st.topic.lines = [];
-- const text = bufferText(lines);
-- st.lastJudgeAt = Date.now();
-- const j = await judgeTopic(stream.user_id, topic, text);
-- const ref = { ...lineRefFor(lines, j.best_line), stream_id: stream.id };
-- const res = board().applyTopicJudgement(stream.user_id, topic, j, ref);
-- if (res.applied) { st.lastTopic.lockUntil = Math.max(st.lastTopic.lockUntil || 0, Date.now() + SUBJECT_TAIL_MS); st.lastTopic.misses = 0; }
-- else if (++st.lastTopic.misses >= 2) st.lastTopic.lockUntil = 0; // moved on
-- st.lastTopicJudgement = { at: new Date().toISOString(), topic_id: topic.id, topic: topic.text, ...j, applied: res.applied, xp: res.xp || 0 };
-- events.push({ kind: res.applied ? 'topic_hit' : 'topic_miss', streamId: stream.id, speakerId: stream.user_id, topicId: topic.id, ...res });
-- }
++ // 2) Free talk → the mic judge.
++ if (!st.focus && st.mic.lines.reduce((n, l) => n + words(l.t), 0) >= JUDGE_MIN_WORDS) await judgeFreeTalk(stream, roster, st, events);
}
let _timer = null, _busy = false;
@@@ -314,9 +294,9 @@ function consoleState(userId) {
const f = st.focus;
return {
stream_id: streamId, listening: true,
-- focus: f ? { target_id: f.targetId, target: (() => { try { return board().nameOf(f.targetId); } catch { return null; } })(), how: f.how, since: new Date(f.since).toISOString(), lock_seconds_left: Math.max(0, Math.round((f.lockUntil - Date.now()) / 1000)), hits: f.hits, misses: f.misses, pending_words: f.lines.reduce((n, l) => n + words(l.t), 0), context: f.context } : null,
-- pending_topic_words: st.topic.lines.reduce((n, l) => n + words(l.t), 0),
-- last_topic_judgement: st.lastTopicJudgement || null, last_beef_judgement: st.lastBeefJudgement || null, last_judge_at: st.lastJudgeAt ? new Date(st.lastJudgeAt).toISOString() : null,
++ focus: f ? { target_id: f.targetId, target: (() => { try { return mic().nameOf(f.targetId); } catch { return null; } })(), how: f.how, since: new Date(f.since).toISOString(), lock_seconds_left: Math.max(0, Math.round((f.lockUntil - Date.now()) / 1000)), hits: f.hits, misses: f.misses, pending_words: f.lines.reduce((n, l) => n + words(l.t), 0), context: f.context } : null,
++ pending_mic_words: st.mic.lines.reduce((n, l) => n + words(l.t), 0),
++ last_mic_judgement: st.lastMicJudgement || null, last_beef_judgement: st.lastBeefJudgement || null, last_judge_at: st.lastJudgeAt ? new Date(st.lastJudgeAt).toISOString() : null,
};
}
return { listening: false };
@@@ -326,8 +306,8 @@ function start() {
if (_timer) return;
_timer = setInterval(() => tick().catch(e => console.warn('[Arena] listener:', e.message)), TICK_MS);
if (_timer.unref) _timer.unref();
-- console.log('[Arena] listener started (every 15 s)');
++ console.log('[Arena] listener started (every 15 s — name-drops → beef judge, free talk → mic judge)');
}
function stop() { if (_timer) { clearInterval(_timer); _timer = null; } }
--module.exports = { start, stop, tick, consoleState, TICK_MS, JUDGE_MIN_WORDS, JUDGE_MIN_INTERVAL_MS, FOCUS_TAIL_MS, FOCUS_EXTEND_MS, FOCUS_MAX_MS, _mentionsIn: mentionsIn, _mentionsDetailed: mentionsDetailed, _aliases: aliases, _heuristicBeef: heuristicBeef, _heuristicTopic: heuristicTopic, _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, _mentionsIn: mentionsIn, _mentionsDetailed: mentionsDetailed, _aliases: aliases, _heuristicBeef: heuristicBeef, _heuristicMic: heuristicMic, _state: state };
ddiff --git a/server/arena/mic.js b/server/arena/mic.js
nnew file mode 100644
iindex 00000000..51f423a4
---- /dev/null
++++ b/server/arena/mic.js
@@@ -0,0 +1,235 @@
++/**
++ * OpenVibe.Live — Arena mic ledger (Battle Cam mode)
++ *
++ * The Arena runs on ONE input: what fighters say on mic (stream_timeline_events speech,
++ * judged by listener.js). This module is the ledger for that:
++ *
++ * moments → every judged piece of shit talk lands here: a callout that opened/fed a
++ * beef (`beef_hit`), or free-standing trash talk aimed at chat / a group / a
++ * name that is not on the roster (`trash`). VOD-linked so every line can be
++ * replayed at the second it was said.
++ * XP → moment quality → Trash Level (arena_trash_levels / arena_xp_log). No other
++ * source of XP exists: not chat, not votes, not check-ins.
++ * mic stats → per-fighter aggregates the roster ratings are built from (HEAT, AIM,
++ * CLAPBACK, KILLS…) — see arena-service.js.
++ * feed → the live "shit talk feed": newest judged lines across every live cam.
++ *
++ * The behaviour filter (threats / minors / doxxing — never vocabulary) is applied before any
++ * line is stored; see arena-service.isBannedText.
++ */
++'use strict';
++
++const db = require('../db/database');
++
++const XP_PER_LEVEL = 50;
++const XP_MOMENT = 0.8; // × quality
++const XP_HYPE = 1;
++
++let _ready = false;
++function ensureTables() {
++ if (_ready) return;
++ db.run(`CREATE TABLE IF NOT EXISTS arena_trash_levels (
++ user_id INTEGER PRIMARY KEY,
++ xp INTEGER DEFAULT 0,
++ level INTEGER DEFAULT 1,
++ angles_cleared INTEGER DEFAULT 0,
++ topics_conquered INTEGER DEFAULT 0,
++ beef_hits INTEGER DEFAULT 0,
++ best_line TEXT,
++ best_line_vod_id INTEGER,
++ best_line_sec INTEGER,
++ best_line_score REAL DEFAULT 0,
++ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
++ )`);
++ for (const col of ['topic_moments INTEGER DEFAULT 0', 'topics_joined INTEGER DEFAULT 0', 'mic_moments INTEGER DEFAULT 0']) { try { db.run(`ALTER TABLE arena_trash_levels ADD COLUMN ${col}`); } catch { /* exists */ } }
++ db.run(`CREATE TABLE IF NOT EXISTS arena_xp_log (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ user_id INTEGER NOT NULL,
++ amount INTEGER NOT NULL,
++ reason TEXT NOT NULL,
++ ref_id INTEGER,
++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
++ )`);
++ db.run('CREATE INDEX IF NOT EXISTS idx_arena_xp_log_user ON arena_xp_log (user_id, created_at)');
++ db.run(`CREATE TABLE IF NOT EXISTS arena_mic_moments (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ user_id INTEGER NOT NULL,
++ stream_id INTEGER,
++ vod_id INTEGER,
++ sec INTEGER,
++ kind TEXT NOT NULL DEFAULT 'trash',
++ target_user_id INTEGER,
++ beef_id INTEGER,
++ aimed_at TEXT,
++ text TEXT NOT NULL,
++ about TEXT,
++ quality REAL DEFAULT 0,
++ announcer TEXT,
++ said_at DATETIME,
++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
++ )`);
++ db.run('CREATE INDEX IF NOT EXISTS idx_arena_mic_user ON arena_mic_moments (user_id, id)');
++ db.run('CREATE INDEX IF NOT EXISTS idx_arena_mic_created ON arena_mic_moments (created_at)');
++ _ready = true;
++}
++
++function parseJson(t, f = null) { try { return t ? JSON.parse(t) : f; } catch { return f; } }
++function arena() { return require('./arena-service'); }
++function levelFor(xp) { return 1 + Math.floor((Number(xp) || 0) / XP_PER_LEVEL); }
++function levelRow(userId) {
++ ensureTables();
++ return db.get('SELECT * FROM arena_trash_levels WHERE user_id = ?', [userId]) || { user_id: userId, xp: 0, level: 1, beef_hits: 0, mic_moments: 0, best_line: null, best_line_score: 0 };
++}
++
++// ── XP / Trash Level ─────────────────────────────────────────
++
++function addXp(userId, amount, reason, refId = null, extra = {}) {
++ ensureTables();
++ amount = Math.round(Number(amount) || 0);
++ if (amount <= 0) return levelRow(userId);
++ const before = levelRow(userId);
++ db.run(`INSERT INTO arena_trash_levels (user_id, xp, level) VALUES (?, ?, ?)
++ ON CONFLICT(user_id) DO UPDATE SET xp = xp + excluded.xp, level = ?, updated_at = CURRENT_TIMESTAMP`,
++ [userId, amount, levelFor(amount), levelFor((before.xp || 0) + amount)]);
++ db.run('INSERT INTO arena_xp_log (user_id, amount, reason, ref_id) VALUES (?, ?, ?, ?)', [userId, amount, reason, refId]);
++ const sets = [];
++ if (extra.moment) sets.push('mic_moments = mic_moments + 1');
++ if (extra.beefHit) sets.push('beef_hits = beef_hits + 1');
++ if (sets.length) db.run(`UPDATE arena_trash_levels SET ${sets.join(', ')} WHERE user_id = ?`, [userId]);
++ if (extra.line && (extra.lineScore || 0) > (before.best_line_score || 0)) {
++ db.run('UPDATE arena_trash_levels SET best_line = ?, best_line_vod_id = ?, best_line_sec = ?, best_line_score = ? WHERE user_id = ?', [String(extra.line).slice(0, 220), extra.lineVodId || null, extra.lineSec ?? null, extra.lineScore, userId]);
++ }
++ const after = levelRow(userId);
++ if (after.level > before.level) {
++ console.log(`[Arena] user ${userId} → Trash Level ${after.level}`);
++ try { require('./notify').arenaNotify(userId, { type: 'level', title: `Trash Level ${after.level}`, message: `${after.xp} XP from pure mic. Keep talking.`, icon: '🎙️', url: '/arena', key: `level:${after.level}` }); } catch { /* */ }
++ }
++ try { arena().loadRoster(true); } catch { /* */ }
++ return { ...after, leveled_up: after.level > before.level, gained: amount };
++}
++
++function recentXp(userId, days = 7) {
++ ensureTables();
++ return db.get(`SELECT COALESCE(SUM(amount), 0) AS xp FROM arena_xp_log WHERE user_id = ? AND created_at >= datetime('now', ?)`, [userId, `-${days} days`])?.xp || 0;
++}
++
++function levelView(userId) {
++ const r = levelRow(userId);
++ const xp = r.xp || 0;
++ return {
++ level: levelFor(xp), xp, xp_into_level: xp - (levelFor(xp) - 1) * XP_PER_LEVEL, xp_per_level: XP_PER_LEVEL, next_level_xp: levelFor(xp) * XP_PER_LEVEL,
++ mic_moments: r.mic_moments || 0, beef_hits: r.beef_hits || 0,
++ recent_xp: recentXp(userId),
++ best_line: r.best_line ? { text: r.best_line, vod_id: r.best_line_vod_id, sec: r.best_line_sec, score: r.best_line_score } : null,
++ };
++}
++
++function levelsLeaderboard(limit = 10) {
++ ensureTables();
++ const roster = arena().loadRoster();
++ return db.all('SELECT * FROM arena_trash_levels WHERE xp > 0 ORDER BY xp DESC LIMIT ?', [limit])
++ .map(r => ({ ...fighterBrief(r.user_id, roster), xp: r.xp, level: levelFor(r.xp), beef_hits: r.beef_hits || 0, mic_moments: r.mic_moments || 0, best_line: r.best_line ? { text: r.best_line, vod_id: r.best_line_vod_id, sec: r.best_line_sec, score: r.best_line_score } : null }));
++}
++
++// ── Names + briefs ───────────────────────────────────────────
++
++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 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}`,
++ 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]),
++ level: levelFor(levelRow(userId).xp || 0),
++ };
++}
++
++// ── Moments ──────────────────────────────────────────────────
++
++/**
++ * Store a judged mic line. kind: 'trash' (aimed at chat / a group / a non-roster name),
++ * 'beef_hit' (fed a beef; target_user_id + beef_id set). Pays XP for 'trash' here; beef hits
++ * are paid by beef.recordHit. Returns the row or null when the line is behaviour-filtered.
++ */
++function addMoment({ userId, streamId = null, vodId = null, sec = null, kind = 'trash', targetUserId = null, beefId = null, aimedAt = null, text, about = null, quality = 0, announcer = null, saidAt = null }) {
++ ensureTables();
++ const t = String(text || '').replace(/\s+/g, ' ').trim().slice(0, 240);
++ if (!t) return null;
++ try { if (arena()._isBannedText(t)) return null; } catch { /* */ }
++ const q = Math.max(0, Math.min(10, Number(quality) || 0));
++ const r = db.run(`INSERT INTO arena_mic_moments (user_id, stream_id, vod_id, sec, kind, target_user_id, beef_id, aimed_at, text, about, quality, announcer, said_at)
++ 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 });
++ return db.get('SELECT * FROM arena_mic_moments WHERE id = ?', [id]);
++}
++
++function momentView(m, roster) {
++ const brief = fighterBrief(m.user_id, roster);
++ return {
++ id: m.id, kind: m.kind, user: brief.user, fighter_name: brief.fighter_name, rank: brief.rank, image_url: brief.image_url, level: brief.level, live: brief.live,
++ target: m.target_user_id ? (() => { const b = fighterBrief(m.target_user_id, roster); return { user: b.user, fighter_name: b.fighter_name, rank: b.rank }; })() : null,
++ beef_id: m.beef_id || null, aimed_at: m.aimed_at || null, text: m.text, about: m.about, quality: Number(m.quality) || 0, announcer: m.announcer || null,
++ stream_id: m.stream_id, vod_id: m.vod_id, sec: m.sec, at: m.said_at || m.created_at,
++ };
++}
++
++/** The shit-talk feed: newest judged lines across the roster. */
++function feed({ limit = 40, since = null, userId = null } = {}) {
++ ensureTables();
++ const roster = arena().loadRoster();
++ const params = [];
++ let where = '1 = 1';
++ 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));
++}
++function momentsFor(userId, limit = 12) { return feed({ limit, userId }); }
++function bestLines(userId, limit = 5) {
++ ensureTables();
++ const roster = arena().loadRoster();
++ return db.all('SELECT * FROM arena_mic_moments WHERE user_id = ? AND quality >= 5 ORDER BY quality DESC, id DESC LIMIT ?', [userId, limit]).map(m => momentView(m, roster));
++}
++function latestFor(userId) {
++ ensureTables();
++ const m = db.get('SELECT * FROM arena_mic_moments WHERE user_id = ? ORDER BY id DESC LIMIT 1', [userId]);
++ return m ? momentView(m, arena().loadRoster()) : null;
++}
++
++/** Aggregates the ratings are built from. All from the mic ledger + beefs — nothing else. */
++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(quality >= 7), 0) AS bangers
++ FROM arena_mic_moments WHERE user_id = ? AND created_at >= datetime('now', ?)`, [userId, win]) || {};
++ let b = {};
++ try { b = db.get(`SELECT COALESCE(SUM(b_user_id = ?), 0) AS targeted, COALESCE(SUM(b_user_id = ? AND responded = 1), 0) AS answered, COALESCE(SUM(winner_user_id = ?), 0) AS wins, COALESCE(SUM(status = 'resolved' AND winner_user_id IS NOT NULL AND winner_user_id != ?), 0) AS losses, COALESCE(SUM(status = 'resolved' AND resolution = 'forfeit' AND winner_user_id != ? AND winner_user_id IS NOT NULL), 0) AS ducked FROM arena_beefs WHERE a_user_id = ? OR b_user_id = ?`, [userId, userId, userId, userId, userId, userId, userId]) || {}; } catch { b = {}; }
++ const speechMin = (() => { try { return (db.get(`SELECT COALESCE(SUM(COALESCE(end_sec, start_sec + 3) - start_sec), 0) AS s FROM stream_timeline_events WHERE user_id = ? AND kind = 'speech' AND created_at >= datetime('now', ?)`, [userId, win])?.s || 0) / 60; } catch { return 0; } })();
++ const hours = Math.max(speechMin / 60, 0.05);
++ const targeted = b.targeted || 0;
++ return {
++ window_days: days, moments: m.n || 0, trash: m.trash || 0, beef_hits: m.beef_hits || 0, bangers: m.bangers || 0,
++ avg_quality: Number((m.avg_q || 0).toFixed(2)), best_quality: Number(m.best_q || 0),
++ hits_per_mic_hour: Number((((m.beef_hits || 0) + (m.trash || 0)) / hours).toFixed(2)),
++ targeted, answered: b.answered || 0, clapback_rate: targeted ? Number(((b.answered || 0) / targeted).toFixed(2)) : 0,
++ wins: b.wins || 0, losses: b.losses || 0, ducked: b.ducked || 0, speech_minutes: Number(speechMin.toFixed(1)),
++ };
++}
++
++module.exports = {
++ ensureTables, addXp, recentXp, levelView, levelFor, levelRow, levelsLeaderboard, nameOf, fighterBrief,
++ addMoment, momentView, feed, momentsFor, bestLines, latestFor, micStats,
++ XP_PER_LEVEL, XP_MOMENT, XP_HYPE,
++};
ddiff --git a/server/arena/progress.js b/server/arena/progress.js
ddeleted file mode 100644
iindex 6349cfc8..00000000
---- a/server/arena/progress.js
++++ /dev/null
@@@ -1,175 +0,0 @@
--/**
-- * progress.js — the part that BUILDS over time: tiers, achievements, a history feed, weekly ladders.
-- *
-- * tiers → Bronze → Silver → Gold → Platinum → Diamond → Mythic, from all-time XP (fighters:
-- * arena_trash_levels.xp; chatters: chatter_profiles.xp). Never goes down.
-- * achievements → milestones checked from the data already there (no AI): first blood, streaks,
-- * upsets, rematch wins, subjects started, HOT subjects, being quoted, hype received,
-- * bounties, days in a row… Each pays XP; accounts also get OpenCoins (idempotent).
-- * history → arena_events: everything notable that happened to a key, newest first — beefs,
-- * level-ups, tier-ups, achievements, subjects, quotes. The profile timeline.
-- * weekly → XP gained in the last 7 days per key (fighters + chatters) for "this week" crowns.
-- *
-- * Keys: fighters are `user:<id>` (same as their chatter key) — one history per person; chatters of
-- * other kinds are `anon:<n>` / `relay:<platform>:<name>`.
-- */
--'use strict';
--
--const db = require('../db/database');
--
--const TIERS = [[0, 'Bronze', '#cd7f32'], [150, 'Silver', '#c0c0c0'], [400, 'Gold', '#f5c542'], [900, 'Platinum', '#7fe7e0'], [1800, 'Diamond', '#8ab4ff'], [3500, 'Mythic', '#ff5fd2']];
--
--// { id, name, desc, icon, xp, coins, for: 'fighter'|'chatter'|'both', check(ctx) → bool, hint }
--const ACHIEVEMENTS = [
-- // fighters (on-mic)
-- { id: 'first_blood', for: 'fighter', name: 'First Blood', icon: '🩸', desc: 'Land your first judged hit in a beef.', xp: 20, coins: 20, check: c => c.f.beef_hits >= 1 },
-- { id: 'answered', for: 'fighter', name: 'Not Ducking', icon: '🎙️', desc: 'Answer a beef on your own stream before the clock ran out.', xp: 25, coins: 25, check: c => c.f.answered >= 1 },
-- { id: 'first_win', for: 'fighter', name: 'W', icon: '🏆', desc: 'Win a beef.', xp: 30, coins: 30, check: c => c.f.wins >= 1 },
-- { id: 'five_wins', for: 'fighter', name: 'Problem', icon: '🥊', desc: 'Win five beefs.', xp: 80, coins: 100, check: c => c.f.wins >= 5 },
-- { id: 'streak3', for: 'fighter', name: 'Untouchable', icon: '🔥', desc: 'Three beef wins in a row.', xp: 60, coins: 60, check: c => c.f.streak >= 3 },
-- { id: 'upset', for: 'fighter', name: 'Giant Killer', icon: '⚡', desc: 'Beat someone ranked 4+ places above you.', xp: 60, coins: 80, check: c => c.f.upsets >= 1 },
-- { id: 'rematch_win', for: 'fighter', name: 'Run It Back', icon: '🔁', desc: 'Win a rematch.', xp: 40, coins: 40, check: c => c.f.rematch_wins >= 1 },
-- { id: 'on_the_board', for: 'fighter', name: 'On The Board', icon: '📌', desc: 'Get a judged on-mic moment on a subject.', xp: 15, coins: 10, check: c => c.f.topic_moments >= 1 },
-- { id: 'loudmouth', for: 'fighter', name: 'Loudmouth', icon: '📢', desc: 'Twenty-five judged on-mic moments.', xp: 60, coins: 60, check: c => c.f.topic_moments >= 25 },
-- { id: 'bounty_hunter', for: 'fighter', name: 'Bounty Hunter', icon: '💰', desc: 'Collect a bounty.', xp: 40, coins: 60, check: c => c.f.bounties >= 1 },
-- { id: 'main_event', for: 'fighter', name: 'Main Character', icon: '🎯', desc: 'Be heard on a subject that went HOT.', xp: 30, coins: 30, check: c => c.f.hot_subjects >= 1 },
-- { id: 'crowd', for: 'fighter', name: 'Crowd Favorite', icon: '👑', desc: 'Get hyped 25 times.', xp: 40, coins: 50, check: c => c.f.hypes_received >= 25 },
-- // chatters (from chat)
-- { id: 'first_moment', for: 'chatter', name: 'Said Something', icon: '💬', desc: 'Land your first line on a subject.', xp: 5, coins: 5, check: c => c.c.moments >= 1 },
-- { id: 'ten_moments', for: 'chatter', name: 'Yapping', icon: '🗣️', desc: 'Ten lines on the board.', xp: 15, coins: 10, check: c => c.c.moments >= 10 },
-- { id: 'fifty_moments', for: 'chatter', name: 'Cannot Be Stopped', icon: '🌪️', desc: 'Fifty lines on the board.', xp: 50, coins: 50, check: c => c.c.moments >= 50 },
-- { id: 'three_subjects', for: 'chatter', name: 'Everywhere', icon: '🧭', desc: 'Pile onto three different subjects.', xp: 15, coins: 10, check: c => c.c.subjects >= 3 },
-- { id: 'starter', for: 'chatter', name: 'Instigator', icon: '🧨', desc: 'Start a subject.', xp: 20, coins: 20, check: c => c.c.subjects_started >= 1 },
-- { id: 'quoted', for: 'chatter', name: 'In The Lore', icon: '📜', desc: 'Get quoted in a subject\'s story.', xp: 15, coins: 15, check: c => c.c.quoted >= 1 },
-- { id: 'quoted5', for: 'chatter', name: 'Historian\'s Nightmare', icon: '📚', desc: 'Quoted five times.', xp: 40, coins: 40, check: c => c.c.quoted >= 5 },
-- { id: 'hot_take', for: 'chatter', name: 'Hot Take', icon: '🌶️', desc: 'Land a line on a subject while it\'s HOT.', xp: 10, coins: 10, check: c => c.c.hot_lines >= 1 },
-- { id: 'streak3', for: 'chatter', name: 'Three Days', icon: '📅', desc: 'Show up three days in a row.', xp: 15, coins: 15, check: c => c.c.best_streak >= 3 },
-- { id: 'streak7', for: 'chatter', name: 'Regular', icon: '🗓️', desc: 'Seven days in a row.', xp: 50, coins: 60, check: c => c.c.best_streak >= 7 },
-- { id: 'level5', for: 'chatter', name: 'Instigator Rank', icon: '⬆️', desc: 'Reach yap level 5.', xp: 25, coins: 25, check: c => c.c.level >= 5 },
-- { id: 'level10', for: 'chatter', name: 'Menace Rank', icon: '😈', desc: 'Reach yap level 10.', xp: 80, coins: 100, check: c => c.c.level >= 10 },
--];
--
--let _ready = false;
--function ensureTables() {
-- if (_ready) return;
-- db.run(`CREATE TABLE IF NOT EXISTS arena_achievements (
-- key TEXT NOT NULL,
-- achievement_id TEXT NOT NULL,
-- earned_at DATETIME DEFAULT CURRENT_TIMESTAMP,
-- PRIMARY KEY (key, achievement_id)
-- )`);
-- db.run(`CREATE TABLE IF NOT EXISTS arena_events (
-- id INTEGER PRIMARY KEY AUTOINCREMENT,
-- key TEXT NOT NULL,
-- kind TEXT NOT NULL,
-- title TEXT NOT NULL,
-- detail TEXT,
-- url TEXT,
-- created_at DATETIME DEFAULT CURRENT_TIMESTAMP
-- )`);
-- db.run('CREATE INDEX IF NOT EXISTS idx_arena_events_key ON arena_events (key, id)');
-- db.run(`CREATE TABLE IF NOT EXISTS arena_tier_paid (key TEXT NOT NULL, tier INTEGER NOT NULL, PRIMARY KEY (key, tier))`);
-- try { db.run('ALTER TABLE arena_topics ADD COLUMN peak_heat REAL DEFAULT 0'); } catch { /* exists */ }
-- _ready = true;
--}
--
--function tierFor(xp) { let t = 0; for (let i = 0; i < TIERS.length; i++) if ((xp || 0) >= TIERS[i][0]) t = i; const [min, name, color] = TIERS[t]; const next = TIERS[t + 1] || null; return { index: t, name, color, min, next: next ? { name: next[1], xp: next[0], color: next[2] } : null, progress: next ? Math.round(((xp - min) / (next[0] - min)) * 100) : 100 }; }
--
--/** History feed entry (also mirrored to the notification bell by callers when it matters). */
--function event(key, kind, title, { detail = null, url = null } = {}) {
-- ensureTables();
-- if (!key) return;
-- db.run('INSERT INTO arena_events (key, kind, title, detail, url) VALUES (?, ?, ?, ?, ?)', [key, kind, String(title).slice(0, 160), detail ? String(detail).slice(0, 240) : null, url]);
--}
--function events(key, limit = 30) { ensureTables(); return db.all('SELECT * FROM arena_events WHERE key = ? ORDER BY id DESC LIMIT ?', [key, limit]); }
--
--// ── Context for achievement checks (cheap aggregate queries) ──
--function fighterCtx(userId) {
-- const lv = db.get('SELECT * FROM arena_trash_levels WHERE user_id = ?', [userId]) || {};
-- const beefs = db.get(`SELECT SUM(winner_user_id = ?) AS wins, SUM(status = 'resolved' AND winner_user_id = ? AND upset = 1) AS upsets, SUM(status = 'resolved' AND winner_user_id = ? AND rematch = 1) AS rematch_wins, SUM(b_user_id = ? AND responded = 1) AS answered FROM arena_beefs WHERE a_user_id = ? OR b_user_id = ?`, [userId, userId, userId, userId, userId, userId]) || {};
-- let streak = 0; try { streak = require('./beef').streakFor(userId); } catch { /* */ }
-- const bounties = db.get(`SELECT COUNT(*) AS n FROM arena_xp_log WHERE user_id = ? AND reason = 'bounty_claimed'`, [userId])?.n || 0;
-- const hot = db.get(`SELECT COUNT(*) AS n FROM arena_topic_members m JOIN arena_topics t ON t.id = m.topic_id WHERE m.user_id = ? AND t.peak_heat >= 12`, [userId])?.n || 0;
-- const hypes = (db.get('SELECT COUNT(*) AS n FROM arena_topic_hype WHERE user_id = ?', [userId])?.n || 0) + (db.get(`SELECT COUNT(*) AS n FROM arena_beef_hype h JOIN arena_beefs b ON b.id = h.beef_id WHERE (h.side = 'a' AND b.a_user_id = ?) OR (h.side = 'b' AND b.b_user_id = ?)`, [userId, userId])?.n || 0);
-- return { xp: lv.xp || 0, beef_hits: lv.beef_hits || 0, topic_moments: lv.topic_moments || 0, wins: beefs.wins || 0, upsets: beefs.upsets || 0, rematch_wins: beefs.rematch_wins || 0, answered: beefs.answered || 0, streak, bounties, hot_subjects: hot, hypes_received: hypes };
--}
--function chatterCtx(key) {
-- const r = db.get('SELECT * FROM chatter_profiles WHERE key = ?', [key]) || {};
-- const hot = db.get(`SELECT COUNT(*) AS n FROM chatter_xp_log WHERE key = ? AND reason = 'moment_hot'`, [key])?.n || 0;
-- return { xp: r.xp || 0, level: r.level || 1, moments: r.moments || 0, subjects: r.subjects || 0, subjects_started: r.subjects_started || 0, quoted: r.quoted || 0, best_streak: r.best_streak || 0, hot_lines: hot };
--}
--
--/** Run every applicable achievement for a key; awards XP/coins/events/notifications for new ones. Returns the new ids. */
--function check(key, { streamId = null } = {}) {
-- ensureTables();
-- const p = String(key || '').split(':');
-- const isUser = p[0] === 'user';
-- const userId = isUser ? Number(p[1]) : null;
-- const onRoster = isUser && (() => { try { return !!require('./arena-service').loadRoster().byId[userId]; } catch { return false; } })();
-- const ctx = { f: onRoster ? fighterCtx(userId) : null, c: chatterCtx(key) };
-- const earned = new Set(db.all('SELECT achievement_id FROM arena_achievements WHERE key = ?', [key]).map(r => r.achievement_id));
-- const fresh = [];
-- for (const a of ACHIEVEMENTS) {
-- if (earned.has(a.id)) continue;
-- if (a.for === 'fighter' && !ctx.f) continue;
-- let ok = false; try { ok = !!a.check(ctx); } catch { ok = false; }
-- if (!ok) continue;
-- db.run('INSERT OR IGNORE INTO arena_achievements (key, achievement_id) VALUES (?, ?)', [key, a.id]);
-- fresh.push(a);
-- event(key, 'achievement', `${a.icon} ${a.name}`, { detail: a.desc, url: isUser && onRoster ? `/arena/${encodeURIComponent(db.getUserById(userId)?.username || '')}` : `/arena/chatter/${encodeURIComponent(key)}` });
-- // XP: fighters into Trash Level, everyone into their chatter profile (same key for accounts → one bar builds).
-- try { if (a.for === 'fighter') require('./board').addXp(userId, a.xp, `ach_${a.id}`); else require('./chatters').addXp(key, a.xp, `ach_${a.id}`, null, { streamId }); } catch { /* */ }
-- if (isUser && a.coins) { try { require('../monetization/opencoins').credit(userId, a.coins, `arena_ach_${a.id}`, `live:arena_ach:${userId}:${a.id}`, { achievement: a.id }); } catch { /* */ } }
-- if (isUser) { try { require('./notify').arenaNotify(userId, { type: 'achievement', title: `${a.icon} ${a.name}`, message: `${a.desc} +${a.xp} XP${a.coins ? ` · +${a.coins} OpenCoins` : ''}`, icon: a.icon, url: onRoster ? `/arena/${encodeURIComponent(db.getUserById(userId)?.username || '')}` : `/arena/chatter/${encodeURIComponent(key)}`, key: `ach:${a.id}` }); } catch { /* */ } }
-- console.log(`[Arena] ${key} unlocked ${a.name}`);
-- }
-- checkTier(key, ctx);
-- return fresh.map(a => a.id);
--}
--
--/** Tier-ups: pay once per tier (accounts: coins = tier index × 50), log + notify. */
--function checkTier(key, ctx) {
-- const xp = Math.max(ctx.f ? ctx.f.xp : 0, ctx.c ? ctx.c.xp : 0);
-- const t = tierFor(xp);
-- if (t.index === 0) return;
-- const paid = db.all('SELECT tier FROM arena_tier_paid WHERE key = ?', [key]).map(r => r.tier);
-- for (let i = 1; i <= t.index; i++) {
-- if (paid.includes(i)) continue;
-- db.run('INSERT OR IGNORE INTO arena_tier_paid (key, tier) VALUES (?, ?)', [key, i]);
-- const name = TIERS[i][1];
-- event(key, 'tier', `Reached ${name}`, { detail: `${TIERS[i][0]}+ XP all-time` });
-- const p = String(key).split(':');
-- if (p[0] === 'user') {
-- const userId = Number(p[1]);
-- try { require('../monetization/opencoins').credit(userId, i * 50, `arena_tier_${name.toLowerCase()}`, `live:arena_tier:${userId}:${i}`, { tier: name }); } catch { /* */ }
-- try { require('./notify').arenaNotify(userId, { type: 'tier', title: `${name} tier`, message: `Your arena rep passed ${TIERS[i][0]} XP. +${i * 50} OpenCoins.`, icon: '🏅', url: '/arena', key: `tier:${i}` }); } catch { /* */ }
-- }
-- }
--}
--
--/** Everything the profile needs: tier, achievements (earned + locked with hints), history, weekly XP. */
--function view(key) {
-- ensureTables();
-- const p = String(key || '').split(':');
-- const isUser = p[0] === 'user';
-- const userId = isUser ? Number(p[1]) : null;
-- const onRoster = isUser && (() => { try { return !!require('./arena-service').loadRoster().byId[userId]; } catch { return false; } })();
-- const fx = onRoster ? (db.get('SELECT xp FROM arena_trash_levels WHERE user_id = ?', [userId])?.xp || 0) : 0;
-- const cx = db.get('SELECT xp FROM chatter_profiles WHERE key = ?', [key])?.xp || 0;
-- const xp = Math.max(fx, cx);
-- const earned = new Map(db.all('SELECT achievement_id, earned_at FROM arena_achievements WHERE key = ?', [key]).map(r => [r.achievement_id, r.earned_at]));
-- const list = ACHIEVEMENTS.filter(a => a.for === 'chatter' || onRoster).map(a => ({ id: a.id, for: a.for, name: a.name, icon: a.icon, desc: a.desc, xp: a.xp, coins: isUser ? a.coins : 0, earned_at: earned.get(a.id) || null }));
-- const weekF = onRoster ? (db.get(`SELECT COALESCE(SUM(amount), 0) AS n FROM arena_xp_log WHERE user_id = ? AND created_at >= datetime('now', '-7 days')`, [userId])?.n || 0) : 0;
-- const weekC = db.get(`SELECT COALESCE(SUM(amount), 0) AS n FROM chatter_xp_log WHERE key = ? AND created_at >= datetime('now', '-7 days')`, [key])?.n || 0;
-- return { key, xp, tier: tierFor(xp), tiers: TIERS.map(([min, name, color]) => ({ min, name, color })), achievements: list, earned: list.filter(a => a.earned_at).length, total: list.length, week_xp: weekF + weekC, history: events(key, 30) };
--}
--
--/** Weekly fighters ladder (XP gained in 7 days). */
--function weeklyFighters(limit = 5) {
-- ensureTables();
-- const roster = require('./arena-service').loadRoster();
-- const board = require('./board');
-- return db.all(`SELECT user_id, SUM(amount) AS gained FROM arena_xp_log WHERE created_at >= datetime('now', '-7 days') GROUP BY user_id ORDER BY gained DESC LIMIT ?`, [limit]).filter(r => roster.byId[r.user_id]).map(r => ({ ...board.fighterBrief(r.user_id, roster), gained: r.gained, tier: tierFor(db.get('SELECT xp FROM arena_trash_levels WHERE user_id = ?', [r.user_id])?.xp || 0) }));
--}
--
--module.exports = { ensureTables, TIERS, ACHIEVEMENTS, tierFor, event, events, check, view, weeklyFighters, fighterCtx, chatterCtx };
ddiff --git a/server/arena/routes.js b/server/arena/routes.js
iindex 5f37f877..b4c572da 100644
---- a/server/arena/routes.js
++++ b/server/arena/routes.js
@@@ -1,16 +1,14 @@
/**
-- * OpenVibe.Live — Arena API (mounted at /api/arena)
++ * OpenVibe.Live — Arena API (mounted at /api/arena) — Battle Cam mode
*
* GET /status · /fighters · /fighters/:user · /fighters/:user/stat/:stat · /live
++ * GET /feed?limit&since the shit-talk feed: newest judged mic lines across every cam
* POST /fighters/:user/refresh admin: regenerate persona (+ portrait)
-- * GET /console/:user what the listener hears for a live fighter
-- * GET /board pulse + open events (topics/debates/phrases/bounties) + leaderboards
-- * POST /board/topics {text} signed in, 1 per person + per IP per 24 h; AI rewrites it
-- * GET /board/topics/:id · POST …/hype {user_id} (no joining — the ears auto-detect who talks on what)
-- * POST /board/bounty {username} put a bounty on a fighter
++ * GET /console/:user what the ears hear for a live fighter (focus lock, judgements)
* GET /beefs · /beefs/:id · POST /beefs/:id/hype {side}
-- * GET /levels · /yappers Trash Level ladder · viewers who keep subjects alive from chat
-- * GET /voice/:user?t=<text> the line read in that user's chat TTS voice (cached on disk + a week in the browser)
++ * GET /levels Trash Level ladder (XP from mic only)
++ * GET /me the signed-in fighter's own state (beefs on them, level, moments)
++ * GET /voice/:user?t=<text> the line read in that user's chat TTS voice
*/
'use strict';
@@@ -19,7 +17,7 @@ const { requireAuth, optionalAuth } = require('../auth/auth');
const permissions = require('../auth/permissions');
const db = require('../db/database');
const arena = require('./arena-service');
--const board = require('./board');
++const mic = require('./mic');
const beef = require('./beef');
const listener = require('./listener');
@@@ -32,10 +30,18 @@ const userFrom = (param) => (/^\d+$/.test(String(param)) ? db.getUserById(Number
router.get('/status', (req, res) => { try { res.json(arena.status()); } catch (err) { fail(res, err, 'Arena unavailable'); } });
router.get('/fighters', (req, res) => {
-- try { res.set('Cache-Control', 'public, max-age=30'); res.json({ fighters: arena.listFighters(), ai: arena.aiOn(), image_generation: arena.imageGenAvailable() }); }
++ try { res.set('Cache-Control', 'public, max-age=30'); res.json({ fighters: arena.listFighters(), ai: arena.aiOn(), image_generation: arena.imageGenAvailable(), stats: arena.STAT_KEYS, stat_meta: arena.STAT_META }); }
catch (err) { fail(res, err, 'Failed to load the roster'); }
});
router.get('/live', (req, res) => { try { res.set('Cache-Control', 'no-store'); res.json({ live: arena.liveFighters() }); } catch (err) { fail(res, err, 'Failed to load live fighters'); } });
++router.get('/feed', (req, res) => {
++ try {
++ res.set('Cache-Control', 'no-store');
++ const limit = Math.min(100, Math.max(1, parseInt(req.query.limit, 10) || 40));
++ const since = req.query.since ? Number(req.query.since) : null;
++ res.json({ feed: mic.feed({ limit, since }), ai: arena.aiOn() });
++ } catch (err) { fail(res, err, 'Failed to load the feed'); }
++});
router.get('/fighters/:user/stat/:stat', (req, res) => {
try {
@@@ -51,7 +57,11 @@ router.get('/fighters/:user', async (req, res) => {
try {
const card = await arena.getFighter(req.params.user, { generate: req.query.generate !== '0' });
if (!card) return res.status(404).json({ error: 'No such fighter' });
-- if (!card.not_on_roster) { try { card.rivalries = beef.rivalriesFor(card.user.id); } catch { card.rivalries = []; } try { card.progress = require('./progress').view(`user:${card.user.id}`); } catch { card.progress = null; } }
++ if (!card.not_on_roster) {
++ try { card.rivalries = beef.rivalriesFor(card.user.id); } catch { card.rivalries = []; }
++ try { card.moments = mic.momentsFor(card.user.id, 14); } catch { card.moments = []; }
++ try { card.best_lines = mic.bestLines(card.user.id, 5); } catch { card.best_lines = []; }
++ }
res.set('Cache-Control', 'no-store');
res.json(card);
} catch (err) { fail(res, err, 'Failed to load fighter'); }
@@@ -72,9 +82,8 @@ router.get('/console/:user', (req, res) => {
const user = userFrom(req.params.user);
if (!user) return res.status(404).json({ error: 'No such user' });
const roster = arena.loadRoster();
-- const brief = board.fighterBrief(user.id, roster);
++ const brief = mic.fighterBrief(user.id, roster);
const liveStream = db.get('SELECT id, title, started_at, viewer_count FROM streams WHERE user_id = ? AND is_live = 1 ORDER BY started_at DESC LIMIT 1', [user.id]);
-- const active = board.activeTopicFor(user.id);
let lines = [];
if (liveStream) {
lines = db.all(`SELECT text, start_sec, vod_id FROM stream_timeline_events WHERE stream_id = ? AND kind = 'speech' ORDER BY start_sec DESC LIMIT 14`, [liveStream.id]).reverse()
@@@ -85,61 +94,15 @@ router.get('/console/:user', (req, res) => {
fighter: brief, on_roster: !!roster.byId[user.id], live: !!liveStream, stream: liveStream || null,
transcribed: liveStream ? !!db.get(`SELECT 1 FROM stream_timeline_events WHERE stream_id = ? AND kind = 'speech' AND created_at >= datetime('now', '-30 minutes') LIMIT 1`, [liveStream.id]) : false,
listener: listener.consoleState(user.id),
-- level: board.levelView(user.id),
-- active_topic: active ? board.topicDetail(active.id) : null,
++ level: mic.levelView(user.id),
++ mic: mic.micStats(user.id),
open_beefs: beef.openBeefsFor(user.id).map(b => beef.beefView(b, roster)),
hot_mic: lines,
-- bounty_on_me: board.openBountyOn(user.id) ? board.topicDetail(board.openBountyOn(user.id).id) : null,
++ recent_moments: mic.momentsFor(user.id, 8),
});
} catch (err) { fail(res, err, 'Failed to load console'); }
});
--// ── Board ──
--router.get('/board', (req, res) => {
-- try {
-- const v = board.boardView();
-- res.set('Cache-Control', 'no-store');
-- const ch = require('./chatters'); let fightersWeek = []; try { fightersWeek = require('./progress').weeklyFighters(5); } catch { /* */ } res.json({ ...v, levels: board.levelsLeaderboard(8), fighters_week: fightersWeek, yappers: ch.leaderboard(8), yappers_week: ch.leaderboard(5, { days: 7 }), yappers_total: ch.count(), ai: arena.aiOn() });
-- } catch (err) { fail(res, err, 'Failed to load the board'); }
--});
--router.post('/board/topics', requireAuth, async (req, res) => {
-- try {
-- const onRoster = !!arena.loadRoster().byId[req.user.id];
-- const t = await board.submitTopic({ text: req.body?.text, userId: req.user.id, ip: req.ip, creatorName: req.user.display_name || req.user.username, onRoster });
-- if (!t.folded) board.buildLore(t.id, { force: true }).catch(() => {});
-- res.json({ ok: true, folded: !!t.folded, topic: board.topicDetail(t.id) });
-- } catch (err) { res.status(400).json({ error: err.message }); }
--});
--router.post('/board/bounty', requireAuth, (req, res) => {
-- try {
-- const target = userFrom(String(req.body?.username || ''));
-- if (!target || !arena.loadRoster().byId[target.id]) return res.status(404).json({ error: 'No such fighter on the roster' });
-- if (target.id === req.user.id) return res.status(400).json({ error: "You can't put a bounty on yourself" });
-- const name = board.fighterBrief(target.id, arena.loadRoster()).fighter_name;
-- board.assertCanSubmit(req.user.id, req.ip);
-- const t = board.createTopic({ text: `Bounty: ${name}`, hint: `Put up by ${req.user.display_name || req.user.username}. Say the name, collect the bag.`, createdBy: 'viewer', creatorUserId: req.user.id, creatorName: req.user.display_name || req.user.username, creatorIp: req.ip, kind: 'bounty', targetUserId: target.id, headline: `WANTED: ${name} — chat wants smoke`, tagline: `Double XP for anyone who talks shit about ${name} on stream` });
-- res.json({ ok: true, topic: board.topicDetail(t.id) });
-- } catch (err) { res.status(400).json({ error: err.message }); }
--});
--router.get('/board/topics/:id', (req, res) => {
-- try {
-- const t = board.topicDetail(Number(req.params.id));
-- if (!t) return res.status(404).json({ error: 'No such topic' });
-- res.set('Cache-Control', 'no-store');
-- res.json(t);
-- } catch (err) { fail(res, err, 'Failed to load topic'); }
--});
--router.post('/board/topics/:id/hype', optionalAuth, (req, res) => {
-- try { res.json(board.hypeTopic(Number(req.params.id), Number(req.body?.user_id), arena.voterKeyFor(req))); }
-- catch (err) { res.status(400).json({ error: err.message }); }
--});
--router.post('/pulse/refresh', requireAuth, permissions.requireAdmin, async (req, res) => {
-- try { const d = await board.discoverTopics({ force: true }); const lore = await board.loreSweep(5); res.json({ ...d, lore_rewritten: lore }); } catch (err) { fail(res, err, 'Pulse refresh failed'); }
--});
--router.post('/board/topics/:id/lore', requireAuth, permissions.requireAdmin, async (req, res) => {
-- try { res.json(await board.buildLore(Number(req.params.id), { force: true })); } catch (err) { fail(res, err, 'Lore rebuild failed'); }
--});
--
// ── Beefs ──
router.get('/beefs', (req, res) => { try { res.set('Cache-Control', 'no-store'); res.json(beef.list()); } catch (err) { fail(res, err, 'Failed to load beefs'); } });
router.get('/beefs/:id', (req, res) => {
@@@ -149,9 +112,8 @@ router.get('/beefs/:id', (req, res) => {
router.post('/beefs/:id/hype', optionalAuth, (req, res) => {
try { res.json(beef.hype(Number(req.params.id), String(req.body?.side || ''), arena.voterKeyFor(req))); } catch (err) { res.status(400).json({ error: err.message }); }
});
++
// ── Hear it in their voice ──
--// GET /voice/:user?t=<text> (:user = username, or "announcer"). Synthesized once per (voice, text),
--// cached on disk and by the browser (a week); the file streams back as audio.
router.get('/voice/:user', optionalAuth, async (req, res) => {
try {
const voice = require('./voice');
@@@ -167,42 +129,19 @@ router.get('/voice/:user', optionalAuth, async (req, res) => {
} catch (err) { res.status(err.message && /budget|hoarse/.test(err.message) ? 429 : 500).json({ error: err.message || 'Voice failed' }); }
});
--// ── Your Arena: the signed-in user's own state (yap level, streak, XP today, coins, beefs on them) ──
++// ── Your Arena: the signed-in fighter's own state ──
router.get('/me', requireAuth, (req, res) => {
try {
-- const ch = require('./chatters');
const roster = arena.loadRoster();
-- const key = `user:${req.user.id}`;
-- const chatter = ch.profile(key) || { ...ch.view({ key, kind: 'user', user_id: req.user.id, display_name: req.user.display_name || req.user.username, xp: 0, level: 1 }), recent_moments: [], top_subjects: [], xp_log: [] };
-- const xpToday = db.get(`SELECT COALESCE(SUM(amount), 0) AS n FROM chatter_xp_log WHERE key = ? AND created_at >= date('now')`, [key])?.n || 0;
-- const checkedIn = !!db.get(`SELECT 1 FROM chatter_xp_log WHERE key = ? AND reason = 'checkin' AND created_at >= date('now')`, [key]);
-- const paid = ch.row(key)?.coins_paid_level || 0; let coins = 0; for (let l = 2; l <= paid; l++) coins += l * ch.COINS_PER_LEVEL;
const onRoster = !!roster.byId[req.user.id];
-- const fighter = onRoster ? { ...board.fighterBrief(req.user.id, roster), level: board.levelView(req.user.id), record: beef.recordFor(req.user.id), power: roster.byId[req.user.id].ratings.power } : null;
++ const fighter = onRoster ? { ...mic.fighterBrief(req.user.id, roster), level: mic.levelView(req.user.id), record: beef.recordFor(req.user.id), power: roster.byId[req.user.id].ratings.power, mic: mic.micStats(req.user.id) } : null;
const beefs = onRoster ? beef.openBeefsFor(req.user.id).map(b => beef.beefView(b, roster)) : [];
-- const subjects = db.all(`SELECT t.id, t.headline, t.text, t.heat, s.moments FROM chatter_subjects s JOIN arena_topics t ON t.id = s.topic_id WHERE s.key = ? AND t.status = 'open' ORDER BY t.heat DESC LIMIT 5`, [key]);
const onClock = beefs.filter(b => (b.on_clock === 'a' ? b.a.user.id : b.b.user.id) === req.user.id);
res.set('Cache-Control', 'no-store');
-- let progress = null; try { progress = require('./progress').view(key); } catch { /* */ }
-- res.json({ chatter, progress, xp_today: xpToday, checked_in: checkedIn, coins_from_arena: coins, fighter, beefs, on_clock: onClock, subjects, hot_now: board.boardView().open.filter(t => t.hot).slice(0, 3).map(t => ({ id: t.id, headline: t.headline || t.text })) });
++ res.json({ on_roster: onRoster, fighter, beefs, on_clock: onClock, moments: onRoster ? mic.momentsFor(req.user.id, 6) : [], live: onRoster ? !!db.get('SELECT 1 FROM streams WHERE user_id = ? AND is_live = 1 LIMIT 1', [req.user.id]) : false });
} catch (err) { fail(res, err, 'Failed to load your arena'); }
});
--router.post('/checkin', requireAuth, (req, res) => {
-- try { res.json(require('./chatters').checkin(req.user.id, { display: req.user.display_name || req.user.username })); } catch (err) { fail(res, err, 'Check-in failed'); }
--});
--router.get('/progress/:key', (req, res) => { try { res.set('Cache-Control', 'no-store'); res.json(require('./progress').view(String(req.params.key || ''))); } catch (err) { fail(res, err, 'Failed'); } });
--router.get('/levels', (req, res) => { try { res.json({ levels: board.levelsLeaderboard(20) }); } catch (err) { fail(res, err, 'Failed'); } });
--router.get('/yappers', (req, res) => { try { const ch = require('./chatters'); res.json({ yappers: ch.leaderboard(20), week: ch.leaderboard(10, { days: 7 }), total: ch.count() }); } catch (err) { fail(res, err, 'Failed'); } });
--// Chatter (yapper) profile by key: user:<id> · anon:<n> · relay:<platform>:<name> — anyone who chats has one.
--router.get('/chatter/:key', (req, res) => {
-- try { const p = require('./chatters').profile(String(req.params.key || '')); if (!p) return res.status(404).json({ error: 'No such chatter yet — say something about a subject first' }); try { p.progress = require('./progress').view(p.key); } catch { p.progress = null; } res.set('Cache-Control', 'no-store'); res.json(p); }
-- catch (err) { fail(res, err, 'Failed to load chatter'); }
--});
--router.get('/chatter/by-user/:username', (req, res) => {
-- try { const u = userFrom(req.params.username); if (!u) return res.status(404).json({ error: 'No such user' }); const p = require('./chatters').profile(`user:${u.id}`); if (!p) return res.status(404).json({ error: 'No yap profile yet' }); res.json(p); }
-- catch (err) { fail(res, err, 'Failed'); }
--});
--router.post('/chatter/:key/card', requireAuth, permissions.requireAdmin, async (req, res) => { try { res.json(await require('./chatters').buildCard(String(req.params.key), { force: true })); } catch (err) { fail(res, err, 'Card failed'); } });
++router.get('/levels', (req, res) => { try { res.json({ levels: mic.levelsLeaderboard(20) }); } catch (err) { fail(res, err, 'Failed'); } });
module.exports = router;
ddiff --git a/server/chat/chat-server.js b/server/chat/chat-server.js
iindex 611e8d19..3aa913b8 100644
---- a/server/chat/chat-server.js
++++ b/server/chat/chat-server.js
@@@ -437,6 +437,9 @@ class ChatServer {
slur_filter_regexes: this._parseRegexLines(streamSettings.slur_filter_regexes),
slur_filter_nudge_message: String(streamSettings.slur_filter_nudge_message || ''),
min_auto_delete_minutes: MIN_CHAT_AUTO_DELETE_MINUTES,
++ // Language the channel lives in — the client shows "auto-translated for the
++ // streamer" when it isn't English (see server/i18n/translate.js).
++ channel_language: this._channelLanguage(client.channelUserId),
});
break;
}
@@@ -862,6 +865,9 @@ class ChatServer {
// Surface on the homepage global feed (tagged with the channel).
if (client.streamId) this.forwardToGlobal(client.streamId, chatMsg);
else this.forwardToGlobalByChannel(client.channelUserId, chatMsg);
++ // Auto-translate (async): foreign → English for everyone, English → the channel's
++ // language for a non-English streamer. Lands as a follow-up 'chat_translation'.
++ this._maybeTranslate(chatMsg, client.channelUserId, client.streamId);
}
if (client.streamId) {
@@@ -953,7 +959,7 @@ class ChatServer {
const parts = text.trim().split(/\s+/);
const cmd = parts[0].toLowerCase();
-- // Arena: !hype / !talk / !arena / !vote / !fight (server/arena/arena-chat.js)
++ // Arena: !hype / !beef / !arena (server/arena/arena-chat.js) — chat can only hype; the Arena is pure mic
try { if (require('../arena/arena-chat').handle(this, ws, client, cmd, parts)) return; } catch (e) { console.warn('[Arena] chat command:', e.message); }
if (cmd === '!gotti') {
@@@ -1953,6 +1959,38 @@ class ChatServer {
}
}
++ /** Language a channel lives in ('en' when unknown). Cached inside i18n. */
++ _channelLanguage(channelUserId) {
++ if (!channelUserId) return 'en';
++ try { return require('../i18n/translate').channelLanguage(channelUserId); } catch { return 'en'; }
++ }
++
++ /**
++ * Translate a just-broadcast chat line and push the translation to the same rooms as a
++ * 'chat_translation' event (clients attach it under the message by id). Persisted into
++ * chat_messages.metadata so history shows it too. Fire-and-forget; never throws.
++ */
++ _maybeTranslate(chatMsg, channelUserId, streamId) {
++ if (!chatMsg || !chatMsg.message || chatMsg.message_type && chatMsg.message_type !== 'chat') return;
++ let i18n;
++ try { i18n = require('../i18n/translate'); } catch { return; }
++ if (!i18n.available()) return;
++ let chanUid = channelUserId || null;
++ if (!chanUid && streamId) { try { chanUid = db.getStreamById(streamId)?.user_id || null; } catch { /* */ } }
++ const text = String(chatMsg.message).replace(/^\s*\.\s?/, ''); // ".msg" = tts-off marker
++ i18n.translateChatMessage(text, chanUid).then((tr) => {
++ if (!tr || !tr.text) return;
++ const evt = {
++ type: 'chat_translation', id: chatMsg.id || null, from: tr.from, to: tr.to, text: tr.text,
++ stream_id: streamId || null, channel_user_id: chanUid, timestamp: new Date().toISOString(),
++ };
++ this.broadcastToChannelRoom(chanUid, streamId, evt);
++ if (streamId) this.forwardToGlobal(streamId, evt);
++ else this.forwardToGlobalByChannel(chanUid, evt);
++ if (chatMsg.id) { try { db.mergeChatMessageMetadata(chatMsg.id, { translation: tr }); } catch { /* */ } }
++ }).catch(() => { /* best-effort */ });
++ }
++
broadcastToStream(streamId, data) {
const msg = JSON.stringify(data);
for (const [ws, client] of this.clients) {
ddiff --git a/server/config.js b/server/config.js
iindex 6446adce..305ef1a2 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -217,6 +217,22 @@ function buildConfig(registryValues) {
},
adminUsername: process.env.ADMIN_USERNAME || 'admin',
adminPassword: process.env.ADMIN_PASSWORD || 'changeme123',
++ // RobotStreamer switch bonus — the site-wide "$100 for converting" promo (public/js/rs-promo.js).
++ // RS_PROMO_ENABLED=false turns every promo surface off without a deploy of the frontend.
++ // Home-page "Star of OpenVibe" spotlight (username). The site setting `star_streamer`
++ // (admin settings API) overrides this env default.
++ starStreamer: process.env.STAR_STREAMER || '',
++ rsPromo: {
++ enabled: process.env.RS_PROMO_ENABLED !== 'false',
++ amount: parseInt(process.env.RS_PROMO_AMOUNT, 10) || 50, // top 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: parseInt(process.env.RS_PROMO_REFERRAL, 10) || 10,
++ 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',
++ discord: process.env.RS_PROMO_DISCORD || 'https://discord.gg/M6MuRUaeJj',
++ },
jwt: {
secret: process.env.JWT_SECRET || 'openvibelive-dev-secret-change-me',
expiresIn: '7d',
ddiff --git a/server/db/database.js b/server/db/database.js
iindex 7a2f73df..27d031ec 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -481,6 +481,23 @@ function initDb() {
}
} catch (e) { console.warn('[DB] Channel visibility migration:', e.message); }
++ // Channel language (chat translation direction + whisper language). 'auto' = detect from
++ // the streamer's bio/name (see server/i18n/translate.js). Speech rows gain the detected
++ // language + an English rendering so non-English streamers are readable site-wide.
++ try {
++ const chanCols = database.prepare("PRAGMA table_info('channels')").all().map(c => c.name);
++ if (!chanCols.includes('chat_language')) {
++ database.exec(`ALTER TABLE channels ADD COLUMN chat_language TEXT DEFAULT 'auto'`);
++ console.log('[DB] Added chat_language column to channels');
++ }
++ database.exec(`CREATE TABLE IF NOT EXISTS translations (
++ key TEXT PRIMARY KEY,
++ src TEXT, dst TEXT,
++ text TEXT NOT NULL,
++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
++ )`);
++ } catch (e) { console.warn('[DB] i18n migration:', e.message); }
++
// Migrate: add weather_zip / weather_detail to channels
try {
const wCols = database.prepare("PRAGMA table_info('channels')").all().map(c => c.name);
@@@ -1227,6 +1244,13 @@ function initDb() {
database.exec('CREATE INDEX IF NOT EXISTS idx_timeline_stream ON stream_timeline_events(stream_id, start_sec)');
database.exec('CREATE INDEX IF NOT EXISTS idx_timeline_kind ON stream_timeline_events(stream_id, kind, start_sec)');
database.exec('CREATE INDEX IF NOT EXISTS idx_timeline_vod ON stream_timeline_events(vod_id, start_sec)');
++ // i18n: language of the speech + an English rendering for non-English streamers (see
++ // server/i18n/translate.js). Added here, right after the table exists, so fresh databases
++ // and old ones both end up with the columns.
++ try {
++ const tlCols = database.prepare("PRAGMA table_info('stream_timeline_events')").all().map(c => c.name);
++ if (!tlCols.includes('lang')) { database.exec('ALTER TABLE stream_timeline_events ADD COLUMN lang TEXT'); database.exec('ALTER TABLE stream_timeline_events ADD COLUMN text_en TEXT'); console.log('[DB] Added lang/text_en columns to stream_timeline_events'); }
++ } catch (e) { console.warn('[DB] timeline i18n columns:', e.message); }
database.exec(`CREATE TABLE IF NOT EXISTS ai_usage (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@@ -3160,19 +3184,28 @@ function countStreamMemoriesByUser(userId) {
function addTimelineEvents(rows) {
if (!Array.isArray(rows) || !rows.length) return 0;
const stmt = db.prepare(`INSERT INTO stream_timeline_events
-- (stream_id, user_id, vod_id, kind, start_sec, end_sec, text, label, confidence)
-- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`);
++ (stream_id, user_id, vod_id, kind, start_sec, end_sec, text, label, confidence, lang, text_en)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
const tx = db.transaction((list) => {
for (const r of list) {
if (!r || !r.stream_id || !r.kind || r.start_sec == null) continue;
stmt.run(r.stream_id, r.user_id || null, r.vod_id || null, r.kind,
Number(r.start_sec) || 0, r.end_sec == null ? null : Number(r.end_sec),
-- r.text || null, r.label || null, r.confidence == null ? null : Number(r.confidence));
++ r.text || null, r.label || null, r.confidence == null ? null : Number(r.confidence),
++ r.lang || null, r.text_en || null);
}
});
try { tx(rows); return rows.length; } catch { return 0; }
}
++/** Newest speech rows for a live stream after a given row id (live captions feed). */
++function getTimelineSpeechSince(streamId, afterId = 0, limit = 40) {
++ return all(`SELECT id, start_sec, end_sec, text, lang, text_en, created_at
++ FROM stream_timeline_events
++ WHERE stream_id = ? AND kind = 'speech' AND id > ?
++ ORDER BY id DESC LIMIT ?`, [streamId, afterId || 0, Math.min(200, Math.max(1, limit))]).reverse();
++}
++
/** Read a stream's timeline, optionally filtered by kind and time window. */
function getTimeline(streamId, { kind = null, from = null, to = null, limit = 5000 } = {}) {
let sql = 'SELECT kind, start_sec, end_sec, text, label, confidence FROM stream_timeline_events WHERE stream_id = ?';
@@@ -4257,7 +4290,7 @@ function updateChannel(userId, fields) {
const updates = [];
const params = [];
for (const [key, val] of Object.entries(fields)) {
-- if (val !== undefined && ['title', 'description', 'category', 'tags', 'protocol', 'is_nsfw', 'force_nsfw', 'auto_record', 'vod_recording_enabled', 'force_vod_recording_disabled', 'offline_banner_url', 'panels', 'emote_sources', 'weather_zip', 'weather_detail', 'weather_show_location', 'control_mode', 'anon_controls_enabled', 'control_rate_limit_ms', 'active_control_config_id', 'video_click_enabled', 'offline_screen_type', 'offline_screen_url', 'offline_html', 'offline_css', 'hide_ai_overview', 'ai_overview_pref'].includes(key)) {
++ if (val !== undefined && ['title', 'description', 'category', 'tags', 'protocol', 'is_nsfw', 'force_nsfw', 'auto_record', 'vod_recording_enabled', 'force_vod_recording_disabled', 'offline_banner_url', 'panels', 'emote_sources', 'weather_zip', 'weather_detail', 'weather_show_location', 'control_mode', 'anon_controls_enabled', 'control_rate_limit_ms', 'active_control_config_id', 'video_click_enabled', 'offline_screen_type', 'offline_screen_url', 'offline_html', 'offline_css', 'hide_ai_overview', 'ai_overview_pref', 'chat_language'].includes(key)) {
updates.push(`${key} = ?`);
params.push(['tags', 'panels', 'emote_sources'].includes(key) ? (typeof val === 'string' ? val : JSON.stringify(val)) : val);
}
@@@ -7196,6 +7229,17 @@ function getChatMessageById(id) {
return get('SELECT * FROM chat_messages WHERE id = ?', [id]);
}
++/** Shallow-merge a JSON patch into chat_messages.metadata (e.g. an async translation). */
++function mergeChatMessageMetadata(id, patch) {
++ if (!id || !patch || typeof patch !== 'object') return;
++ const row = get('SELECT metadata FROM chat_messages WHERE id = ?', [id]);
++ if (!row) return;
++ let meta = {};
++ try { meta = row.metadata ? JSON.parse(row.metadata) : {}; } catch { meta = {}; }
++ if (!meta || typeof meta !== 'object' || Array.isArray(meta)) meta = {};
++ return run('UPDATE chat_messages SET metadata = ? WHERE id = ?', [JSON.stringify({ ...meta, ...patch }), id]);
++}
++
/**
* Soft-delete a chat message by ID. Sets is_deleted=1 and records who deleted it.
*/
@@@ -8008,6 +8052,7 @@ module.exports = {
getVodAiState, getClipAiState,
scheduleClipNotifyState, bumpClipNotifyNowState, markClipNotifiedState, getDueClipNotifies,
getDb, initDb, run, get, all, close,
++ mergeChatMessageMetadata, getTimelineSpeechSince,
getDonationGoalsForWidget, getAllDonationGoals, getActiveDonationGoals, getDonationGoalById,
recordViewerSample, getViewerTrend, getHomePulse, getActiveGoalsForUsers,
createDonationGoal, updateDonationGoal, deleteDonationGoal, addToDonationGoal,
ddiff --git a/server/home/routes.js b/server/home/routes.js
iindex 710d2183..707fb8e1 100644
---- a/server/home/routes.js
++++ b/server/home/routes.js
@@@ -206,6 +206,54 @@ router.get('/hero', async (req, res) => {
}
});
++// ── Star of OpenVibe: one featured streamer, spotlighted on the home page ──────────
++// Picked by the `star_streamer` site setting (falls back to STAR_STREAMER in the env).
++// Ships the bio in its own language AND in English for non-English streamers, plus the
++// live stream (stream-card shape) when they're on. Cached ~20s.
++let _starCache = { at: 0, key: '', data: null };
++router.get('/star', async (req, res) => {
++ try {
++ res.set('Cache-Control', 'public, max-age=20');
++ const config = require('../config');
++ let uname = '';
++ try { uname = String(db.getSetting('star_streamer') || '').trim(); } catch { uname = ''; }
++ if (!uname) uname = String(config.starStreamer || '').trim();
++ if (!uname) return res.json({ star: null });
++ if (Date.now() - _starCache.at < 20_000 && _starCache.key === uname.toLowerCase() && _starCache.data) return res.json(_starCache.data);
++
++ const user = db.getUserByUsername(uname);
++ if (!user) return res.json({ star: null });
++ const i18n = require('../i18n/translate');
++ const channel = db.getChannelByUserId(user.id) || {};
++ const code = i18n.channelLanguage(user.id);
++ const live = (db.getLiveStreams() || []).find(s => s.user_id === user.id) || null;
++ let liveSafe = null;
++ if (live) { const { managed_stream_key, stream_key, ...safe } = live; liveSafe = safe; }
++ const bio = String(user.bio || '').trim();
++ let bio_en = null;
++ if (bio && code !== 'en') { try { bio_en = await i18n.translate(bio, { from: code, to: 'en', context: 'bio' }); } catch { bio_en = null; } }
++ let follower_count = null;
++ try { follower_count = typeof db.getFollowerCount === 'function' ? db.getFollowerCount(user.id) : null; } catch { follower_count = null; }
++ let last_stream = null;
++ try { last_stream = (db.getRecentStreams(60) || []).find(s => s.user_id === user.id) || null; } catch { last_stream = null; }
++ const data = {
++ star: {
++ username: user.username, display_name: user.display_name || user.username,
++ avatar_url: user.avatar_url || null, profile_color: user.profile_color || null,
++ bio, bio_en, language: { code, name: i18n.langName(code), flag: i18n.langFlag(code) },
++ ai_overview: channel.ai_overview || null, category: channel.ai_category || channel.category || null,
++ follower_count, live: liveSafe,
++ last_live_at: live ? null : (last_stream && (last_stream.ended_at || last_stream.started_at)) || null,
++ },
++ };
++ _starCache = { at: Date.now(), key: uname.toLowerCase(), data };
++ res.json(data);
++ } catch (err) {
++ console.error('[Home] star error:', err.message);
++ res.status(500).json({ error: 'Failed to load star streamer' });
++ }
++});
++
// ── Community pulse: goals near completion, latest activity, weekly leaders,
// AI moment showcase, and the newest shipped update. Cached ~30s server-side.
let _pulseCache = { at: 0, data: null };
ddiff --git a/server/i18n/routes.js b/server/i18n/routes.js
nnew file mode 100644
iindex 00000000..2d7cc0ad
---- /dev/null
++++ b/server/i18n/routes.js
@@@ -0,0 +1,53 @@
++/**
++ * i18n/routes.js — on-demand translation for viewers (mounted at /api/i18n).
++ *
++ * POST /api/i18n/translate { text, to } → { from, to, text } (text null when nothing to do)
++ *
++ * The chat auto-translation (server/i18n/translate.js) covers the two automatic directions
++ * (foreign → English for everyone, English → the streamer's language). This route is the
++ * third: ANY viewer can ask for ANY line in THEIR language — an English viewer on a Spanish
++ * stream, a Japanese viewer on an English stream, a Korean viewer reading JapaneseOldGuy.
++ * Cached like everything else, budget-metered, and rate-limited per IP so it cannot be used
++ * as a free translation API.
++ */
++'use strict';
++
++const express = require('express');
++const { optionalAuth } = require('../auth/auth');
++const i18n = require('./translate');
++
++const router = express.Router();
++const MAX_CHARS = 600;
++const _hits = new Map(); // ip → [timestamps]
++function allow(ip, signedIn) {
++ const now = Date.now(), win = 60_000, max = signedIn ? 40 : 15;
++ const list = (_hits.get(ip) || []).filter(t => now - t < win);
++ if (list.length >= max) { _hits.set(ip, list); return false; }
++ list.push(now); _hits.set(ip, list);
++ if (_hits.size > 5000) for (const [k, v] of _hits) if (!v.length || now - v[v.length - 1] > win) _hits.delete(k);
++ return true;
++}
++
++router.get('/languages', (req, res) => {
++ res.set('Cache-Control', 'public, max-age=3600');
++ res.json({ languages: Object.entries(i18n.LANG_NAMES).map(([code, name]) => ({ code, name, flag: i18n.langFlag(code) })), available: i18n.available() });
++});
++
++router.post('/translate', optionalAuth, async (req, res) => {
++ try {
++ if (!i18n.available()) return res.status(503).json({ error: 'Translation is off right now' });
++ if (!allow(String(req.ip || ''), !!req.user)) return res.status(429).json({ error: 'Slow down — too many translations' });
++ const text = String(req.body?.text || '').trim().slice(0, MAX_CHARS);
++ const to = String(req.body?.to || 'en').trim().toLowerCase();
++ if (!text) return res.status(400).json({ error: 'Nothing to translate' });
++ if (!i18n.isAllowedLang(to) || to === 'auto') return res.status(400).json({ error: 'Unknown target language' });
++ const from = i18n.detectLang(text) || 'auto';
++ if (from === to) return res.json({ from, to, text: null, same: true });
++ const out = await i18n.translate(text, { from, to, context: 'chat' });
++ res.json({ from, to, text: out || null, from_name: i18n.langName(from), to_name: i18n.langName(to) });
++ } catch (err) {
++ res.status(500).json({ error: 'Translation failed' });
++ }
++});
++
++module.exports = router;
ddiff --git a/server/i18n/translate.js b/server/i18n/translate.js
nnew file mode 100644
iindex 00000000..64209fd6
---- /dev/null
++++ b/server/i18n/translate.js
@@@ -0,0 +1,261 @@
++/**
++ * i18n/translate.js — language detection + LLM translation for chat, bios and live speech.
++ *
++ * Built for streamers who don't stream in English (JapaneseOldGuy was the first): the
++ * owner couldn't read him, and his bio asks viewers not to type English. This module is
++ * the ONE place that answers three questions:
++ *
++ * detectLang(text) → 'ja' | 'ko' | 'zh' | 'ru' | … | 'en' (latin script) | null
++ * channelLanguage(userId) → the language a channel lives in: the streamer's explicit
++ * channels.chat_language, else detected from their bio/name.
++ * translate(text, {from, to}) → cached LLM translation (memory LRU + `translations` table)
++ *
++ * On top of those, translateChatMessage() picks the DIRECTION for a chat line:
++ * - a non-English message → English (so the owner + English viewers can read it)
++ * - an English message in a non-English channel → the channel's language
++ * (so the streamer can read his own chat without anyone typing Japanese)
++ *
++ * Every call is best-effort and metered through ai/llm.js (shared key, daily budget); when
++ * AI is off it all silently returns null and the site behaves exactly as before.
++ */
++'use strict';
++const crypto = require('crypto');
++const db = require('../db/database');
++let llm = null;
++try { llm = require('../ai/llm'); } catch { llm = null; }
++
++const LANG_NAMES = {
++ en: 'English', ja: 'Japanese', ko: 'Korean', zh: 'Chinese', ru: 'Russian', uk: 'Ukrainian', ar: 'Arabic',
++ th: 'Thai', he: 'Hebrew', el: 'Greek', hi: 'Hindi', es: 'Spanish', pt: 'Portuguese', fr: 'French',
++ de: 'German', it: 'Italian', tr: 'Turkish', vi: 'Vietnamese', id: 'Indonesian', pl: 'Polish', nl: 'Dutch',
++};
++const LANG_FLAGS = { en: '🇺🇸', ja: '🇯🇵', ko: '🇰🇷', zh: '🇨🇳', ru: '🇷🇺', uk: '🇺🇦', ar: '🇸🇦', th: '🇹🇭', he: '🇮🇱', el: '🇬🇷', hi: '🇮🇳', es: '🇪🇸', pt: '🇧🇷', fr: '🇫🇷', de: '🇩🇪', it: '🇮🇹', tr: '🇹🇷', vi: '🇻🇳', id: '🇮🇩', pl: '🇵🇱', nl: '🇳🇱' };
++const ALLOWED = new Set(['auto', ...Object.keys(LANG_NAMES)]);
++
++function langName(code) { return LANG_NAMES[code] || code || 'English'; }
++function langFlag(code) { return LANG_FLAGS[code] || '🌐'; }
++function isAllowedLang(code) { return ALLOWED.has(String(code || '')); }
++
++// ── Detection (script-based; no model call) ─────────────────────────────────────
++// Latin script is reported as 'en' — we cannot tell English from Spanish without a model,
++// and for direction-picking "latin vs the channel's script" is what matters.
++function detectLang(text) {
++ const s = String(text || '');
++ const count = (re) => (s.match(re) || []).length;
++ const kana = count(/[\u3040-\u30ff\uff66-\uff9f]/g);
++ const han = count(/[\u4e00-\u9fff\u3400-\u4dbf]/g);
++ const hangul = count(/[\uac00-\ud7af\u1100-\u11ff\u3130-\u318f]/g);
++ const cyr = count(/[\u0400-\u04ff]/g);
++ const arab = count(/[\u0600-\u06ff]/g);
++ const thai = count(/[\u0e00-\u0e7f]/g);
++ const heb = count(/[\u0590-\u05ff]/g);
++ const greek = count(/[\u0370-\u03ff]/g);
++ const deva = count(/[\u0900-\u097f]/g);
++ const latin = count(/[A-Za-z\u00c0-\u024f]/g);
++ const nonLatin = kana + han + hangul + cyr + arab + thai + heb + greek + deva;
++ if (!nonLatin && !latin) return null;
++ if (!nonLatin) return 'en';
++ if (nonLatin / (nonLatin + latin) < 0.34) return 'en'; // "lol 草" — mostly English
++ if (kana) return 'ja';
++ if (hangul) return 'ko';
++ if (han) return 'zh';
++ if (cyr) return 'ru';
++ if (arab) return 'ar';
++ if (thai) return 'th';
++ if (heb) return 'he';
++ if (greek) return 'el';
++ if (deva) return 'hi';
++ return 'en';
++}
++
++/**
++ * The first non-English language found in any LINE of a longer text. A bio like
++ * "I don't speak English … おっさんの垂れ流し配信" is mostly Latin letters overall, so whole-text
++ * detection says 'en'; per line, the Japanese line wins — which is the right answer for
++ * "what language does this streamer live in".
++ */
++function detectForeignInText(text) {
++ const lines = String(text || '').split(/[\n\r]+|(?<=[。!?.!?])\s+/);
++ for (const line of lines) {
++ const l = detectLang(line);
++ if (l && l !== 'en') return l;
++ }
++ return null;
++}
++
++/** Is there anything worth translating (letters, not just emotes/links/numbers)? */
++function translatable(text) {
++ const s = String(text || '').trim();
++ if (s.length < 2 || s.length > 1200) return false;
++ const stripped = s
++ .replace(/https?:\/\/\S+/gi, '')
++ .replace(/:[a-z0-9_]+:/gi, '') // :emote:
++ .replace(/[!/]\w+/g, '') // !commands /commands
++ .replace(/@\w+/g, '');
++ return /\p{L}{2,}/u.test(stripped);
++}
++
++// ── Channel language ─────────────────────────────────────────────────────────────
++const _chanCache = new Map(); // userId → { lang, at }
++function channelLanguage(userId) {
++ const id = parseInt(userId, 10);
++ if (!id) return 'en';
++ const hit = _chanCache.get(id);
++ if (hit && Date.now() - hit.at < 5 * 60_000) return hit.lang;
++ let lang = 'en';
++ try {
++ const ch = db.getChannelByUserId(id);
++ const explicit = String(ch?.chat_language || 'auto').toLowerCase();
++ if (explicit !== 'auto' && ALLOWED.has(explicit)) lang = explicit;
++ else {
++ const u = db.getUserById(id);
++ // Bio wins (it is where "I don't speak English" lives); a name/title only if it is
++ // clearly in another script.
++ const bioLang = detectForeignInText(u?.bio || '');
++ const nameLang = detectLang(`${u?.display_name || ''} ${ch?.title || ''}`);
++ lang = bioLang || ((nameLang && nameLang !== 'en') ? nameLang : 'en');
++ }
++ } catch { lang = 'en'; }
++ _chanCache.set(id, { lang, at: Date.now() });
++ return lang;
++}
++function invalidateChannel(userId) { _chanCache.delete(parseInt(userId, 10)); }
++/** { code, name, flag, explicit } for API responses. */
++function channelMeta(userId) {
++ const code = channelLanguage(userId);
++ let explicit = false;
++ try { const ch = db.getChannelByUserId(parseInt(userId, 10)); explicit = !!(ch && ch.chat_language && ch.chat_language !== 'auto'); } catch { /* */ }
++ return { code, name: langName(code), flag: langFlag(code), explicit, translate: available() };
++}
++
++// ── Translation (LLM, cached) ────────────────────────────────────────────────────
++function siteEnabled() {
++ try {
++ const v = db.getSetting('chat_translate_enabled');
++ if (v === undefined || v === null || v === '') return true; // default ON
++ return !(String(v) === 'false' || String(v) === '0');
++ } catch { return true; }
++}
++function available() { return !!(llm && llm.isEnabled && llm.isEnabled() && siteEnabled()); }
++
++let _tableReady = false;
++function _ensureTable() {
++ if (_tableReady) return;
++ try {
++ db.getDb().exec(`CREATE TABLE IF NOT EXISTS translations (
++ key TEXT PRIMARY KEY,
++ src TEXT, dst TEXT,
++ text TEXT NOT NULL,
++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
++ )`);
++ _tableReady = true;
++ } catch { /* read-only db or race — memory cache still works */ }
++}
++const _mem = new Map(); // key → text (LRU-ish, capped)
++const MEM_MAX = 1500;
++function _memGet(k) { const v = _mem.get(k); if (v !== undefined) { _mem.delete(k); _mem.set(k, v); } return v; }
++function _memSet(k, v) { _mem.set(k, v); if (_mem.size > MEM_MAX) _mem.delete(_mem.keys().next().value); }
++function cacheKey(text, from, to) { return crypto.createHash('sha1').update(`${from}|${to}|${String(text).trim()}`).digest('hex'); }
++
++let _inflight = 0;
++const MAX_INFLIGHT = 3;
++const _queue = [];
++const MAX_QUEUE = 60;
++function _slot() {
++ if (_inflight < MAX_INFLIGHT) { _inflight++; return Promise.resolve(true); }
++ if (_queue.length >= MAX_QUEUE) return Promise.resolve(false); // shed load: the line simply stays untranslated
++ return new Promise((resolve) => _queue.push(resolve));
++}
++function _release() {
++ const next = _queue.shift();
++ if (next) next(true); else _inflight = Math.max(0, _inflight - 1);
++}
++
++/**
++ * Translate `text` from → to. Returns the translated string or null (AI off, over budget,
++ * nothing to translate, provider failure). `context` lets the prompt know the kind of
++ * text ('chat' | 'bio' | 'speech') so tone is preserved appropriately.
++ */
++async function translate(text, { from = 'auto', to = 'en', context = 'chat', maxTokens } = {}) {
++ if (!translatable(text)) return null;
++ if (from === to) return null;
++ const key = cacheKey(text, from, to);
++ const hit = _memGet(key);
++ if (hit !== undefined) return hit;
++ _ensureTable();
++ try {
++ const row = db.get('SELECT text FROM translations WHERE key = ?', [key]);
++ if (row && row.text) { _memSet(key, row.text); return row.text; }
++ } catch { /* no table yet */ }
++ if (!available()) return null;
++ const ok = await _slot();
++ if (!ok) return null;
++ try {
++ const src = from === 'auto' ? 'the source language' : langName(from);
++ const kind = context === 'bio' ? 'a streamer\'s profile bio'
++ : context === 'speech' ? 'lines of live-stream speech (a casual gamer talking to his chat)'
++ : 'a live-stream chat message';
++ const system = [
++ `You translate ${kind} from ${src} to ${langName(to)}.`,
++ 'Rules: keep the tone, slang, jokes, profanity and emoji as they are; keep names, URLs, :emotes:, !commands and @mentions unchanged;',
++ 'never add explanations, notes, quotes or brackets; if it is already in the target language, return it unchanged.',
++ context === 'speech' ? 'Input may contain several lines separated by newlines — return exactly the same number of lines, in order.' : '',
++ 'Output ONLY the translation.',
++ ].filter(Boolean).join(' ');
++ const r = await llm.complete({
++ role: 'chat', kind: 'translate', source: `translate:${context}`,
++ system, user: String(text).trim(),
++ maxTokens: Math.max(40, Math.min(maxTokens || 600, Math.round(String(text).length * 2.2) + 60)),
++ temperature: 0.2, timeoutMs: 15000, retries: 0,
++ });
++ const out = r && typeof r.text === 'string' ? r.text.trim().replace(/^["“]|["”]$/g, '') : '';
++ if (!out) return null;
++ // A translation identical to the input means it was already in the target language.
++ if (out === String(text).trim()) { _memSet(key, ''); return null; }
++ _memSet(key, out);
++ try { db.run('INSERT OR REPLACE INTO translations (key, src, dst, text) VALUES (?, ?, ?, ?)', [key, from, to, out]); } catch { /* */ }
++ return out;
++ } catch { return null; }
++ finally { _release(); }
++}
++
++/**
++ * Pick the direction for a chat line in a channel and translate it.
++ * @returns {Promise<{from:string,to:string,text:string}|null>}
++ */
++async function translateChatMessage(message, channelUserId) {
++ if (!available() || !translatable(message)) return null;
++ const from = detectLang(message);
++ if (!from) return null;
++ const chan = channelLanguage(channelUserId);
++ let to = null;
++ if (from !== 'en') to = 'en'; // foreign → English, always
++ else if (chan !== 'en') to = chan; // English in a Japanese channel → Japanese
++ if (!to || to === from) return null;
++ const text = await translate(message, { from, to, context: 'chat' });
++ return text ? { from, to, text } : null;
++}
++
++/**
++ * Translate an array of speech lines (one LLM call), returning an array aligned to the
++ * input (null where a line could not be translated).
++ */
++async function translateLines(lines, { from, to = 'en' } = {}) {
++ const src = (lines || []).map(l => String(l || '').trim());
++ const out = new Array(src.length).fill(null);
++ const idx = src.map((l, i) => translatable(l) ? i : -1).filter(i => i >= 0);
++ if (!idx.length || !available()) return out;
++ const joined = idx.map(i => src[i].replace(/\s*\n\s*/g, ' ')).join('\n');
++ const t = await translate(joined, { from, to, context: 'speech', maxTokens: 1200 });
++ if (!t) return out;
++ const parts = t.split('\n').map(x => x.trim()).filter(Boolean);
++ if (parts.length === idx.length) { idx.forEach((i, k) => { out[i] = parts[k]; }); return out; }
++ // Line count drifted — fall back to one call per line (bounded).
++ for (const i of idx.slice(0, 8)) out[i] = await translate(src[i], { from, to, context: 'speech' });
++ return out;
++}
++
++module.exports = {
++ detectLang, detectForeignInText, translatable, channelLanguage, channelMeta, invalidateChannel, translate, translateChatMessage, translateLines,
++ langName, langFlag, isAllowedLang, available, siteEnabled, LANG_NAMES, LANG_FLAGS,
++};
ddiff --git a/server/index.js b/server/index.js
iindex f1839b96..bf50dd80 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -562,6 +562,7 @@ app.use('/api/game', (req, res) => res.status(410).json({ error: 'Game has moved
app.use('/api/meta', metaRoutes);
app.use('/api/pastes', pasteRoutes);
app.use('/api/home', require('./home/routes'));
++app.use('/api/i18n', require('./i18n/routes')); // on-demand translation for viewers (any line → your language)
app.use('/api/kiosk', require('./kiosk/routes'));
// Song-request queue (watch-party) — stays Live-local (OpenVibe.Media does not
// carry the downloader/queue subsystem). Spends OpenCoins via the Network wallet.
@@@ -607,6 +608,15 @@ app.get('/api/health', (req, res) => {
});
});
++// ── RobotStreamer switch-bonus promo (public) ────────────────
++// Read by public/js/rs-promo.js on every page load so the promo can be paused or retuned
++// from the env (RS_PROMO_*) without touching the frontend.
++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 });
++});
++
// ── Updates / Changelog ──────────────────────────────────────
const { execSync } = require('child_process');
const REPO_DIR = path.resolve(__dirname, '..');
ddiff --git a/server/streaming/routes.js b/server/streaming/routes.js
iindex 26f92a80..327543f6 100644
---- a/server/streaming/routes.js
++++ b/server/streaming/routes.js
@@@ -28,6 +28,7 @@ const { requireAuth, requireStreamer, optionalAuth } = require('../auth/auth');
const jsmpegRelay = require('./jsmpeg-relay');
const webrtcSFU = require('./webrtc-sfu');
const recorder = require('./recorder');
++const i18n = require('../i18n/translate');
const robotStreamerService = require('../integrations/robotstreamer-service');
const chatRelayService = require('../integrations/chat-relay-service');
const chatServer = require('../chat/chat-server');
@@@ -466,6 +467,9 @@ router.get('/channel/:username', optionalAuth, async (req, res) => {
clipsTakenTotal,
aiEventTotal,
channel: publicChannel,
++ // The language this channel lives in (explicit setting or detected from the bio) —
++ // drives the chat auto-translation notice, the live-captions panel and bio translation.
++ language: i18n.channelMeta(channel.user_id),
stream: liveStreams[0] || null,
streams: liveStreams,
managed_streams: managedStreams,
@@@ -679,6 +683,11 @@ router.put('/channel', requireAuth, (req, res) => {
const p = String(req.body.ai_overview_pref || 'auto').trim();
if (['auto', 'show', 'hide'].includes(p)) fields.ai_overview_pref = p;
}
++ // Chat/stream language: 'auto' (detect from bio) or an ISO code from i18n.LANG_NAMES.
++ if (hasOwn(req.body, 'chat_language')) {
++ const l = String(req.body.chat_language || 'auto').trim().toLowerCase();
++ if (i18n.isAllowedLang(l)) { fields.chat_language = l; i18n.invalidateChannel(req.user.id); }
++ }
// Offline screen config (asset is uploaded separately at /channel/offline-screen)
if (hasOwn(req.body, 'offline_screen_type')) {
@@@ -701,6 +710,24 @@ router.put('/channel', requireAuth, (req, res) => {
}
});
++// ── Bio in English (for non-English streamers) ────────────────
++// Cached by content hash in the translations table; null when the bio is already English
++// or AI translation is unavailable.
++router.get('/channel/:username/bio-en', async (req, res) => {
++ try {
++ const user = db.getUserByUsername(req.params.username);
++ if (!user) return res.status(404).json({ error: 'Channel not found' });
++ const bio = String(user.bio || '').trim();
++ const from = i18n.detectForeignInText(bio); // any non-English line makes the bio worth translating
++ res.set('Cache-Control', 'public, max-age=300');
++ if (!bio || !from) return res.json({ from: null, to: 'en', text: null });
++ const text = await i18n.translate(bio, { from, to: 'en', context: 'bio' });
++ res.json({ from, to: 'en', text: text || null, from_name: i18n.langName(from), flag: i18n.langFlag(from) });
++ } catch (err) {
++ res.status(500).json({ error: 'Failed to translate bio' });
++ }
++});
++
// ── Save a channel's About section (bio + panels) ────────────
// Editable by the channel owner, and by channel moderators when the streamer has
// enabled `mods_can_edit_about`. Targets the channel by username so a mod writes
ddiff --git a/test/arena-beef-board.test.js b/test/arena-beef-board.test.js
ddeleted file mode 100644
iindex a0bd21f5..00000000
---- a/test/arena-beef-board.test.js
++++ /dev/null
@@@ -1,381 +0,0 @@
--'use strict';
--
--// Arena v2 — beefs + the board + the listener + chat commands, end-to-end on a temp DB with no AI
--// configured (template headlines, fallback angles, heuristic judges — no LLM calls).
--
--const assert = require('assert');
--const fs = require('fs');
--const os = require('os');
--const path = require('path');
--const http = require('http');
--
--const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-arena2-'));
--process.env.DB_PATH = path.join(tmp, 'live.db');
--process.env.ARENA_IMAGE_PATH = path.join(tmp, 'arena');
--
--const db = require('../server/db/database');
--db.initDb();
--const arena = require('../server/arena/arena-service');
--const board = require('../server/arena/board');
--const beef = require('../server/arena/beef');
--const listener = require('../server/arena/listener');
--const chat = require('../server/arena/arena-chat');
--
--const mk = (username) => Number(db.createUser({ username, email: `${username}@x`, password_hash: 'x', display_name: username[0].toUpperCase() + username.slice(1), stream_key: username.padEnd(32, '0') }).lastInsertRowid);
--const u1 = mk('nova'), u2 = mk('grizzly_bear'), u3 = mk('pixelqueen'), viewer = mk('viewer');
--for (const uid of [u1, u2, u3, viewer]) db.ensureChannel(uid);
--const stream = (uid, peak, hours, daysAgo) => {
-- const id = Number(db.createStream({ user_id: uid, title: `${uid} stream`, category: 'irl', protocol: 'rtmp' }).lastInsertRowid);
-- db.run(`UPDATE streams SET is_live = 0, started_at = datetime('now', ?), ended_at = datetime('now', ?), duration_seconds = ?, peak_viewers = ? WHERE id = ?`, [`-${daysAgo} days`, `-${daysAgo} days`, Math.round(hours * 3600), peak, id]);
-- db.run(`INSERT INTO stream_analytics (stream_id, avg_viewers, peak_viewers, unique_chatters, total_messages, total_watch_minutes, new_followers, clips_created, coins_earned) VALUES (?, ?, ?, ?, ?, ?, 0, ?, 0)`, [id, peak / 2, peak, peak, peak * 20, peak * 60, Math.round(hours)]);
-- return id;
--};
--stream(u1, 120, 6, 2); stream(u2, 60, 5, 1); stream(u3, 20, 2, 3);
--const roster = arena.loadRoster(true);
--assert.deepStrictEqual(roster.order, [u1, u2, u3], 'roster by power');
--board.ensureTables(); beef.ensureTables();
--
--(async () => {
-- // ── Board: subjects with keywords, moments from chat + mic, lore, Trash Level ──
-- board._resetScan();
-- const t1 = board.createTopic({ text: 'The baby voice', createdBy: 'community', creatorName: 'global chat', headline: 'baby voice gate', keywords: ['baby voice', 'dono voice'], threads: [{ name: 'the fax machine voice', keywords: ['fax machine'], hint: 'roast the voice' }, { name: 'who does it for $2', keywords: ['dono', 'donation'] }] });
-- assert.deepStrictEqual(JSON.parse(t1.keywords_json), ['baby voice', 'dono voice', 'fax machine', 'dono', 'donation'], 'subject keywords = its own + every thread\'s');
-- assert.deepStrictEqual(board.threadsOf(t1.id).map(x => x.name), ['the fax machine voice', 'who does it for $2']);
-- assert.strictEqual(board.threadFor(t1.id, 'sounds like a FAX MACHINE'), board.threadsOf(t1.id)[0].id, 'lines file into the matching thread');
-- assert.strictEqual(board.threadFor(t1.id, 'baby voice in general'), null, 'no thread match → subject-level moment');
-- assert.throws(() => board.createTopic({ text: 'the BABY voice', createdBy: 'chat' }), /already on the board/);
-- assert.throws(() => board.createTopic({ text: 'Dono donation drama', createdBy: 'chat' }), /already on the board/, 'two shared keywords = same subject');
-- assert.throws(() => board.createTopic({ text: 'ok', createdBy: 'chat' }), /short/i);
-- const auto = board.createTopic({ text: 'Pakistanis in the global chat again', createdBy: 'chat', creatorUserId: viewer, creatorName: 'Viewer', creatorIp: '9.9.9.9' });
-- assert.deepStrictEqual(JSON.parse(auto.keywords_json), ['pakistanis', 'global'], 'keywords fall out of the subject text (stopwords dropped)');
-- assert.deepStrictEqual(board.matchTopics('lol the pakistani guy is back').map(t => t.id), [auto.id], 'keyword match forgives plural/singular');
-- assert.deepStrictEqual(board.matchTopics('I do the BABY VOICE for every dono').map(t => t.id), [t1.id], 'phrase keywords match case-insensitively');
-- assert.deepStrictEqual(board.matchTopics('nothing to see here'), []);
--
-- // One per person + per IP per 24 h.
-- assert.throws(() => board.assertCanSubmit(viewer, '1.2.3.4'), /per person/);
-- assert.throws(() => board.assertCanSubmit(u3, '9.9.9.9'), /connection/);
-- board.assertCanSubmit(u3, '5.5.5.5');
-- await assert.rejects(board.submitTopic({ text: 'Another one', userId: viewer, ip: '5.5.5.5', creatorName: 'Viewer' }), /per person/);
-- const sub = await board.submitTopic({ text: 'Streamers who blame lag for everything', userId: u3, ip: '5.5.5.5', creatorName: 'Pixelqueen', onRoster: true });
-- assert.strictEqual(sub.created_by, 'streamer'); assert.strictEqual(sub.submitted_text, 'Streamers who blame lag for everything');
-- await assert.rejects(board.submitTopic({ text: 'cp links in bio', userId: u2, ip: '6.6.6.6', creatorName: 'x' }), /crosses the line/);
--
-- // Chat lines that mention a subject become moments (scan picks up only new messages).
-- const say = (uid, uname, msg, streamId = null) => db.run('INSERT INTO chat_messages (stream_id, user_id, username, message) VALUES (?, ?, ?, ?)', [streamId, uid, uname, msg]);
-- say(viewer, 'viewer', 'this was before the scan started and must be ignored, baby voice');
-- board.scanChat(); // primes the cursor
-- say(viewer, 'viewer', 'nova does the baby voice for a $2 dono no shame');
-- say(u2, 'grizzly_bear', 'BABY VOICE gang rise up');
-- say(viewer, 'viewer', '!topic should be ignored baby voice');
-- say(null, 'anon_guy', 'pakistanis run this chat lol');
-- db.run('INSERT INTO chat_messages (stream_id, user_id, username, message, source_platform) VALUES (NULL, NULL, ?, ?, ?)', ['sleepyotter_ttv', 'baby voice is peak content honestly', 'ai']);
-- db.setSetting('arena_bot_usernames', 'ChuckBot');
-- db.run('INSERT INTO chat_messages (stream_id, user_id, username, message) VALUES (NULL, NULL, ?, ?)', ['ChuckBot', 'baby voice detected, deploying roast']);
-- const scan = board.scanChat();
-- assert.strictEqual(scan.moments, 3, `three moments from six new messages (commands + bots skipped): ${JSON.stringify(scan)}`);
-- assert.ok(board.isBotChatter({ username: 'chuckbot' }) && board.isBotChatter({ source_platform: 'ai' }) && !board.isBotChatter({ username: 'viewer' }));
-- let d1 = board.topicDetail(t1.id);
-- assert.strictEqual(d1.mentions.chat, 2); assert.strictEqual(d1.chatters, 2);
-- assert.strictEqual(d1.last_moment.username, 'grizzly_bear');
-- db.run(`UPDATE chat_messages SET timestamp = datetime('now', '-3 hours') WHERE message LIKE 'nova does the baby voice%'`);
-- const old = board.createTopic({ text: 'Nova dono voice', createdBy: 'chat', creatorName: 'x', keywords: ['nova does'] });
-- board.backfillMoments([old], { windowMin: 600 });
-- const om = board.topicDetail(old.id).moments[0];
-- assert.ok(om && Date.now() - Date.parse(om.at.replace(' ', 'T') + 'Z') > 2.5 * 3600_000, `moment time is when it was said, not when it was filed: ${om && om.at} vs ${om && om.filed_at}`);
-- db.run(`UPDATE arena_topics SET status = 'archived' WHERE id = ?`, [old.id]);
-- assert.strictEqual(board.topicDetail(auto.id).mentions.chat, 1);
-- assert.ok(!board.addMoment(t1.id, { kind: 'chat', source: 'chat', username: 'x', text: 'kys baby voice people' }), 'threat lines never become moments');
--
-- // A streamer saying it on mic auto-joins them; the judge adds scored moments + XP.
-- const mm = board.noteMicMention(t1.id, { userId: u1, username: 'nova', streamId: 77, vodId: 501, sec: 100, text: 'chat the baby voice thing is a bit' });
-- assert.ok(mm && mm.kind === 'speech');
-- assert.strictEqual(board.noteMicMention(t1.id, { userId: u1, username: 'nova', streamId: 77, vodId: 501, sec: 105, text: 'baby voice again' }), null, 'one raw mention per stream per cooldown');
-- assert.strictEqual(board.activeTopicFor(u1).id, t1.id, 'saying it on mic puts you on the subject');
-- assert.ok(board.levelView(u1).xp >= board.XP_JOIN, 'joining pays XP');
-- const T = () => db.get('SELECT * FROM arena_topics WHERE id = ?', [t1.id]);
-- assert.strictEqual(board.applyTopicJudgement(u1, T(), { on_topic: false, quality: 0 }).applied, false, 'off-subject chunk does nothing');
-- assert.strictEqual(board.applyTopicJudgement(u1, T(), { on_topic: true, quality: 9, flagged: true }).applied, false, 'flagged chunk (threat/minor/dox) is ignored');
-- let r = board.applyTopicJudgement(u1, T(), { on_topic: true, quality: 8, best_line: 'Read my dono in your real voice, coward', about: 'dono voice' }, { vod_id: 501, sec: 40, stream_id: 77 });
-- assert.deepStrictEqual([r.applied, r.quality, r.xp], [true, 8, 6]);
-- r = board.applyTopicJudgement(u1, T(), { on_topic: true, quality: 6, best_line: 'fax machine voice', about: 'x' }, {});
-- const lv = board.levelView(u1);
-- assert.strictEqual(lv.topic_moments, 2); assert.strictEqual(lv.topics_joined, 1);
-- assert.strictEqual(lv.best_line.text, 'Read my dono in your real voice, coward', 'best line is the highest-quality one');
-- d1 = board.topicDetail(t1.id);
-- assert.strictEqual(d1.mentions.mic, 3); assert.strictEqual(d1.fighters[0].user.id, u1); assert.strictEqual(d1.fighters[0].moments, 3);
-- assert.strictEqual(d1.threads.find(x => x.name === 'the fax machine voice').moments, 1, 'the fax-machine line landed in its thread');
-- assert.ok(d1.moments.some(m => m.thread_id === d1.threads[0].id || m.thread_id === d1.threads[1]?.id));
-- assert.strictEqual(board.upsertThreads(t1.id, [{ name: 'The Fax Machine Voice', keywords: ['fax'] }, { name: 'nova vs grizzly', keywords: ['grizzly'] }]), 1, 'same thread merges, new angle added');
-- assert.strictEqual(board.threadsOf(t1.id).length, 3);
-- assert.strictEqual(d1.best_moment.quality, 8); assert.strictEqual(d1.best_lines.length, 2);
-- assert.ok(d1.moments.length === 5 && d1.moments[0].source === 'judge');
-- await board.joinTopic(auto.id, u1);
-- assert.strictEqual(board.activeTopicFor(u1).id, auto.id, 'joining another subject switches the active one');
-- await assert.rejects(board.joinTopic(t1.id, viewer), /roster/, 'viewers cannot talk on a subject');
-- console.log('✅ board: keywords, chat scan → moments, mic auto-join, judged moments, Trash Level');
--
-- // Lore (template without AI), hype, heat and the featured order.
-- const lore = await board.buildLore(t1.id, { force: true });
-- assert.ok(/It started in chat when viewer said/.test(lore.lore) && /took it on stream/.test(lore.lore) && /Best line so far, nova/.test(lore.lore), lore.lore);
-- assert.deepStrictEqual(await board.buildLore(t1.id), { skipped: true }, 'no rewrite without new moments');
-- assert.throws(() => board.hypeTopic(t1.id, u1, `user:${u1}`), /yourself/);
-- assert.deepStrictEqual(board.hypeTopic(t1.id, u1, 'anon:abc'), { added: true, hypers: 1 });
-- assert.deepStrictEqual(board.hypeTopic(t1.id, u1, 'anon:abc'), { added: false, hypers: 1 }, 'one hype per person');
-- const view = board.boardView();
-- assert.strictEqual(view.open[0].id, t1.id, 'most talked-about subject first');
-- assert.ok(view.open[0].heat > view.open[1].heat && view.open[0].hot, `heat sorted: ${view.open.map(t => t.heat)}`);
-- assert.strictEqual(view.open[0].lore, lore.lore);
-- assert.strictEqual(view.cooldown_hours, 24);
-- const yap = board.yappersLeaderboard();
-- assert.deepStrictEqual(yap.map(y => y.name).sort(), ['anon_guy', 'grizzly_bear', 'viewer'], 'everyone who typed about a subject is a yapper');
-- assert.ok(yap.every(y => y.moments >= 1 && y.subjects >= 1 && y.level >= 1 && y.title), 'yapper profiles carry level + title');
-- console.log('✅ lore, hype, heat ordering, yappers');
--
-- // Discovery without AI: a word ≥ 3 people keep saying becomes a subject, seeded with what was said.
-- for (const [who, uid] of [['a1', null], ['a2', null], ['a3', null], ['a1', null]]) say(uid, who, `the curry smell in goosely tent is unreal ${who}`);
-- say(null, 'a2', 'curry again bro');
-- const found = board.heuristicDiscover(board.discoverInput());
-- assert.ok(found.some(f => f.keywords[0] === 'curry'), `burst detected: ${JSON.stringify(found)}`);
-- const disc = await board.discoverTopics({ force: true });
-- assert.ok(disc.made >= 1, `discovered: ${JSON.stringify(disc)}`);
-- const curry = db.get(`SELECT * FROM arena_topics WHERE keywords_json LIKE '%curry%'`);
-- assert.ok(curry && curry.created_by === 'community');
-- assert.ok(board.topicDetail(curry.id).mentions.chat >= 4, 'backfilled with the lines that started it');
-- // Same story = same subject: overlapping discoveries and submissions fold in as threads.
-- const mc = board.mergeCandidate('goosely drama', ['goosely', 'mic on']);
-- assert.strictEqual(mc, null, 'nothing about goosely yet → new subject is fine');
-- const gz = board.createTopic({ text: 'Grizzly bear drama', createdBy: 'community', creatorName: 'chat', keywords: ['grizzly bear', 'alts'], threads: [{ name: 'the alt accounts', keywords: ['alts'] }] });
-- assert.ok(board.mergeCandidate('grizzly vs everyone', ['grizzly bear', 'salty']), 'shares a roster name → same subject');
-- assert.ok(board.mergeCandidate('The alt account thing', ['alts', 'bots']) === null, 'one plain shared keyword is not enough');
-- assert.ok(board.mergeCandidate('Grizzly bear alt accounts', ['grizzly', 'alts', 'accounts']), 'two shared keywords → same subject');
-- const fold = board.foldInto(gz.id, { subject: 'grizzly vs everyone', keywords: ['salty'], hint: 'he is', threads: [{ name: 'the salty era', keywords: ['salty'] }, { name: 'the baby voice', keywords: ['baby voice'] }] });
-- assert.strictEqual(fold.id, gz.id);
-- const names2 = board.threadsOf(gz.id).map(x => x.name);
-- assert.ok(names2.includes('grizzly vs everyone') && names2.includes('the salty era'), `folded subject + its threads: ${names2}`);
-- assert.ok(!names2.includes('the baby voice'), 'a thread that is really another open subject is not added');
-- assert.ok(JSON.parse(db.get('SELECT keywords_json FROM arena_topics WHERE id = ?', [gz.id]).keywords_json).includes('salty'), 'keywords widen');
-- const sub2 = await board.submitTopic({ text: 'grizzly is salty again', userId: u2, ip: '8.8.8.8', creatorName: 'Grizzly', onRoster: true });
-- assert.strictEqual(sub2.folded, true); assert.strictEqual(sub2.id, gz.id, 'a user submission about an existing story folds in');
-- db.run(`UPDATE arena_topics SET status = 'archived' WHERE id = ?`, [gz.id]);
-- console.log('✅ discovery from chat bursts + backfill, duplicates fold into threads');
--
-- // Bounty: a subject with a target; expires into the archive with a result.
-- const bty = board.createTopic({ text: 'Bounty: Pixelqueen', createdBy: 'chat', creatorUserId: viewer, creatorName: 'Viewer', kind: 'bounty', targetUserId: u3, headline: 'WANTED' });
-- assert.strictEqual(board.openBountyOn(u3).id, bty.id);
-- assert.throws(() => board.createTopic({ text: 'Bounty again', createdBy: 'chat', kind: 'bounty', targetUserId: u3 }), /already a bounty/);
-- assert.ok(JSON.parse(bty.keywords_json).includes('pixelqueen'), 'bounty keywords include the target name');
--
-- // ── Beefs: a name on mic opens it, the target goes on the clock, silence forfeits ──
-- const h1 = beef.recordHit(u1, u2, { quality: 7, best_line: 'Grizzly streams to 12 people and 9 are his alts', about: 'his viewers are alts', announcer: 'Nova swings first!' });
-- assert.strictEqual(h1.opened, true); assert.strictEqual(h1.side, 'a');
-- assert.strictEqual(h1.beef.on_clock, 'b', 'the target is on the clock');
-- let v = beef.get(h1.beef.id);
-- assert.ok(v.clock_seconds_left > 23 * 3600 && v.clock_seconds_left <= 24 * 3600, `offline clock is 24 h: ${v.clock_seconds_left}`);
-- assert.ok(v.headline && v.headline.includes('Nova'), `template headline: ${v.headline}`);
-- assert.strictEqual(v.share_a, 100);
-- assert.strictEqual(beef.recordHit(u1, u1, { quality: 5 }), null, 'no beef with yourself');
-- const h2 = beef.recordHit(u2, u1, { quality: 9, best_line: 'Nova needed a bit to get views, I just exist', about: 'clout chasing' });
-- assert.strictEqual(h2.opened, false); assert.strictEqual(h2.beef.id, h1.beef.id, 'same pair → same beef');
-- assert.strictEqual(h2.first_response, true);
-- assert.strictEqual(h2.beef.on_clock, 'a', 'answering flips the clock');
-- v = beef.get(h1.beef.id);
-- assert.strictEqual(v.feed.length, 2);
-- assert.strictEqual(v.feed[1].kind, 'respond');
-- assert.ok(v.share_a < 50, 'quality 9 beats quality 7');
-- assert.throws(() => beef.hype(v.id, 'a', `user:${u1}`), /yourself/);
-- assert.deepStrictEqual(beef.hype(v.id, 'a', 'anon:x'), { added: true, hypers: 1, crowd: 1 });
-- assert.deepStrictEqual(beef.hype(v.id, 'a', 'anon:x').added, false);
-- assert.ok(!beef.pickSide && !beef.sidesTally && !('sides' in v), 'no voting anywhere');
-- assert.throws(() => beef.hype(999, 'a', 'anon:x'), /No open beef/);
-- beef.tick();
-- assert.strictEqual(beef.get(v.id).status, 'open', 'clock still running → stays open');
-- db.run(`UPDATE arena_beefs SET clock_until = datetime('now', '-1 minute') WHERE id = ?`, [v.id]);
-- beef.tick();
-- v = beef.get(v.id);
-- assert.strictEqual(v.status, 'resolved'); assert.strictEqual(v.resolution, 'forfeit');
-- assert.strictEqual(v.winner_user_id, u2, 'the side on the clock (a) went silent → b wins');
-- assert.ok(v.result_headline, 'result headline set');
-- assert.deepStrictEqual(beef.recordFor(u2), { wins: 1, losses: 0, draws: 0 });
-- assert.deepStrictEqual(beef.recordFor(u1), { wins: 0, losses: 1, draws: 0 });
-- assert.strictEqual(beef.streakFor(u2), 1);
-- assert.ok(board.levelView(u2).xp >= beef.XP_BEEF_WIN, 'winner gets beef XP');
-- assert.ok(arena.listFighters().find(f => f.user.id === u2).ratings.talk_bonus > 0, 'recent win → mouth bonus on POWER');
-- console.log('✅ beef: open on a name-drop, clock flips on answer, forfeit on silence');
--
-- // Rematch + hard end on points + upset + rivalry receipts.
-- const h3 = beef.recordHit(u1, u2, { quality: 8, best_line: 'Round two, and this time bring your real viewers', about: 'rematch' });
-- assert.strictEqual(h3.opened, true);
-- let v2 = beef.get(h3.beef.id);
-- assert.strictEqual(v2.rematch, true, 'second beef between the pair is a rematch');
-- assert.strictEqual(v2.history.fights, 1);
-- beef.recordHit(u2, u1, { quality: 3, best_line: 'ok', about: 'weak' });
-- db.run(`UPDATE arena_beefs SET ends_at = datetime('now', '-1 minute') WHERE id = ?`, [v2.id]);
-- beef.tick();
-- v2 = beef.get(v2.id);
-- assert.strictEqual(v2.resolution, 'score'); assert.strictEqual(v2.winner_user_id, u1, 'higher total on the hard end wins');
-- assert.strictEqual(v2.upset, false, 'the #1 beating #2 is no upset');
-- const riv = beef.rivalry(u1, u2);
-- assert.deepStrictEqual([riv.fights, riv.wins_1, riv.wins_2], [2, 1, 1]);
-- assert.ok(riv.receipts.length >= 1 && riv.receipts[0].quality >= 6, 'receipts are the quotable lines from earlier beefs');
-- assert.strictEqual(beef.rivalriesFor(u1)[0].opponent.user.id, u2);
-- // Upset: #3 beats #1 on a forfeit.
-- const h4 = beef.recordHit(u3, u1, { quality: 6, best_line: 'Nova is a lobby simulator with a face cam', about: 'x' });
-- db.run(`UPDATE arena_beefs SET clock_until = datetime('now', '-1 minute') WHERE id = ?`, [h4.beef.id]);
-- beef.tick();
-- const up = beef.get(h4.beef.id);
-- assert.strictEqual(up.winner_user_id, u3);
-- assert.strictEqual(up.upset, false, 'rank gap of 2 is not an upset (needs ≥ 4)');
-- const L = beef.list();
-- assert.strictEqual(L.open.length, 0); assert.strictEqual(L.resolved.length, 3);
-- console.log('✅ rematches, hard end on points, rivalry receipts');
--
-- // Bounty doubles beef XP for whoever collects, and the hit lands as a moment on the bounty.
-- const before = board.levelView(u2).xp;
-- const hb = beef.recordHit(u2, u3, { quality: 8, best_line: 'Pixelqueen plays on a calculator', about: 'setup' });
-- assert.strictEqual(hb.bounty, true);
-- const gained = board.levelView(u2).xp - before;
-- assert.ok(gained >= 8 * 2 + beef.XP_BEEF_OPEN, `bounty doubles the hit XP (+open bonus): ${gained}`);
-- assert.ok(board.topicDetail(bty.id).moments.some(m => m.text.includes('calculator')));
-- db.run(`UPDATE arena_topics SET expires_at = datetime('now', '-1 minute') WHERE id = ?`, [bty.id]);
-- const res = board.resolveExpired();
-- assert.strictEqual(res.length, 1); assert.strictEqual(res[0].claimed_by, u2);
-- assert.strictEqual(board.topicDetail(bty.id).status, 'resolved');
-- console.log('✅ bounties');
--
-- // ── Listener: aliases + heuristics + a real tick over a live transcribed stream ──
-- const fresh2 = arena.loadRoster(true);
-- assert.deepStrictEqual(listener._mentionsIn('honestly grizzly bear is washed', u1, fresh2), [u2], 'underscored username spoken with a space');
-- assert.deepStrictEqual(listener._mentionsIn('shoutout @pixelqueen for the raid', u1, fresh2), [u3]);
-- assert.deepStrictEqual(listener._mentionsIn('nova is the best, nova nova', u1, fresh2), [], 'a speaker never mentions themselves');
-- assert.deepStrictEqual(listener._mentionsIn('the supernova exploded', u2, fresh2), [], 'no match inside other words');
-- assert.deepStrictEqual(listener._mentionsIn('grizzlybear is a fraud', u1, fresh2), [u2], 'glued form of a split name');
-- assert.deepStrictEqual(listener._mentionsIn('grizly bear ducked me', u1, fresh2), [u2], 'typo-level fuzzy match');
-- assert.deepStrictEqual(listener._mentionsIn('pixel queen is scared', u1, fresh2), [u3], 'a one-word handle the transcriber split in two');
-- assert.strictEqual(listener._mentionsDetailed('pixel queen is scared', u1, fresh2)[0].how, 'phonetic');
-- // names.js on the handles that break naive matching
-- const names = require('../server/arena/names');
-- assert.deepStrictEqual(names.variants('JapaneseOldGuy'), ['japanese old guy', 'japaneseoldguy']);
-- assert.deepStrictEqual(names.variants('lofi_dan99'), ['lofi dan 99', 'lofi dan', 'lofidan']);
-- assert.ok(names.variants('x_Goosely_TV').includes('goosely tv') && names.variants('M4ticus').includes('maticus'));
-- assert.deepStrictEqual(names.variants('Guy'), [], 'a common word alone can never be an alias');
-- const E = [...names.aliasEntries(1, ['JapaneseOldGuy']), ...names.aliasEntries(2, ['Maticus']), ...names.aliasEntries(3, ['Goosely'])];
-- const hit = (t) => names.findMentions(t, E).map(m => `${m.userId}:${m.how}`);
-- assert.deepStrictEqual(hit('Japanese old guy is washed'), ['1:exact']);
-- assert.deepStrictEqual(hit('japanese-old-guy again'), ['1:exact']);
-- assert.deepStrictEqual(hit("JapaneseOldGuy's chat is dead"), ['1:exact']);
-- assert.deepStrictEqual(hit('japanese old gai lol'), ['1:fuzzy']);
-- assert.deepStrictEqual(hit('a japanese guy walked in'), [], 'a different phrase is not a fuzzy hit');
-- assert.deepStrictEqual(hit('the old guy said nothing'), []);
-- assert.deepStrictEqual(hit('Matticus is a fraud'), ['2:fuzzy']);
-- assert.deepStrictEqual(hit('mattie cuss is scared'), ['2:phonetic']);
-- assert.deepStrictEqual(hit('goose lee ducked'), ['3:phonetic']);
-- assert.deepStrictEqual(hit('Goosley never answered'), ['3:phonetic']);
-- assert.deepStrictEqual(hit('that goose flew'), [], 'a prefix alone is not the name');
-- const S = [...names.aliasEntries(9, ['Mater151']), ...names.aliasEntries(10, ['brunomelato'])];
-- const hit2 = (t) => names.findMentions(t, S).map(m => `${m.userId}:${m.how}`);
-- assert.deepStrictEqual(hit2('it doesnt matter anymore'), [], 'short names never sound-match plain words');
-- assert.deepStrictEqual(hit2('the water is cold'), []); assert.deepStrictEqual(hit2('see you later'), []);
-- assert.deepStrictEqual(hit2('mater is trash'), ['9:exact'], 'short names still match exactly');
-- assert.deepStrictEqual(hit2('bruno melato is a bot'), ['10:phonetic'], 'a glued handle split by the transcriber');
-- assert.deepStrictEqual(hit2('a bruno at the door'), [], 'half a handle is not the handle');
-- console.log('✅ name matching: camelCase/snake/digits/leet/suffixes, fuzzy + phonetic, guarded');
-- const hb1 = listener._heuristicBeef('Grizzly is trash and washed, he could never beat me!', ['grizzly']);
-- assert.strictEqual(hb1.aimed_at_target, true); assert.ok(hb1.quality >= 6);
-- assert.strictEqual(listener._heuristicBeef('thanks grizzly for the raid, love you', ['grizzly']).aimed_at_target, false);
-- const ht = listener._heuristicTopic('honestly the baby voice thing is the worst, nobody wants it', { keywords_json: JSON.stringify(['baby voice']) });
-- assert.strictEqual(ht.on_topic, true); assert.ok(ht.quality >= 5);
-- assert.strictEqual(listener._heuristicTopic('unrelated gameplay talk', { keywords_json: JSON.stringify(['baby voice']) }).on_topic, false);
--
-- // Nova goes live with transcription; says pixelqueen's name while talking shit → beef opens without anyone clicking.
-- const liveId = Number(db.createStream({ user_id: u1, title: 'live', category: 'irl', protocol: 'rtmp' }).lastInsertRowid);
-- db.run(`UPDATE streams SET is_live = 1, started_at = datetime('now', '-600 seconds') WHERE id = ?`, [liveId]);
-- db.addTimelineEvents([
-- { stream_id: liveId, user_id: u1, vod_id: null, kind: 'speech', start_sec: 586, end_sec: 590, text: 'Chat, pixelqueen is trash, she is washed and could never beat me in anything, she is scared of the smoke.', label: null, confidence: 0.9 },
-- { stream_id: liveId, user_id: u1, vod_id: null, kind: 'speech', start_sec: 591, end_sec: 596, text: 'Nobody watches that stream, it is mid, it is garbage, I would cook her in a second, bet! Also the curry thing is real.', label: null, confidence: 0.9 },
-- ]);
-- assert.strictEqual(arena.liveFighters()[0].user.id, u1);
-- const ev = await listener.tick();
-- assert.ok(ev.some(e => e.kind === 'beef_hit' && e.targetId === u3 && e.opened), `listener opened the beef: ${JSON.stringify(ev)}`);
-- assert.strictEqual(listener.consoleState(u1).listening, true);
-- assert.ok(listener.consoleState(u1).last_beef_judgement.aimed_at_target);
-- assert.ok(ev.some(e => e.kind === 'topic_mention' && e.topicId === curry.id), 'saying a subject on mic adds a moment on it');
-- assert.ok(board.topicDetail(curry.id).fighters.some(f => f.user.id === u1), 'and auto-joins the fighter');
-- const opened = beef.openBeefsFor(u1);
-- assert.strictEqual(opened.length, 1); assert.strictEqual(opened[0].b_user_id, u3);
-- assert.strictEqual(opened[0].on_clock, 'b');
-- assert.strictEqual(arena.liveFighters()[0].open_beefs, 1);
-- const cs = listener.consoleState(u1);
-- assert.ok(cs.focus && cs.focus.target_id === u3 && cs.focus.hits === 1 && cs.focus.lock_seconds_left > 60, `locked on the target after the hit: ${JSON.stringify(cs.focus)}`);
-- console.log('✅ listener: name-drop on a live stream opens the beef and locks on');
--
-- // Locked on: the next lines never say the name, but "she", "her chat" keep the rant going → another hit.
-- const hitsBefore = beef.get(opened[0].id).a.hits;
-- db.run(`UPDATE streams SET started_at = datetime('now', '-700 seconds') WHERE id = ?`, [liveId]);
-- db.addTimelineEvents([
-- { stream_id: liveId, user_id: u1, vod_id: null, kind: 'speech', start_sec: 686, end_sec: 692, text: 'And her chat? Her chat is twelve alts and a bot, she is cooked, she could never run it back with me.', label: null, confidence: 0.9 },
-- { stream_id: liveId, user_id: u1, vod_id: null, kind: 'speech', start_sec: 693, end_sec: 698, text: 'She is trash at this and everyone knows it, bet, she is scared to even answer.', label: null, confidence: 0.9 },
-- ]);
-- listener._state.get(liveId).lastJudgeAt = 0; // skip the 30 s judge spacing for the test
-- const ev2 = await listener.tick();
-- assert.ok(ev2.some(e => e.kind === 'beef_hit' && e.targetId === u3 && e.continued), `continuation counted without the name: ${JSON.stringify(ev2)}`);
-- assert.strictEqual(beef.get(opened[0].id).a.hits, hitsBefore + 1);
-- assert.ok(listener.consoleState(u1).focus.hits === 2 && listener.consoleState(u1).focus.context.includes('|'), 'lock extended with context');
-- // Then they move on to gameplay: two off-target chunks drop the lock.
-- for (let k = 0; k < 2; k++) {
-- db.run(`UPDATE streams SET started_at = datetime('now', ?) WHERE id = ?`, [`-${800 + k * 100} seconds`, liveId]);
-- db.addTimelineEvents([{ stream_id: liveId, user_id: u1, vod_id: null, kind: 'speech', start_sec: 786 + k * 100, end_sec: 792 + k * 100, text: 'Okay chat back to the game, we need to farm this boss and then do the quest line for the sword upgrade, let me check the map real quick.', label: null, confidence: 0.9 }]);
-- listener._state.get(liveId).lastJudgeAt = 0;
-- await listener.tick();
-- }
-- assert.strictEqual(listener.consoleState(u1).focus, null, 'moved on → lock dropped');
-- console.log('✅ listener: stays locked on the target, counts continuations, lets go when they move on');
--
-- // ── Chat commands ──
-- const sent = [], room = [];
-- const fakeChat = { sendTo: (ws, m) => sent.push(m.message), broadcastToStream: (sid, m) => room.push(m.message) };
-- const run = async (client, line) => { const parts = line.split(' '); const handled = chat.handle(fakeChat, {}, client, parts[0], parts); await new Promise(r => setTimeout(r, 30)); return handled; };
-- const vc = { user: { id: viewer, username: 'viewer', display_name: 'Viewer' }, streamId: liveId, ip: '1.1.1.1' };
-- assert.strictEqual(await run(vc, '!nope'), false);
-- assert.strictEqual(await run(vc, '!topic Streamers who blame lag for everything'), true);
-- assert.ok(/per person/.test(sent.pop()), 'viewer already used their topic for the day');
-- const vc2 = { user: { id: u2, username: 'grizzly_bear', display_name: 'Grizzly' }, streamId: liveId, ip: '7.7.7.7' };
-- await run(vc2, '!topic Goosely tent smell');
-- assert.ok(sent.pop().includes('/arena/topic/'), 'topic posted with a link');
-- assert.ok(room.pop().includes('put a subject on the Arena board'));
-- assert.ok(db.get(`SELECT id FROM arena_topics WHERE text LIKE 'Goosely tent smell%' AND created_by = 'streamer'`));
-- await new Promise(r => setTimeout(r, 4100)); // per-person command rate limit
-- await run(vc, '!hype');
-- assert.ok(sent.pop().includes('Hyped Nova in their beef'), 'hype goes to the streamer\'s open beef');
-- assert.strictEqual(beef.get(opened[0].id).a.crowd, 1);
-- await new Promise(r => setTimeout(r, 4100));
-- await run({ anonId: 'zz', streamId: liveId }, '!beef');
-- assert.ok(sent.pop().includes('on the clock'), 'beef summary shows the clock');
-- await run({ anonId: 'zz2', streamId: liveId }, '!board');
-- assert.ok(sent.pop().includes('Hottest'), 'board summary');
-- await run({ anonId: 'zz4', streamId: liveId }, '!side nova');
-- assert.strictEqual(sent.length, 0, 'no voting commands exist');
-- await run({ anonId: 'zz3', streamId: liveId }, '!topic no sign in');
-- assert.ok(sent.pop().includes('Sign in'));
-- console.log('✅ chat: !topic (24 h limit) !hype !beef !board');
--
-- // ── 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}`); return { status: res.status, body: await res.json() }; };
-- const bd = await get('/board');
-- assert.strictEqual(bd.status, 200); assert.ok(Array.isArray(bd.body.open) && bd.body.levels.length >= 1 && bd.body.yappers.length >= 1 && bd.body.archive.length >= 1);
-- const bf = await get('/beefs'); assert.strictEqual(bf.body.open.length, 2, 'nova→pixelqueen (listener) + grizzly→pixelqueen (bounty)'); assert.strictEqual(bf.body.resolved.length, 3);
-- const one = await get(`/beefs/${opened[0].id}`); assert.strictEqual(one.body.a.user.username, 'nova'); assert.ok(one.body.rules.response_live_min === 15);
-- const con = await get('/console/nova'); assert.strictEqual(con.body.listener.listening, true); assert.strictEqual(con.body.open_beefs.length, 1); assert.ok(con.body.hot_mic.length >= 2 && con.body.listener.focus === null);
-- const lv2 = await get('/live'); assert.strictEqual(lv2.body.live[0].open_beefs, 1);
-- const tp = await get(`/board/topics/${t1.id}`); assert.ok(tp.body.lore && tp.body.moments.length >= 5 && tp.body.keywords.includes('baby voice') && tp.body.threads.length === 3);
-- assert.strictEqual((await get('/beefs/999')).status, 404);
-- assert.strictEqual((await get('/console/nobody')).status, 404);
-- srv.close();
-- console.log('✅ public API');
--
-- console.log('\n✅ All Arena beef/board tests passed');
-- process.exit(0);
--})().catch(err => { console.error(err); process.exit(1); });
ddiff --git a/test/arena-chatters.test.js b/test/arena-chatters.test.js
ddeleted file mode 100644
iindex 270c145c..00000000
---- a/test/arena-chatters.test.js
++++ /dev/null
@@@ -1,72 +0,0 @@
--'use strict';
--// Yapper profiles: one key for accounts / anon / relay chatters, XP from board moments, levels,
--// titles, streaks, lore quotes, leaderboards — and level-up coins only for accounts.
--const assert = require('assert');
--const fs = require('fs'); const os = require('os'); const path = require('path');
--const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-yap-'));
--process.env.DB_PATH = path.join(tmp, 'live.db');
--const db = require('../server/db/database'); db.initDb();
--const ch = require('../server/arena/chatters');
--const board = require('../server/arena/board');
--const arena = require('../server/arena/arena-service');
--arena.ensureTables(); board.ensureTables(); ch.ensureTables();
--
--assert.strictEqual(ch.keyFor({ user_id: 7, username: 'Goosely' }), 'user:7');
--assert.strictEqual(ch.keyFor({ user_id: null, anon_id: 'anon4412', username: 'anon4412' }), 'anon:4412');
--assert.strictEqual(ch.keyFor({ user_id: null, username: 'anon99' }), 'anon:99', 'anon without anon_id column still keys');
--assert.strictEqual(ch.keyFor({ user_id: null, username: '[Twitch] BobLol', source_platform: 'twitch' }), 'relay:twitch:boblol');
--assert.strictEqual(ch.keyFor({ user_id: null, username: '[RS] dan' }), 'relay:rs:dan', 'label alone identifies the platform');
--assert.deepStrictEqual(ch.parseKey('relay:kick:some:name'), { kind: 'relay', platform: 'kick', username: 'some:name' });
--assert.deepStrictEqual([0, 24, 25, 100, 400, 2500].map(ch.levelFor), [1, 1, 2, 3, 5, 11]);
--assert.deepStrictEqual([1, 3, 5, 12, 20].map(ch.titleFor), ['Lurker', 'Yapper', 'Instigator', 'Main Character', 'Final Boss']);
--console.log('✅ chatter keys, level curve, titles');
--
--const uid = Number(db.createUser({ username: 'viewer', email: 'v@x', password_hash: 'x', display_name: 'Viewer', stream_key: 'v'.repeat(32) }).lastInsertRowid);
--const t = board.createTopic({ text: 'The baby voice', createdBy: 'community', creatorName: 'global chat', keywords: ['baby voice'] });
--const say = (uid2, uname, msg, extra = {}) => board.addMoment(t.id, { kind: 'chat', source: 'chat', userId: uid2, username: uname, text: msg, ...extra });
--say(uid, 'viewer', 'the baby voice is a war crime');
--let p = ch.profile('user:viewer'.replace('viewer', uid));
--assert.strictEqual(p.xp, ch.XP_MOMENT + ch.XP_FIRST_ON_SUBJECT + 5 + 5, 'moment + first-on-subject + day-1 streak + the Said Something achievement');
--assert.strictEqual(p.streak, 1); assert.strictEqual(p.subjects, 1); assert.strictEqual(p.moments, 1);
--say(uid, 'viewer', 'baby voice again');
--p = ch.profile(`user:${uid}`);
--assert.strictEqual(p.xp, 17 + ch.XP_MOMENT, 'second line: just the moment (streak already paid today)');
--say(null, 'anon4412', 'baby voice enjoyers rise', { anonId: 'anon4412' });
--say(null, '[Twitch] BobLol', 'the baby voice guy again', { platform: 'twitch' });
--assert.strictEqual(ch.profile('anon:4412').kind, 'anon'); assert.strictEqual(ch.profile('relay:twitch:boblol').name, '[Twitch] BobLol');
--assert.strictEqual(ch.profile('anon:4412').coins, false, 'anon: level + title, no coins');
--db.run(`UPDATE arena_topics SET heat = 50 WHERE id = ?`, [t.id]);
--say(uid, 'viewer', 'HOT take on the baby voice');
--assert.strictEqual(ch.profile(`user:${uid}`).xp, 20 + ch.XP_MOMENT_HOT + 10, 'hot subjects pay double (+ the Hot Take achievement)');
--assert.strictEqual(db.get('SELECT chatter_key FROM arena_topic_moments ORDER BY id DESC LIMIT 1').chatter_key, `user:${uid}`);
--console.log('✅ moments → XP for accounts, anon and relay chatters');
--
--(async () => {
-- const before = ch.profile(`user:${uid}`).xp;
-- const lore = await board.buildLore(t.id, { force: true });
-- assert.ok(/viewer/.test(lore.lore), 'template lore names the chatter');
-- assert.strictEqual(ch.profile(`user:${uid}`).xp, before + ch.XP_QUOTED_IN_LORE + 15, 'quoted in the lore → XP (+ the In The Lore achievement)');
-- assert.strictEqual(ch.profile(`user:${uid}`).quoted, 1);
-- ch.onHype(`user:${uid}`, t.id); assert.strictEqual(ch.profile(`user:${uid}`).hypes, 1);
-- ch.onSubjectStarted(`user:${uid}`, t.id, { display: 'Viewer' }); assert.strictEqual(ch.profile(`user:${uid}`).subjects_started, 1);
-- // Level up: force XP over the level-3 line; coins are attempted once per level (wallet is unlinked here → no-op, but marked paid).
-- const r = ch.addXp(`user:${uid}`, 200, 'test');
-- assert.ok(r.leveled_up && r.level >= 3, JSON.stringify(r));
-- await new Promise(res => setTimeout(res, 20));
-- assert.strictEqual(ch.row(`user:${uid}`).coins_paid_level, 0, 'wallet unlinked here → not marked paid (housekeeping retries)');
-- assert.strictEqual(await ch.settleCoins(), 0, 'retry is safe when the wallet cannot credit');
-- const r2 = ch.addXp(`user:${uid}`, 1, 'test'); assert.strictEqual(r2.leveled_up, false);
-- const lb = ch.leaderboard(5);
-- assert.strictEqual(lb[0].key, `user:${uid}`); assert.ok(lb.length === 3 && lb[0].title === ch.titleFor(lb[0].level));
-- assert.strictEqual(ch.leaderboard(5, { days: 7 })[0].gained, ch.profile(`user:${uid}`).xp, 'weekly board sums the log');
-- const prof = ch.profile(`user:${uid}`);
-- assert.ok(prof.recent_moments.length === 3 && prof.top_subjects[0].id === t.id && prof.rank === 1 && prof.titles.length === ch.TITLES.length);
-- assert.deepStrictEqual(await ch.buildCard(`user:${uid}`), { skipped: 'ai off' }, 'no AI → no card call');
-- assert.strictEqual(await ch.cardSweep(), 0);
-- const c1 = ch.checkin(uid, { display: 'Viewer' });
-- assert.strictEqual(c1.already, false); assert.ok(c1.gained >= 5, 'daily check-in pays');
-- const c2 = ch.checkin(uid); assert.strictEqual(c2.already, true, 'once a day');
-- console.log('✅ lore quotes, hype, subjects, level-ups (coins once per level), leaderboards, profile, daily check-in');
-- console.log('\n✅ All chatter tests passed');
-- process.exit(0);
--})().catch(err => { console.error(err); process.exit(1); });
ddiff --git a/test/arena-mic.test.js b/test/arena-mic.test.js
nnew file mode 100644
iindex 00000000..b3db9b11
---- /dev/null
++++ b/test/arena-mic.test.js
@@@ -0,0 +1,184 @@
++'use strict';
++
++// Arena v3 (Battle Cam) — pure mic. The roster is whoever has been heard, ratings come from the
++// transcripts + the mic ledger, beefs open from name-drops AND from the mic judge's "aimed_at",
++// free-standing shit talk lands in the feed and pays Trash Level XP, and chat can only hype.
++// Runs end-to-end on a temp DB with no AI configured (heuristic judges, template headlines).
++
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++
++const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-arena3-'));
++process.env.DB_PATH = path.join(tmp, 'live.db');
++process.env.ARENA_IMAGE_PATH = path.join(tmp, 'arena');
++
++const db = require('../server/db/database');
++db.initDb();
++const arena = require('../server/arena/arena-service');
++const mic = require('../server/arena/mic');
++const beef = require('../server/arena/beef');
++const listener = require('../server/arena/listener');
++const chat = require('../server/arena/arena-chat');
++
++const mk = (username) => Number(db.createUser({ username, email: `${username}@x`, password_hash: 'x', display_name: username[0].toUpperCase() + username.slice(1), stream_key: username.padEnd(32, '0') }).lastInsertRowid);
++const u1 = mk('nova'), u2 = mk('grizzly_bear'), u3 = mk('pixelqueen'), quiet = mk('quietguy'), viewer = mk('viewer');
++for (const uid of [u1, u2, u3, quiet, viewer]) db.ensureChannel(uid);
++// Streams: everyone streamed, but the roster only cares who was HEARD.
++const stream = (uid, hours, daysAgo, peak = 500) => {
++ const id = Number(db.createStream({ user_id: uid, title: `${uid} stream`, category: 'irl', protocol: 'rtmp' }).lastInsertRowid);
++ db.run(`UPDATE streams SET is_live = 0, started_at = datetime('now', ?), ended_at = datetime('now', ?), duration_seconds = ?, peak_viewers = ? WHERE id = ?`, [`-${daysAgo} days`, `-${daysAgo} days`, Math.round(hours * 3600), peak, id]);
++ return id;
++};
++const s1 = stream(u1, 2, 2), s2 = stream(u2, 2, 1), s3 = stream(u3, 1, 3);
++stream(quiet, 40, 1, 9000); // huge audience, never says a word → NOT on the roster
++const speak = (streamId, uid, n, mk) => db.addTimelineEvents(Array.from({ length: n }, (_, i) => ({ stream_id: streamId, user_id: uid, vod_id: null, kind: 'speech', start_sec: i * 30, end_sec: i * 30 + 8, text: mk(i), label: null, confidence: 0.9 })));
++speak(s1, u1, 60, i => `Chat listen, this is line ${i}, we are cooking today, nobody can touch this, bet!`);
++speak(s2, u2, 30, i => `Okay so line ${i}, let me check the map and farm this boss.`);
++speak(s3, u3, 10, i => `Line ${i} hello everyone welcome in.`);
++
++const roster = arena.loadRoster(true);
++assert.deepStrictEqual([...roster.order].sort(), [u1, u2, u3].sort(), 'roster = everyone with speech; the silent 9000-viewer streamer is not a fighter');
++assert.ok(!roster.byId[quiet], 'audience size buys nothing');
++assert.deepStrictEqual(arena.STAT_KEYS, ['heat', 'aim', 'kills', 'mouth', 'clapback', 'stamina', 'pace']);
++assert.ok(Math.abs(Object.values(arena.STAT_WEIGHTS).reduce((a, b) => a + b, 0) - 1) < 1e-9, 'stat weights sum to 1');
++const f1 = roster.byId[u1];
++assert.ok(f1.raw.mic && f1.raw.mic.window_days === 30 && f1.raw.mic.moments === 0, 'mic stats present, nothing judged yet');
++assert.ok(!('peak_viewers' in f1.raw) && !('followers' in f1.raw) && !('messages_per_hour' in f1.raw), 'no audience numbers anywhere in the raw stats');
++assert.strictEqual(f1.ratings.stamina, 99, 'the most speech = top STAMINA');
++assert.strictEqual(roster.byId[u3].ratings.stamina, 40);
++assert.strictEqual(f1.ratings.heat, roster.byId[u3].ratings.heat, 'nobody judged yet → HEAT ties');
++console.log('✅ roster + ratings are pure mic');
++
++(async () => {
++ // ── Mic ledger: moments pay XP, best line sticks, feed lists newest first ──
++ const m1 = mic.addMoment({ userId: u1, streamId: s1, vodId: 501, sec: 40, kind: 'trash', aimedAt: 'chat', text: 'Chat you are all clowns, every one of you, bet', about: 'chat is clowns', quality: 7 });
++ assert.ok(m1 && m1.id, 'moment stored');
++ assert.strictEqual(mic.addMoment({ userId: u1, kind: 'trash', text: 'kys buddy', quality: 9 }), null, 'threats never land in the feed');
++ const lv = mic.levelView(u1);
++ assert.strictEqual(lv.xp, Math.round(7 * mic.XP_MOMENT), `trash moment pays quality × ${mic.XP_MOMENT} XP: ${lv.xp}`);
++ assert.strictEqual(lv.mic_moments, 1); assert.strictEqual(lv.best_line.text, 'Chat you are all clowns, every one of you, bet');
++ mic.addMoment({ userId: u1, streamId: s1, kind: 'trash', aimedAt: 'the mods', text: 'the mods are asleep again, useless', about: 'mods', quality: 5 });
++ const feed = mic.feed({ limit: 10 });
++ assert.strictEqual(feed.length, 2); assert.strictEqual(feed[0].aimed_at, 'the mods'); assert.strictEqual(feed[1].vod_id, 501);
++ assert.strictEqual(feed[0].fighter_name, 'Nova');
++ const ms = mic.micStats(u1);
++ assert.strictEqual(ms.moments, 2); assert.strictEqual(ms.avg_quality, 6); assert.strictEqual(ms.bangers, 1);
++ assert.strictEqual(arena.loadRoster(true).byId[u1].ratings.heat, 99, 'judged moments lift HEAT');
++ console.log('✅ mic ledger: moments, XP, best line, feed, stats');
++
++ // ── Beefs: a hit logs into the feed too; clocks flip; forfeit; KILLS ──
++ const h1 = beef.recordHit(u1, u2, { quality: 7, best_line: 'Grizzly streams to 12 people and 9 are his alts', about: 'alts', announcer: 'Nova swings first!', stream_id: s1, vod_id: 501, sec: 90 });
++ assert.strictEqual(h1.opened, true); assert.strictEqual(h1.beef.on_clock, 'b');
++ assert.ok(!('bounty' in h1), 'bounties are gone');
++ const fm = mic.feed({ limit: 1 })[0];
++ assert.strictEqual(fm.kind, 'beef_hit'); assert.strictEqual(fm.target.user.id, u2); assert.strictEqual(fm.beef_id, h1.beef.id); assert.strictEqual(fm.sec, 90);
++ assert.strictEqual(mic.levelView(u1).beef_hits, 1);
++ const h2 = beef.recordHit(u2, u1, { quality: 9, best_line: 'Nova needed a bit to get views, I just exist', about: 'clout' });
++ assert.strictEqual(h2.first_response, true); assert.strictEqual(h2.beef.on_clock, 'a');
++ assert.deepStrictEqual(beef.hype(h1.beef.id, 'a', 'anon:x'), { added: true, hypers: 1, crowd: 1 });
++ assert.throws(() => beef.hype(h1.beef.id, 'a', `user:${u1}`), /yourself/);
++ db.run(`UPDATE arena_beefs SET clock_until = datetime('now', '-1 minute') WHERE id = ?`, [h1.beef.id]);
++ beef.tick();
++ const v = beef.get(h1.beef.id);
++ assert.strictEqual(v.resolution, 'forfeit'); assert.strictEqual(v.winner_user_id, u2, 'nova went silent on the clock → grizzly wins');
++ assert.ok(!('bounty' in v), 'no bounty field on the view');
++ assert.deepStrictEqual(beef.recordFor(u2), { wins: 1, losses: 0, draws: 0 });
++ const r2 = arena.loadRoster(true);
++ assert.strictEqual(r2.byId[u2].ratings.kills, 99, 'a win is a KILL');
++ assert.strictEqual(r2.byId[u2].raw.mic.answered, 1, 'answering on the clock counts for CLAPBACK');
++ assert.ok(r2.byId[u2].ratings.talk_bonus > 0, 'recent win → mouth bonus');
++ console.log('✅ beefs feed the ledger; forfeit; KILLS + CLAPBACK from the mic ledger');
++
++ // ── Heuristic judges (AI off) ──
++ const hm = listener._heuristicMic('Chat, listen, all of you are clowns, absolute bums, nobody in this chat could beat me at anything, bet!');
++ assert.strictEqual(hm.is_trash_talk, true); assert.ok(hm.quality >= 5, `quality ${hm.quality}`); assert.strictEqual(hm.aimed_at, 'chat');
++ assert.strictEqual(listener._heuristicMic('okay so we need to farm this boss and then go to the shop, let me check the map').is_trash_talk, false);
++ assert.strictEqual(listener._heuristicBeef('thanks grizzly for the raid, love you', ['grizzly']).aimed_at_target, false);
++ console.log('✅ heuristic judges');
++
++ // ── Listener over a live stream: free talk → feed; a name-drop → beef; a callout by nickname → beef ──
++ const liveId = Number(db.createStream({ user_id: u3, title: 'live', category: 'irl', protocol: 'rtmp' }).lastInsertRowid);
++ db.run(`UPDATE streams SET is_live = 1, started_at = datetime('now', '-600 seconds') WHERE id = ?`, [liveId]);
++ const xpBefore = mic.levelView(u3).xp;
++ db.addTimelineEvents([
++ { stream_id: liveId, user_id: u3, vod_id: null, kind: 'speech', start_sec: 586, end_sec: 590, text: 'Chat, every single one of you is a clown, you bums could never do what I do, nobody in here has a job, bet!', label: null, confidence: 0.9 },
++ { stream_id: liveId, user_id: u3, vod_id: null, kind: 'speech', start_sec: 591, end_sec: 596, text: 'Sit down, all of you, absolute losers, I am better than this whole chat combined and everyone knows it!', label: null, confidence: 0.9 },
++ ]);
++ let ev = await listener.tick();
++ assert.ok(ev.some(e => e.kind === 'mic_hit' && e.speakerId === u3), `free shit talk lands in the feed: ${JSON.stringify(ev)}`);
++ assert.ok(mic.levelView(u3).xp > xpBefore, 'and pays XP');
++ assert.strictEqual(mic.feed({ limit: 1 })[0].user.id, u3);
++ const cs = listener.consoleState(u3);
++ assert.ok(cs.listening && cs.last_mic_judgement && cs.last_mic_judgement.is_trash_talk && cs.focus === null, 'console shows the mic judgement, no lock');
++ assert.strictEqual(arena.liveFighters()[0].user.id, u3);
++ assert.ok(arena.liveFighters()[0].last_moment && arena.liveFighters()[0].last_moment.aimed_at === 'chat', 'live cam carries the last judged line');
++ // Name-drop → focus lock → beef.
++ db.run(`UPDATE streams SET started_at = datetime('now', '-700 seconds') WHERE id = ?`, [liveId]);
++ db.addTimelineEvents([
++ { stream_id: liveId, user_id: u3, vod_id: null, kind: 'speech', start_sec: 686, end_sec: 690, text: 'And grizzly bear? Grizzly bear is trash, washed, a fraud, he could never beat me, he is scared of the smoke, bet!', label: null, confidence: 0.9 },
++ { stream_id: liveId, user_id: u3, vod_id: null, kind: 'speech', start_sec: 691, end_sec: 696, text: 'Nobody watches that stream, it is mid, it is garbage, I would cook him in a second!', label: null, confidence: 0.9 },
++ ]);
++ listener._state.get(liveId).lastJudgeAt = 0;
++ ev = await listener.tick();
++ assert.ok(ev.some(e => e.kind === 'beef_hit' && e.targetId === u2 && e.opened), `name-drop opened the beef: ${JSON.stringify(ev)}`);
++ assert.ok(listener.consoleState(u3).focus && listener.consoleState(u3).focus.target_id === u2, 'locked on grizzly');
++ assert.strictEqual(beef.openBeefsFor(u3).length, 1);
++ assert.strictEqual(arena.liveFighters()[0].ears.focus.target_id, u2, 'live cam shows the lock');
++ // Drop the lock (two off-target chunks), then a callout WITHOUT the name in the speech but with the judge's aimed_at.
++ for (let k = 0; k < 2; k++) {
++ db.run(`UPDATE streams SET started_at = datetime('now', ?) WHERE id = ?`, [`-${800 + k * 100} seconds`, liveId]);
++ db.addTimelineEvents([{ stream_id: liveId, user_id: u3, vod_id: null, kind: 'speech', start_sec: 786 + k * 100, end_sec: 792 + k * 100, text: 'Okay chat back to the game, we need to farm this boss and then do the quest line for the sword upgrade, let me check the map real quick.', label: null, confidence: 0.9 }]);
++ listener._state.get(liveId).lastJudgeAt = 0;
++ await listener.tick();
++ }
++ assert.strictEqual(listener.consoleState(u3).focus, null, 'moved on → lock dropped');
++ console.log('✅ listener: free talk → feed + XP; name-drop → lock → beef; lets go when they move on');
++
++ // ── Persona fallback + fighter card are mic-shaped ──
++ const card = await arena.getFighter('nova');
++ assert.ok(card.mic && card.mic.moments >= 2 && card.level.level >= 1 && Array.isArray(card.beefs));
++ assert.ok(!('active_topic' in card), 'no topics');
++ assert.ok(['Rushdown', 'Sniper', 'Assassin', 'Caster', 'Counter', 'Tank', 'Zoner'].includes(card.persona.class), `fallback class from the mic stats: ${card.persona.class}`);
++ assert.deepStrictEqual(Object.keys(card.persona.stat_quips).sort(), [...arena.STAT_KEYS].sort());
++ const detail = arena.getStatDetail(u1, 'heat');
++ assert.strictEqual(detail.unit, 'avg judge score'); assert.ok(detail.series.length >= 1);
++ assert.strictEqual(arena.getStatDetail(u1, 'hype'), null, 'audience stats do not exist');
++ const lb = mic.levelsLeaderboard(5);
++ assert.ok(lb.length >= 2 && lb[0].xp >= lb[1].xp);
++ console.log('✅ fighter card, stat detail, ladder');
++
++ // ── Chat: hype only ──
++ const sent = [], room = [];
++ const fakeChat = { sendTo: (ws, m) => sent.push(m.message), broadcastToStream: (sid, m) => room.push(m.message) };
++ const run = async (client, line) => { const parts = line.split(' '); const handled = chat.handle(fakeChat, {}, client, parts[0], parts); await new Promise(r => setTimeout(r, 30)); return handled; };
++ assert.strictEqual(await run({ user: { id: viewer, username: 'viewer' }, streamId: liveId, ip: '1.1.1.1' }, '!topic anything'), false, '!topic no longer exists');
++ assert.strictEqual(await run({ anonId: 'b1', streamId: liveId }, '!bounty nova'), false, '!bounty no longer exists');
++ assert.strictEqual(await run({ anonId: 'b2', streamId: liveId }, '!board'), false, '!board no longer exists');
++ await run({ user: { id: viewer, username: 'viewer', display_name: 'Viewer' }, streamId: liveId, ip: '1.1.1.1' }, '!hype');
++ assert.ok(sent.pop().includes('Hyped'), 'hype goes to the streamer\'s open beef');
++ await run({ anonId: 'zz', streamId: liveId }, '!beef');
++ assert.ok(sent.pop().includes('on the clock'));
++ console.log('✅ chat: !hype / !beef only');
++
++ // ── 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);
++ 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);
++ const lv2 = await get('/live'); assert.strictEqual(lv2.body.live[0].open_beefs, 1);
++ const st = await get('/status'); assert.strictEqual(st.body.mode, 'battle-cam'); assert.ok(!('topics_open' in st.body));
++ assert.strictEqual((await get('/board')).status, 404, 'the board is gone');
++ assert.strictEqual((await get('/yappers')).status, 404, 'yappers are gone');
++ assert.strictEqual((await get('/fighters/quietguy')).body.not_on_roster, true);
++ srv.close();
++ console.log('✅ public API');
++
++ console.log('\n✅ All Arena (Battle Cam) tests passed');
++ process.exit(0);
++})().catch(err => { console.error(err); process.exit(1); });
ddiff --git a/test/arena-progress.test.js b/test/arena-progress.test.js
ddeleted file mode 100644
iindex 00f668d8..00000000
---- a/test/arena-progress.test.js
++++ /dev/null
@@@ -1,54 +0,0 @@
--'use strict';
--// Growth: tiers from all-time XP, achievements from existing data (XP + coins + history), weekly XP.
--const assert = require('assert');
--const fs = require('fs'); const os = require('os'); const path = require('path');
--const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-progress-'));
--process.env.DB_PATH = path.join(tmp, 'live.db');
--const db = require('../server/db/database'); db.initDb();
--const arena = require('../server/arena/arena-service'); const board = require('../server/arena/board'); const beef = require('../server/arena/beef'); const ch = require('../server/arena/chatters'); const pr = require('../server/arena/progress');
--arena.ensureTables(); board.ensureTables(); beef.ensureTables(); ch.ensureTables(); pr.ensureTables();
--
--assert.deepStrictEqual([0, 149, 150, 400, 3500, 9000].map(x => pr.tierFor(x).name), ['Bronze', 'Bronze', 'Silver', 'Gold', 'Mythic', 'Mythic']);
--assert.strictEqual(pr.tierFor(275).progress, 50, 'halfway from Silver (150) to Gold (400)');
--assert.strictEqual(pr.tierFor(9000).next, null);
--console.log('✅ tiers');
--
--const mk = (u) => Number(db.createUser({ username: u, email: `${u}@x`, password_hash: 'x', display_name: u, stream_key: u.padEnd(32, '0') }).lastInsertRowid);
--const u1 = mk('nova'), u2 = mk('grizzly'), viewer = mk('viewer');
--for (const uid of [u1, u2, viewer]) db.ensureChannel(uid);
--const stream = (uid, peak, hours, daysAgo) => { const id = Number(db.createStream({ user_id: uid, title: 't', category: 'irl', protocol: 'rtmp' }).lastInsertRowid); db.run(`UPDATE streams SET is_live = 0, started_at = datetime('now', ?), ended_at = datetime('now', ?), duration_seconds = ?, peak_viewers = ? WHERE id = ?`, [`-${daysAgo} days`, `-${daysAgo} days`, hours * 3600, peak, id]); db.run(`INSERT INTO stream_analytics (stream_id, avg_viewers, peak_viewers, unique_chatters, total_messages, total_watch_minutes, new_followers, clips_created, coins_earned) VALUES (?, ?, ?, ?, ?, ?, 0, 0, 0)`, [id, peak / 2, peak, peak, peak * 20, peak * 60]); };
--stream(u1, 100, 5, 1); stream(u2, 40, 5, 1);
--arena.loadRoster(true);
--
--// A chatter earns "Said Something" from their first moment; XP + history + coins attempt.
--const t = board.createTopic({ text: 'The baby voice', createdBy: 'community', creatorName: 'chat', keywords: ['baby voice'] });
--board.addMoment(t.id, { kind: 'chat', source: 'chat', userId: viewer, username: 'viewer', text: 'baby voice is a war crime' });
--let v = pr.view(`user:${viewer}`);
--assert.ok(v.achievements.find(a => a.id === 'first_moment').earned_at, 'first moment achievement');
--assert.ok(v.history.some(e => e.kind === 'achievement' && /Said Something/.test(e.title)), 'history records it');
--assert.ok(!v.achievements.some(a => a.for === 'fighter'), 'a viewer is not shown fighter achievements');
--assert.ok(ch.profile(`user:${viewer}`).xp >= 12 + 5, 'achievement XP lands on the same bar');
--console.log('✅ chatter achievements from moments');
--
--// A fighter: first blood → win → the beef events + achievements + tier.
--beef.recordHit(u1, u2, { quality: 8, best_line: 'grizzly streams to alts', about: 'alts' });
--let f = pr.view(`user:${u1}`);
--assert.ok(f.achievements.find(a => a.id === 'first_blood').earned_at, 'first blood');
--assert.ok(f.history.some(e => e.kind === 'beef'), 'beef in history');
--const b = beef.openBeefsFor(u1)[0];
--db.run(`UPDATE arena_beefs SET clock_until = datetime('now', '-1 minute') WHERE id = ?`, [b.id]); beef.tick();
--f = pr.view(`user:${u1}`);
--assert.ok(f.achievements.find(a => a.id === 'first_win').earned_at, 'first win');
--assert.ok(f.history.some(e => e.kind === 'beef_over' && /Beat grizzly/.test(e.title)));
--assert.ok(f.xp >= 40 + 20 + 30, `xp accrued: ${f.xp}`);
--assert.strictEqual(pr.view(`user:${u2}`).history.some(e => /Lost to/.test(e.title)), true);
--board.addXp(u1, 200, 'test');
--f = pr.view(`user:${u1}`);
--assert.strictEqual(f.tier.name, 'Silver'); assert.ok(f.history.some(e => e.kind === 'tier' && /Silver/.test(e.title)), 'tier-up recorded once');
--board.addXp(u1, 1, 'test');
--assert.strictEqual(pr.view(`user:${u1}`).history.filter(e => e.kind === 'tier').length, 1, 'not re-paid');
--assert.ok(f.week_xp >= f.xp - 1, 'weekly XP counts recent gains');
--const wk = pr.weeklyFighters(3); assert.strictEqual(wk[0].user.id, u1); assert.strictEqual(wk[0].tier.name, 'Silver');
--console.log('✅ fighter achievements, beef history, tiers (paid once), weekly ladder');
--console.log('\n✅ All Arena progress tests passed');
--process.exit(0);
ddiff --git a/test/arena.test.js b/test/arena.test.js
iindex 29fa47ae..b89cec78 100644
---- a/test/arena.test.js
++++ b/test/arena.test.js
@@@ -1,9 +1,10 @@
'use strict';
--// Arena roster regression tests: ratings are fair percentiles (incl. MIC from the transcripts),
--// the behaviour-only filter blocks threats/minors/doxxing but never vocabulary, quotes come from
--// real transcript lines with VOD links, and the roster/card/stat API works end-to-end on a temp DB
--// without any AI configured (personas fall back, no LLM calls). Beefs + the board: arena-beef-board.test.js.
++// Arena roster regression tests (Battle Cam mode): ratings are fair percentiles over MIC-ONLY
++// metrics, the behaviour-only filter blocks threats/minors/doxxing but never vocabulary, quotes
++// come from real transcript lines with VOD links, and the roster/card/stat API works end-to-end on
++// a temp DB without any AI configured (personas fall back, no LLM calls). Beefs + the ledger +
++// the listener: arena-mic.test.js.
const assert = require('assert');
const fs = require('fs');
@@@ -19,22 +20,22 @@ db.initDb();
const arena = require('../server/arena/arena-service');
// ── Pure helpers ──
--const base = { peak_viewers: 10, hours: 5, messages_per_hour: 20, loyalty_score: 30, clutch_per_hour: 0.2, avg_viewers: 4, voice: { voice_score: 5 } };
++const base = { voice: { talk_ratio_pct: 10, speech_minutes: 20, wpm: 100 }, mic: { avg_quality: 2, hits_per_mic_hour: 0.5, wins: 0, clapback_rate: 0 } };
const ratings = arena._computeRatings({
-- 1: { ...base, peak_viewers: 100, hours: 50, messages_per_hour: 200, loyalty_score: 300, clutch_per_hour: 2, avg_viewers: 40, voice: { voice_score: 60 } },
++ 1: { voice: { talk_ratio_pct: 60, speech_minutes: 300, wpm: 180 }, mic: { avg_quality: 8, hits_per_mic_hour: 6, wins: 5, clapback_rate: 1 } },
2: { ...base },
-- 3: { ...base, peak_viewers: 50, hours: 25, messages_per_hour: 100, loyalty_score: 150, clutch_per_hour: 1, avg_viewers: 20, voice: { voice_score: 30 } },
++ 3: { voice: { talk_ratio_pct: 30, speech_minutes: 100, wpm: 140 }, mic: { avg_quality: 5, hits_per_mic_hour: 2, wins: 2, clapback_rate: 0.5 } },
});
--assert.strictEqual(ratings[1].hype, 99, 'top of every metric is a 99');
--assert.strictEqual(ratings[2].hype, 40, 'bottom is the floor, never 0');
--assert.strictEqual(ratings[1].mic, 99, 'MIC ranks the voice score');
--assert.strictEqual(ratings[2].mic, 40);
--assert.ok(ratings[3].hype > 40 && ratings[3].hype < 99, 'middle sits in between');
++assert.strictEqual(ratings[1].heat, 99, 'top of every metric is a 99');
++assert.strictEqual(ratings[2].heat, 40, 'bottom is the floor, never 0');
++assert.strictEqual(ratings[1].kills, 99, 'KILLS ranks beef wins');
++assert.strictEqual(ratings[2].mouth, 40);
++assert.ok(ratings[3].aim > 40 && ratings[3].aim < 99, 'middle sits in between');
assert.ok(ratings[1].power > ratings[3].power && ratings[3].power > ratings[2].power, 'power orders the roster');
assert.ok(Math.abs(Object.values(arena.STAT_WEIGHTS).reduce((a, b) => a + b, 0) - 1) < 1e-9, 'stat weights sum to 1');
const solo = arena._computeRatings({ 7: { ...base } });
--assert.strictEqual(solo[7].hype, 70, 'a roster of one is a flat 70 — no population to compare to');
--console.log('✅ ratings are percentile-based with a floor (7 stats incl. MIC)');
++assert.strictEqual(solo[7].heat, 70, 'a roster of one is a flat 70 — no population to compare to');
++console.log('✅ ratings are percentile-based with a floor (7 mic stats)');
// ── Filter: behaviour, not vocabulary (the platform does not censor language) ──
for (const bad of ['kys', 'kill yourself', 'go drink bleach', 'I will kill you', "i'm gonna stab him", 'his home address is 12 elm st', 'her phone number is 555 0199', 'child porn link', 'cp links in bio', 'doxxed him already'])
@@@ -45,21 +46,19 @@ console.log('✅ filter blocks threats / minors / doxxing only — slurs and pro
// ── End-to-end on a temp DB (no AI configured → fallback personas/quotes, no LLM calls) ──
const mk = (username, key) => Number(db.createUser({ username, email: `${username}@x`, password_hash: 'x', display_name: username.toUpperCase(), stream_key: key }).lastInsertRowid);
--const u1 = mk('alpha', 'a'.repeat(32)), u2 = mk('bravo', 'b'.repeat(32)), u3 = mk('charlie', 'c'.repeat(32)), idle = mk('idle', 'd'.repeat(32));
--for (const uid of [u1, u2, u3, idle]) db.ensureChannel(uid);
--const stream = (uid, peak, hours, daysAgo) => {
++const u1 = mk('alpha', 'a'.repeat(32)), u2 = mk('bravo', 'b'.repeat(32)), u3 = mk('charlie', 'c'.repeat(32)), idle = mk('idle', 'd'.repeat(32)), mute = mk('mute', 'e'.repeat(32));
++for (const uid of [u1, u2, u3, idle, mute]) db.ensureChannel(uid);
++const stream = (uid, hours, daysAgo) => {
const id = Number(db.createStream({ user_id: uid, title: `${uid} stream`, category: 'irl', protocol: 'rtmp' }).lastInsertRowid);
-- db.run(`UPDATE streams SET is_live = 0, started_at = datetime('now', ?), ended_at = datetime('now', ?), duration_seconds = ?, peak_viewers = ? WHERE id = ?`,
-- [`-${daysAgo} days`, `-${daysAgo} days`, Math.round(hours * 3600), peak, id]);
-- db.run(`INSERT INTO stream_analytics (stream_id, avg_viewers, peak_viewers, unique_chatters, total_messages, total_watch_minutes, new_followers, clips_created, coins_earned)
-- VALUES (?, ?, ?, ?, ?, ?, 0, ?, 0)`, [id, peak / 2, peak, peak, peak * 20, peak * 60, Math.round(hours)]);
++ db.run(`UPDATE streams SET is_live = 0, started_at = datetime('now', ?), ended_at = datetime('now', ?), duration_seconds = ?, peak_viewers = 999 WHERE id = ?`,
++ [`-${daysAgo} days`, `-${daysAgo} days`, Math.round(hours * 3600), id]);
return id;
};
--const s1 = stream(u1, 120, 6, 2); stream(u1, 80, 4, 5);
--stream(u2, 30, 10, 1);
--stream(u3, 10, 1, 9);
--stream(idle, 500, 50, 120); // too old → not on the roster
--db.run('INSERT INTO follows (follower_id, streamer_id) VALUES (?, ?), (?, ?), (?, ?)', [u2, u1, u3, u1, u1, u2]);
++const s1 = stream(u1, 6, 2); stream(u1, 4, 5);
++const s2 = stream(u2, 10, 1);
++const s3 = stream(u3, 1, 9);
++const sIdle = stream(idle, 50, 120); // too old → not on the roster even with speech
++stream(mute, 50, 1); // streams a lot, never transcribed → not on the roster
// Transcript for alpha: 40 lines over the 6 h stream, some hype, all linked to a VOD.
db.run(`INSERT INTO vods (id, user_id, stream_id, title, file_path, is_public, created_at) VALUES (901, ?, ?, 'alpha vod', '/x/alpha.webm', 1, datetime('now', '-2 days'))`, [u1, s1]);
@@@ -70,6 +69,11 @@ for (let i = 0; i < 40; i++) {
}
db.addTimelineEvents(lines);
db.addTimelineEvents([{ stream_id: s1, user_id: u1, vod_id: 901, kind: 'sound', start_sec: 30, end_sec: 33, text: null, label: 'Laughter', confidence: 0.8 }, { stream_id: s1, user_id: u1, vod_id: 901, kind: 'sound', start_sec: 90, end_sec: 93, text: null, label: 'Rock music', confidence: 0.8 }]);
++// bravo + charlie: a few lines each so they are on the roster; idle: lines, but 120 days old.
++db.addTimelineEvents(Array.from({ length: 8 }, (_, i) => ({ stream_id: s2, user_id: u2, vod_id: null, kind: 'speech', start_sec: i * 100, end_sec: i * 100 + 5, text: `bravo line ${i} hello chat`, label: null, confidence: 0.9 })));
++db.addTimelineEvents(Array.from({ length: 5 }, (_, i) => ({ stream_id: s3, user_id: u3, vod_id: null, kind: 'speech', start_sec: i * 100, end_sec: i * 100 + 5, text: `charlie line ${i}`, label: null, confidence: 0.9 })));
++db.addTimelineEvents(Array.from({ length: 3 }, (_, i) => ({ stream_id: sIdle, user_id: idle, vod_id: null, kind: 'speech', start_sec: i * 100, end_sec: i * 100 + 5, text: `idle line ${i}`, label: null, confidence: 0.9 })));
++db.run(`UPDATE stream_timeline_events SET created_at = datetime('now', '-120 days') WHERE user_id = ?`, [idle]);
// Offensive lines are fair game for quotes; threats / doxxing never are.
db.addTimelineEvents([
@@@ -90,55 +94,57 @@ assert.ok(voice.talk_ratio_pct > 0 && voice.talk_ratio_pct <= 100, `talk ratio:
assert.ok(voice.hype_hits >= 10, `hype hits counted: ${voice.hype_hits}`);
assert.strictEqual(voice.laughs, 1);
assert.deepStrictEqual(voice.top_sounds.map(s => s.label).sort(), ['Laughter', 'Rock music']);
--assert.ok(voice.voice_score > 0);
--assert.strictEqual(arena._voiceStatsFor(u2, '-90 days').has_data, false, 'no transcript → no data');
--console.log('✅ MIC voice stats from the transcription timeline');
++assert.strictEqual(arena._voiceStatsFor(mute, '-90 days').has_data, false, 'no transcript → no data');
++console.log('✅ voice stats from the transcription timeline');
const fighters = arena.listFighters();
--assert.deepStrictEqual(fighters.map(f => f.user.username), ['alpha', 'bravo', 'charlie'], `roster ordered by power, idle excluded: ${fighters.map(f => f.user.username)}`);
++assert.deepStrictEqual(fighters.map(f => f.user.username).sort(), ['alpha', 'bravo', 'charlie'], `roster = the heard, not the loud-in-numbers: ${fighters.map(f => f.user.username)}`);
++assert.strictEqual(fighters[0].user.username, 'alpha', 'the biggest mouth tops the ladder');
assert.strictEqual(fighters[0].rank, 1);
--assert.strictEqual(fighters[0].ratings.mic, 99, 'the only one with transcripts tops MIC');
--assert.strictEqual(fighters[1].ratings.mic, fighters[2].ratings.mic, 'no-transcript fighters tie on MIC (shared rank)');
++assert.strictEqual(fighters[0].ratings.stamina, 99, 'the most speech tops STAMINA');
assert.ok(fighters[0].persona.fighter_name, 'fallback persona has a name even with AI off');
assert.strictEqual(fighters[0].persona_is_fallback, true);
assert.strictEqual(fighters[0].voice.has_data, true);
assert.deepStrictEqual(fighters[0].record, { wins: 0, losses: 0, draws: 0 }, 'beef record starts empty');
assert.deepStrictEqual(fighters[0].level, { level: 1, xp: 0 }, 'Trash Level starts at 1');
assert.strictEqual(fighters[0].ratings.talk_bonus, 0, 'no mouth bonus without XP');
--console.log('✅ roster from real stream/analytics/transcript rows, inactive streamers excluded');
++assert.ok(fighters[0].mic && fighters[0].mic.moments === 0, 'mic stats ride along');
++assert.ok(!('tier' in fighters[0]), 'tiers are gone');
++console.log('✅ roster from transcripts only; idle and never-transcribed streamers excluded');
(async () => {
const card = await arena.getFighter('alpha');
assert.strictEqual(card.user.username, 'alpha');
assert.strictEqual(card.rank, 1);
assert.ok(card.raw.hours >= 9.9 && card.raw.hours <= 10.1, `hours aggregated: ${card.raw.hours}`);
-- assert.strictEqual(card.raw.followers, 2);
++ 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_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.deepStrictEqual(card.beefs, []);
-- assert.strictEqual(card.active_topic, null);
assert.strictEqual(card.level.level, 1);
-- const off = await arena.getFighter('idle');
++ 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');
-- const detail = arena.getStatDetail(u1, 'hype');
++ const detail = arena.getStatDetail(u1, 'mouth');
assert.strictEqual(detail.position, 1);
assert.strictEqual(detail.series.length, 2, 'one point per stream in the window');
-- assert.strictEqual(detail.series[1].value, 120, 'latest stream last');
++ assert.ok(detail.series.some(p => p.value > 0), 'mouth series = % of stream talking');
assert.strictEqual(detail.top[0].user.username, 'alpha');
-- const micDetail = arena.getStatDetail(u1, 'mic');
-- assert.ok(micDetail.voice && micDetail.series.some(p => p.value > 0), 'mic series = % of stream talking');
++ assert.ok(detail.voice, 'voice detail rides along for voice stats');
++ assert.strictEqual(arena.getStatDetail(u1, 'hype'), null, 'audience stats do not exist');
assert.strictEqual(arena.getStatDetail(u1, 'nope'), null);
console.log('✅ stat drill-down');
assert.deepStrictEqual(arena.liveFighters(), [], 'nobody live');
const st = arena.status();
++ assert.strictEqual(st.mode, 'battle-cam');
assert.strictEqual(st.roster, 3);
-- assert.strictEqual(st.with_voice_data, 1);
++ assert.strictEqual(st.with_voice_data, 3);
assert.strictEqual(st.beefs_open, 0);
assert.strictEqual(st.listener, 15000, 'listener tick advertised');
console.log('✅ live fighters + status');