openvibers/openvibe.live Commits

Arena: the board is living lore, not voting

OP OpenVibers committed 30 Aug 2026 · 93595edc3f · 12 files, +886 −667

Subjects now come from what chat and streamers actually say: the AI reads the
last 30 min of global/stream chat + live transcripts every 5 min (only when there
is new material; keyword bursts without AI) and surfaces subjects with keywords.
Every matching chat line or transcript line becomes a moment (on-mic moments deep
link to the VOD second); the lore is rewritten as moments pile up (≥ 3 new, ≥ 8
min apart) — who started it, who said what, who is on which end. Saying a subject
on mic auto-joins the fighter; judged chunks are scored moments → XP.

Removed every vote: debates/sides, beef side picks, clout. New yappers ladder
(chatters with the most moments). User subjects: signed-in only, one per person
and per IP per 24 h, AI-rewritten into a headline + keywords, backfilled.

Board redesigned: pulse card with live counters, featured hottest subject with
lore + latest moments, compact feed rows, lore archive; subject page with the
full moment timeline, fighters on it, loudest chatters, best lines.

Personas: taunts are now ragebait in the person's own voice — the prompt gets
their own chat lines, transcript lines, chat-AI notes, overview, rooster rivals;
adds three extra taunts + typing_style.

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

Changes

ddiff --git a/docs/arena.md b/docs/arena.md
iindex 5dbd306f..178fdc19 100644
---- a/docs/arena.md
++++ b/docs/arena.md
@@@ -3,7 +3,7 @@
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:
- **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** — topics, debates, phrase challenges and bounties that come from chat (`!topic`, `!bounty`) and from the **pulse** (the AI reads global chat, transcripts and the AI timeline every 30 min and writes what the community is on about). Streamers click a topic, talk on it, clear its angles → XP → **Trash Level**.
++- **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.
With AI off everything still works: template headlines, fallback angles, keyword judges.
@@@ -24,44 +24,42 @@ The platform does not censor language. Offensive, provocative, taboo words — s
| 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 · `!side <name>`: pick who wins → **clout** when right |
++| Crowd | `!hype` / Hype button: one per person per side (no voting) |
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.
--## The board (`server/arena/board.js`)
++## The board (`server/arena/board.js`) — living lore
--| Kind | Comes from | Resolves |
--|---|---|---|
--| **topic** | chat (`!topic`), the page, the pulse | archived after 36 h idle; streamers clear its 3 AI-cut **angles** (brag / roast / bit) by talking; all 3 → *conquered* |
--| **debate** (A vs B) | the pulse (arguments exploding in chat / transcripts) | after 24 h: `talk×2 + chat picks` per side; MVP +30 XP; right picks earn clout |
--| **phrase** ("say X on stream") | the pulse (memes going round) | 6 h; no model needed — 15 XP first time it comes out of your mouth, 5 after |
--| **bounty** (on a fighter) | chat (`!bounty <user>`), the page, the pulse | 6 h; every beef hit on the target pays double; top collector +40 XP |
--
--**Heat** = judged hits×3 + hype + side picks + fighters talking×4 in the last hour; ≥ 12 is **HOT** and the board sorts by it. The **pulse** (`refreshPulse`, every 30 min, `summary` role) reads: the global chat AI summary, `chat_timeline_events`, recent chat, last-hour transcripts, stream memories, streamer overviews, VOD overviews, open beefs and the ladder — and posts up to 6 new typed events with tabloid headlines plus a one-line "pulse" sentence shown at the top of `/arena`. Templates seed the board when AI is off.
++No voting anywhere. The board is a list of **subjects** the community is actually on about, each a lore profile that writes itself:
--**Trash Level** = 1 + XP/50. XP: judged topic chunk (quality×0.8) · angle cleared +25 · topic conquered +60 · chat hype +2 · beef (above). The Trash Level ladder and the viewer **clout** ladder (right side picks, accuracy, streak) sit on `/arena`.
++- **Discovery** — every 5 min (only when ≥ 8 new lines exist, so a dead room costs nothing) the AI reads the last 30 min of global + stream chat and live transcripts and returns the subjects people are really on about (a person, a group, a joke, a drama…) with a tabloid headline, a tagline, the **keywords/slang** people use for it, and optionally one **bounty** on a streamer the community keeps naming. 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. 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.
++- **Lore** — when a subject has ≥ 3 new moments (and ≥ 8 min since the last rewrite) the AI rewrites its lore: 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).
## 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. Every line is checked against open phrase challenges.
++1. New `stream_timeline_events` speech lines are read.
2. A fighter's name (username, display name, fighter name; `@name`; underscores/dots spoken as spaces; longest match first, never yourself) opens a **mention buffer**; lines in the following 45 s join it. At ≥ 20 words the **beef judge** runs (`aimed_at_target`, `quality`, `best_line` verbatim, `about`, `announcer`, `flagged`) → `beef.recordHit`.
--3. Other lines pool for the **topic judge** against the streamer's active board topic (≥ 20 words, ≥ 30 s between calls): `angle_idx`, `quality`, `progress_gain` (0–60), `best_line`, `about`.
++3. 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`.
4. One judge call per stream per tick; keyword heuristics when AI is off.
`/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).
## Pages
--`/arena` — pulse · on the mic now · open + settled beefs · the board (start a topic, pick debate sides, hype, "Talk on this") · Trash Level ladder · clout ladder · power ladder (rows expand) · rules.
++`/arena` — pulse (what the site is on about + live counters) · on the mic now · open + settled beefs · the board (featured subject with lore + latest moments, then a feed of subjects, lore archive) · Trash Level ladder · yappers · power ladder (rows expand) · 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>` — angles, who's talking and their per-angle progress, best lines (▶), debate sides, bounty target, join/leave.
++`/arena/topic/<id>` — headline, subject, lore, keywords, the full moment timeline (chat + on-mic with ▶ to the VOD second), fighters on it with their best line, loudest chatters, join/leave.
`/arena/<username>` — stats drill-down, voice + quotes, Trash Level card, rivalries with receipts, beef history.
## Chat commands (`server/arena/arena-chat.js`)
--`!topic <text>` (1 per 10 min) · `!bounty <user>` (1 per hour) · `!hype` (the streamer's newest open beef, else their active topic) · `!side <name|a|b>` · `!beef` · `!board` · `!arena [user]`. One command per person every 4 s; replies are private system lines, milestones go to the room.
++`!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.
## API (`/api/arena`)
@@@ -70,14 +68,14 @@ Every 15 s, for every roster stream that is live **and** has a transcript line i
| 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) |
| 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), `resolved`, `pulse`, `levels`, `clout` |
--| POST | `/board/topics {text}` · `/board/bounty {username}` | auth |
--| GET | `/board/topics/:id` | detail incl. per-member progress and best lines |
--| POST | `/board/topics/:id/join` · `/leave` (auth, roster) · `/side {side}` · `/hype {user_id}` | |
--| POST | `/pulse/refresh` | admin — force a pulse read |
++| 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}` · `/beefs/:id/side {side}` | one per person; anonymous by hashed IP |
--| GET | `/levels` · `/clout` | ladders |
++| POST | `/beefs/:id/hype {side}` | one per person per side; anonymous by hashed IP |
++| GET | `/levels` · `/yappers` | ladders |
## Settings
@@@ -87,6 +85,8 @@ Every 15 s, for every roster stream that is live **and** has a transcript line i
| `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 + expiries every 60 s · pulse every 30 min.
++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 taunts are **ragebait in the person's own voice** — the prompt gets their own chat lines (`things_they_typed_in_chat`), what they said on stream, the chat-AI notes, the streamer overview, the rooms they lurk in and the roster rivals; output includes `taunt`, three more `taunts` and a `typing_style`.
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.
ddiff --git a/public/css/style.css b/public/css/style.css
iindex 396569e3..6d3a5d77 100644
---- a/public/css/style.css
++++ b/public/css/style.css
@@@ -14360,3 +14360,85 @@ a.arena-tag:hover { color: var(--accent-light); }
.arena-leaderboard { margin: 0 0 28px; }
.arena-lore { display: flex; flex-direction: column; gap: 10px; }
++
++/* ── Arena v2.1: lore board (pulse, featured subject, feed rows, moments) ── */
++.arena-pulse-inner { display: grid; grid-template-columns: auto 1fr; gap: 16px; align-items: center; padding: 14px 18px; border-radius: 16px; border: 1px solid color-mix(in srgb, var(--accent) 50%, var(--border)); background: linear-gradient(100deg, color-mix(in srgb, var(--accent) 22%, var(--bg-card)) 0%, var(--bg-card) 60%); }
++.arena-pulse-icon { display: flex; flex-direction: column; align-items: center; justify-content: center; width: 64px; height: 64px; border-radius: 16px; background: color-mix(in srgb, var(--accent) 28%, transparent); color: var(--accent-light); font-weight: 900; font-size: 0.6rem; letter-spacing: 0.14em; }
++.arena-pulse-icon i { font-size: 1.5rem; margin-bottom: 4px; }
++.arena-pulse-body { display: flex; flex-direction: column; gap: 8px; min-width: 0; }
++.arena-pulse-text { font-size: 1.12rem; font-weight: 700; line-height: 1.35; }
++.arena-pulse-meta { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; font-size: 0.75rem; color: var(--text-muted); }
++.arena-pulse-stat { display: inline-flex; gap: 5px; align-items: center; padding: 3px 9px; border-radius: 999px; background: var(--bg-tertiary); color: var(--text-secondary); }
++.arena-pulse-stat b { color: var(--text-primary); }
++.arena-pulse-stat.is-hot { background: rgba(249, 115, 22, 0.16); color: #fb923c; } .arena-pulse-stat.is-hot b { color: #fb923c; }
++.arena-pulse-when { margin-left: auto; }
++
++.arena-featured { display: grid; grid-template-columns: 1.6fr 1fr; gap: 0; border-radius: 18px; border: 1px solid rgba(249, 115, 22, 0.45); background: linear-gradient(135deg, rgba(249, 115, 22, 0.08), var(--bg-card) 45%); overflow: hidden; margin-bottom: 12px; }
++.arena-featured-main { display: flex; flex-direction: column; gap: 10px; padding: 18px 20px; min-width: 0; }
++.arena-featured-side { display: flex; flex-direction: column; gap: 8px; padding: 18px; border-left: 1px solid var(--border); background: color-mix(in srgb, var(--bg-tertiary) 60%, transparent); min-width: 0; }
++.arena-featured-side h4 { margin: 0 0 2px; font-size: 0.78rem; letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-muted); display: flex; gap: 6px; align-items: center; }
++.arena-featured-headline { margin: 0; font-size: 1.55rem; line-height: 1.2; font-weight: 900; color: var(--text-primary); }
++.arena-topic-link:hover .arena-featured-headline { color: #fb923c; }
++.arena-featured-subject { font-size: 0.95rem; color: var(--text-secondary); }
++.arena-featured-subject b { color: var(--text-primary); font-weight: 800; }
++.arena-tagline { color: var(--text-muted); }
++.arena-lore-p { margin: 0; font-size: 0.95rem; line-height: 1.55; color: var(--text-primary); }
++.arena-lore-p.is-clamp { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; color: var(--text-secondary); font-size: 0.86rem; }
++.arena-lore-big { font-size: 1.05rem; line-height: 1.6; }
++.arena-keywords { display: flex; gap: 5px; flex-wrap: wrap; align-items: center; }
++.arena-keywords span { font-size: 0.7rem; padding: 2px 7px; border-radius: 6px; background: var(--bg-tertiary); color: var(--text-muted); font-family: ui-monospace, monospace; }
++.arena-keywords .arena-note { padding: 0; background: none; font-family: inherit; }
++.arena-chips { display: inline-flex; gap: 6px; flex-wrap: wrap; }
++.arena-chip-stat { display: inline-flex; align-items: center; gap: 5px; font-size: 0.74rem; padding: 3px 8px; border-radius: 999px; background: var(--bg-tertiary); color: var(--text-secondary); font-variant-numeric: tabular-nums; }
++.arena-chip-stat.is-live { color: #f87171; background: rgba(239, 68, 68, 0.12); }
++.arena-stack { display: inline-flex; align-items: center; }
++.arena-stack a { display: inline-flex; margin-left: -8px; border-radius: 10px; box-shadow: 0 0 0 2px var(--bg-card); }
++.arena-stack a:first-child { margin-left: 0; }
++.arena-stack-more { margin-left: 6px; font-size: 0.72rem; color: var(--text-muted); }
++.arena-moment { display: grid; grid-template-columns: 18px 1fr; gap: 8px; padding: 7px 0; border-top: 1px solid var(--border); align-items: start; }
++.arena-moment:first-child { border-top: 0; }
++.arena-moment > i { margin-top: 3px; font-size: 0.78rem; color: var(--text-muted); }
++.arena-moment.is-mic > i { color: #f87171; }
++.arena-moment.is-judged > i { color: #fb923c; }
++.arena-moment-body { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
++.arena-moment-body q { font-size: 0.9rem; quotes: "“" "”"; line-height: 1.4; }
++.arena-moment-body small { color: var(--text-muted); font-size: 0.72rem; display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
++.arena-moment-body small b { color: var(--text-secondary); }
++.arena-featured-side .arena-moment { border-top: 0; padding: 6px 8px; border-radius: 10px; background: var(--bg-card); }
++
++.arena-topic-feed { display: flex; flex-direction: column; gap: 8px; }
++.arena-topic-row { display: grid; grid-template-columns: 96px 1fr auto; gap: 14px; align-items: center; padding: 12px 14px; border-radius: 14px; background: var(--bg-card); border: 1px solid var(--border); }
++.arena-topic-row.is-hot { border-color: rgba(249, 115, 22, 0.45); }
++.arena-topic-row.arena-topic-bounty { border-left: 4px solid #f5c542; }
++.arena-topic-row.is-archived { grid-template-columns: 1fr; opacity: 0.8; }
++.arena-topic-row-heat { display: flex; flex-direction: column; align-items: flex-start; gap: 4px; }
++.arena-topic-row-main { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
++.arena-topic-row-main strong { font-size: 1.02rem; line-height: 1.3; }
++.arena-topic-row-sub { font-size: 0.8rem; color: var(--text-secondary); }
++.arena-topic-row-sub .arena-kind { text-transform: none; letter-spacing: 0; font-size: 0.8rem; color: var(--text-primary); font-weight: 700; }
++.arena-topic-row-last { display: block; font-size: 0.82rem; color: var(--text-muted); quotes: "" ""; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
++.arena-topic-row-last i { font-size: 0.7rem; margin-right: 4px; }
++.arena-topic-row-side { display: flex; flex-direction: column; align-items: flex-end; gap: 8px; }
++.arena-topic-headline-big { margin: 0; font-size: 1.7rem; line-height: 1.2; font-weight: 900; display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
++.arena-topic-cols { display: grid; grid-template-columns: 1.5fr 1fr; gap: 16px; margin-top: 6px; }
++.arena-topic-main, .arena-topic-aside { display: flex; flex-direction: column; gap: 12px; min-width: 0; }
++.arena-lore-box, .arena-moments, .arena-topic-aside section { padding: 14px; border-radius: 14px; background: var(--bg-tertiary); }
++.arena-lore-box h3, .arena-moments h3, .arena-topic-aside h3 { margin: 0 0 8px; font-size: 0.95rem; display: flex; gap: 8px; align-items: center; }
++.arena-lore-box h3 small, .arena-moments h3 small, .arena-topic-aside h3 small { color: var(--text-muted); font-weight: 400; font-size: 0.76rem; }
++.arena-moment-list { max-height: 560px; overflow-y: auto; padding-right: 4px; }
++.arena-topic-page.is-archived { opacity: 0.92; }
++.arena-member { grid-template-columns: 1fr; gap: 4px; }
++.arena-member .btn { justify-self: start; }
++
++@media (max-width: 900px) {
++ .arena-featured, .arena-topic-cols { grid-template-columns: 1fr; }
++ .arena-featured-side { border-left: 0; border-top: 1px solid var(--border); }
++}
++@media (max-width: 600px) {
++ .arena-pulse-inner { grid-template-columns: 1fr; gap: 10px; } .arena-pulse-icon { flex-direction: row; width: auto; height: auto; padding: 6px 10px; gap: 8px; } .arena-pulse-icon i { margin: 0; font-size: 1.1rem; }
++ .arena-pulse-when { margin-left: 0; flex-basis: 100%; }
++ .arena-featured-headline { font-size: 1.25rem; }
++ .arena-topic-row { grid-template-columns: 1fr; gap: 8px; }
++ .arena-topic-row-side { flex-direction: row; align-items: center; flex-wrap: wrap; }
++ .arena-topic-headline-big { font-size: 1.3rem; }
++}
ddiff --git a/public/index.html b/public/index.html
iindex 71f2b711..2cc3132f 100644
---- a/public/index.html
++++ b/public/index.html
@@@ -62,7 +62,7 @@
</script>
<!-- Stylesheets -->
-- <link rel="stylesheet" href="/css/style.css?v=146">
++ <link rel="stylesheet" href="/css/style.css?v=147">
<link rel="stylesheet" href="/css/chat-shared.css?v=1">
<link rel="stylesheet" href="/css/broadcast.css?v=11">
<link rel="stylesheet" href="/css/cosmetics.css?v=2">
@@@ -5107,7 +5107,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<script src="/js/staff-console.js?v=1" defer></script>
<script src="/js/dashboard-moderation.js?v=5" defer></script>
<script src="/js/ai-viewers.js?v=2" defer></script>
--<script src="/js/arena.js?v=5" defer></script>
++<script src="/js/arena.js?v=6" defer></script>
<script src="/js/powerchat.js?v=10" defer></script>
<script src="/js/pastes.js?v=15" defer></script>
<script src="/js/draggable-fab.js?v=2" defer></script>
ddiff --git a/public/js/arena.js b/public/js/arena.js
iindex 7ca1dc32..149a0f78 100644
---- a/public/js/arena.js
++++ b/public/js/arena.js
@@@ -4,7 +4,7 @@
* 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 event: angles, progress, best lines, sides/bounty/phrase
++ * /arena/topic/<id> one board subject: lore, moments (chat + on-mic with VOD links), fighters on it
* /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)
*
@@@ -17,9 +17,7 @@
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: 'Topic', icon: 'fa-comment-dots' },
-- debate: { label: 'Debate', icon: 'fa-scale-unbalanced' },
-- phrase: { label: 'Say it', icon: 'fa-quote-left' },
++ topic: { label: 'Subject', icon: 'fa-comment-dots' },
bounty: { label: 'Bounty', icon: 'fa-sack-dollar' },
};
let _arenaRoster = null;
@@@ -162,25 +160,25 @@ async function _aRenderHome(root) {
<div class="arena-hero">
<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. Talk on a board topic and your Trash Level climbs. Chat picks sides, hypes, starts topics and posts bounties.</p>
++ <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>
</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>
</div>
</div>
-- <section class="arena-pulse" id="arena-pulse">${_aPulse(boardData.pulse)}</section>
++ <section class="arena-pulse" id="arena-pulse">${_aPulse(boardData.pulse, boardData)}</section>
<section class="arena-live" id="arena-live">${_aRenderLive(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, cleared angles, conquered topics, chat hype</small></h2>
++ <h2><i class="fa-solid fa-fire"></i> Trash Level ladder <small>XP from beef hits, judged moments on subjects, chat hype</small></h2>
${_aLevelsList(boardData.levels || [])}
</div>
<div class="arena-ladder">
-- <h2><i class="fa-solid fa-crown"></i> Chat clout <small>viewers who pick the winning side</small></h2>
-- ${_aCloutList(boardData.clout || [])}
++ <h2><i class="fa-solid fa-keyboard"></i> Yappers <small>chatters who keep the subjects alive (7 days)</small></h2>
++ ${_aYappersList(boardData.yappers || [])}
</div>
</section>
<section class="arena-leaderboard">
@@@ -194,8 +192,9 @@ async function _aRenderHome(root) {
<h3>How the Arena 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> Topics, debates, phrase challenges and bounties come from chat (<code>!topic</code>, <code>!bounty</code>) and from the community pulse — the AI reads global chat, transcripts and the AI timeline every 30 min. Click a topic, talk on it, clear its angles → XP → Trash Level.</li>
-- <li><b>Chat.</b> <code>!hype</code> boosts your streamer · <code>!side &lt;name&gt;</code> picks a side (clout if you're right) · <code>!topic &lt;text&gt;</code> · <code>!bounty &lt;user&gt;</code> · <code>!beef</code> · <code>!board</code> · <code>!arena</code>.</li>
++ <li><b>The board.</b> Subjects show up on their own from what chat and streamers are actually saying (the AI reads global chat + live transcripts every few minutes). Every chat line or on-mic line that mentions a subject becomes a <b>moment</b> on it, and its <b>lore</b> gets rewritten as it escalates — who started it, who said what, who's on which end. Streamers "talk on" a subject → judged moments → XP → Trash Level. 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>
</ul>
</section>`;
@@@ -213,19 +212,31 @@ async function _aRenderHome(root) {
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);
++ if (pulseEl) pulseEl.innerHTML = _aPulse(bd.pulse, bd);
_aBindHome(root);
} catch { /* keep the last render */ }
});
_aEvery(1000, () => _aTickClocks(root));
}
--function _aPulse(p) {
-- if (!p || !p.text) return `<div class="arena-pulse-inner"><span class="arena-pulse-kicker"><i class="fa-solid fa-heart-pulse"></i> Pulse</span><span class="arena-pulse-text">The community is quiet. Start something.</span></div>`;
++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">
-- <span class="arena-pulse-kicker"><i class="fa-solid fa-heart-pulse fa-beat"></i> Pulse</span>
-- <span class="arena-pulse-text">${_aEsc(p.text)}</span>
-- <span class="arena-pulse-meta">${p.sources ? _aEsc(p.sources) + ' · ' : ''}read ${_aEsc(_aAgo(p.at))}</span>
++ <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>
++ </div>
</div>`;
}
@@@ -242,7 +253,7 @@ function _aRenderLive(live) {
</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>`}
<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 board topic</span>'}
++ ${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>` : ''}
${_aA(_aConsoleLink(f.user), '<i class="fa-solid fa-ear-listen"></i> ears', 'arena-tag')}
</div>
@@@ -301,7 +312,6 @@ function _aBeefCard(b) {
${open ? `<span class="arena-beef-actions">
<button class="btn btn-ghost btn-sm arena-hype-btn" data-beef="${b.id}" data-side="a" title="Hype ${_aEsc(b.a.fighter_name)}"><i class="fa-solid fa-fire"></i> ${_aEsc(b.a.fighter_name)}</button>
<button class="btn btn-ghost btn-sm arena-hype-btn" data-beef="${b.id}" data-side="b" title="Hype ${_aEsc(b.b.fighter_name)}"><i class="fa-solid fa-fire"></i> ${_aEsc(b.b.fighter_name)}</button>
-- <span class="arena-sides-tally" title="who chat thinks wins"><i class="fa-solid fa-people-group"></i> ${b.sides.a}–${b.sides.b}</span>
</span>` : ''}
</div>
${b.feed?.length ? `<q class="arena-beef-last">${_aEsc(b.feed[b.feed.length - 1].announcer || b.feed[b.feed.length - 1].text || '')}</q>` : ''}
@@@ -321,10 +331,6 @@ async function _aHype(beefId, side, btn) {
if (btn) btn.classList.add('is-hyped');
} catch (err) { _aToast(err?.message || 'Hype failed', 'error'); }
}
--async function _aSide(beefId, side) {
-- try { const r = await api(`/arena/beefs/${beefId}/side`, { method: 'POST', body: { side } }); _aToast(`🗳️ Side picked. Crowd: ${r.a}–${r.b}. Pick right → clout.`, 'success'); return r; }
-- catch (err) { _aToast(err?.message || 'Failed', 'error'); }
--}
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) => {
@@@ -332,11 +338,6 @@ function _aBindHome(root) {
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'); }
});
-- root.querySelectorAll('.arena-debate-side').forEach(btn => btn.onclick = async (e) => {
-- e.preventDefault();
-- try { const r = await api(`/arena/board/topics/${btn.dataset.topic}/side`, { method: 'POST', body: { side: btn.dataset.side } }); _aToast(`🗳️ You're with ${btn.dataset.label}. ${r.a}–${r.b}`, 'success'); const t = btn.closest('.arena-topic'); if (t) { const tally = t.querySelector('.arena-debate-tally'); if (tally) tally.innerHTML = _aDebateTally(r); } }
-- catch (err) { _aToast(err?.message || 'Failed', 'error'); }
-- });
root.querySelectorAll('.arena-topic-join').forEach(btn => btn.onclick = async (e) => {
e.preventDefault();
try { await api(`/arena/board/topics/${btn.dataset.topic}/join`, { method: 'POST' }); _aToast('🎤 You\'re on it. Start talking on stream — the ears are listening.', 'success'); navigate(`/arena/topic/${btn.dataset.topic}`); }
@@@ -347,72 +348,116 @@ function _aBindHome(root) {
// ── Board ────────────────────────────────────────────────────
--function _aDebateTally(s) { return `<span class="arena-debate-bar"><span style="width:${s.share_a}%"></span></span><span class="arena-debate-nums">${s.a} · ${s.b}</span>`; }
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: talk + hype + joins in the last hours"><i class="fa-solid fa-fire"></i> ${t.hot ? 'HOT' : ''} ${t.heat}<span class="arena-heat-bar"><span style="width:${pct}%"></span></span></span>`;
++ 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 === 'debate') return `<span class="arena-debate-title"><b>${_aEsc(t.side_a)}</b> <em>vs</em> <b>${_aEsc(t.side_b)}</b></span>`;
-- if (t.kind === 'phrase') return `Work “<b>${_aEsc(t.phrase)}</b>” into your stream`;
-- if (t.kind === 'bounty') return `Bounty on <b>${_aEsc(t.target?.fighter_name || '?')}</b> — double XP for talking shit about them`;
++ if (t.kind === 'bounty') return `Bounty on <b>${_aEsc(t.target?.fighter_name || '?')}</b>`;
return _aEsc(t.text);
}
--function _aTopicCard(t, hot, { me } = {}) {
-- const k = ARENA_KIND[t.kind] || ARENA_KIND.topic;
++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 _aJoinBtn(t, me) {
const onIt = me && t.talking_now.some(f => f.user.id === me.id);
const onRoster = me && _arenaRoster?.fighters?.some(f => f.user.id === me.id);
-- return `<div class="arena-topic arena-topic-${t.kind} ${t.hot ? 'is-hot' : ''}" data-topic="${t.id}">
-- <div class="arena-topic-head">
-- <span class="arena-kind"><i class="fa-solid ${k.icon}"></i> ${k.label}</span>
-- ${_aHeat(t, hot)}
-- <span class="arena-topic-by">${t.created_by === 'ai' ? '<i class="fa-solid fa-heart-pulse"></i> pulse' : `<i class="fa-solid fa-user"></i> ${_aEsc(t.creator_name || t.created_by)}`}${t.expires_at ? ` · <span class="arena-expires" data-until="${_aEsc(t.expires_at)}">${_aEsc(_aClock((Date.parse(t.expires_at) - Date.now()) / 1000))} left</span>` : ''}</span>
++ if (!onRoster || t.kind === 'bounty' || t.status !== 'open') return '';
++ return onIt ? '<span class="arena-tag arena-tag-hot"><i class="fa-solid fa-microphone"></i> you\'re on it</span>' : `<button class="btn btn-primary btn-sm arena-topic-join" data-topic="${t.id}"><i class="fa-solid fa-microphone"></i> Talk on this</button>`;
++}
++function _aFeatured(t, hot, me) {
++ const k = ARENA_KIND[t.kind] || ARENA_KIND.topic;
++ return `<div class="arena-featured arena-topic-${t.kind}" data-topic="${t.id}">
++ <div class="arena-featured-main">
++ <div class="arena-topic-head">
++ <span class="arena-kind"><i class="fa-solid ${k.icon}"></i> ${t.hot ? 'Hottest right now' : 'Top of the board'}</span>
++ ${_aHeat(t, hot)}
++ <span class="arena-topic-by">${t.created_by === 'community' ? `<i class="fa-solid fa-satellite-dish"></i> ${_aEsc(t.source_note || 'the community')}` : `<i class="fa-solid fa-user"></i> ${_aEsc(t.creator_name || t.created_by)}`} · ${_aEsc(_aAgo(t.created_at))}</span>
++ </div>
++ ${_aA(_aTopicLink(t), `<h3 class="arena-featured-headline">${_aEsc(t.headline || t.text)}</h3>`, 'arena-topic-link')}
++ <div class="arena-featured-subject">${_aTopicTitle(t)}${t.tagline ? ` <span class="arena-tagline">— ${_aEsc(t.tagline)}</span>` : ''}</div>
++ <p class="arena-lore-p">${_aEsc(t.lore || t.hint || 'No lore yet — nobody has said anything worth writing down. Fix that.')}</p>
++ <div class="arena-topic-foot">
++ ${_aMentionChips(t)}
++ ${t.fighters.length ? _aAvatarStack(t.fighters) : ''}
++ <span class="arena-topic-actions">${_aJoinBtn(t, me)} ${_aA(_aTopicLink(t), 'Full lore <i class="fa-solid fa-arrow-right"></i>', 'btn btn-ghost btn-sm')}</span>
++ </div>
</div>
-- ${t.headline ? `<div class="arena-topic-headline">${_aEsc(t.headline)}</div>` : ''}
-- ${_aA(_aTopicLink(t), `<div class="arena-topic-text">${_aTopicTitle(t)}</div>`, 'arena-topic-link')}
-- ${t.hint ? `<p class="arena-topic-hint">${_aEsc(t.hint)}</p>` : ''}
-- ${t.kind === 'debate' ? `<div class="arena-debate">
-- <button class="btn btn-ghost btn-sm arena-debate-side" data-topic="${t.id}" data-side="a" data-label="${_aEsc(t.side_a)}">${_aEsc(t.side_a)}</button>
-- <span class="arena-debate-tally">${_aDebateTally(t.sides || { a: 0, b: 0, share_a: 50 })}</span>
-- <button class="btn btn-ghost btn-sm arena-debate-side" data-topic="${t.id}" data-side="b" data-label="${_aEsc(t.side_b)}">${_aEsc(t.side_b)}</button>
-- </div>` : ''}
-- <div class="arena-topic-foot">
-- <span class="arena-topic-who">${t.talking_now.length ? `<span class="arena-live-dot arena-live-dot-sm"></span> ${t.talking_now.map(f => _aA(_aFighterLink(f.user), _aPortrait(f, 'xs'), '', `${f.fighter_name} is on this`)).join('')}` : ''}${t.members.filter(m => !m.active).slice(0, 6).map(m => _aA(_aFighterLink(m.user), _aPortrait(m, 'xs'), 'is-dim', `${m.fighter_name} · ${m.cleared}/${t.angles.length || 3} angles`)).join('')}</span>
-- <span class="arena-topic-stats">${t.hits} hits · ${t.conquered} conquered${t.angles?.length ? ` · ${t.angles.length} angles` : ''}</span>
-- <span class="arena-topic-actions">
-- ${t.talking_now.length && me && !onIt ? t.talking_now.slice(0, 2).map(f => `<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> ${_aEsc(f.fighter_name)}</button>`).join('') : ''}
-- ${onRoster && t.kind !== 'bounty' ? (onIt ? '<span class="arena-tag arena-tag-hot"><i class="fa-solid fa-microphone"></i> you\'re on it</span>' : `<button class="btn btn-primary btn-sm arena-topic-join" data-topic="${t.id}"><i class="fa-solid fa-microphone"></i> Talk on this</button>`) : ''}
-- </span>
++ <div class="arena-featured-side">
++ <h4><i class="fa-solid fa-bolt"></i> Latest</h4>
++ ${t.best_moment ? _aMomentLine(t.best_moment) : ''}
++ ${t.last_moment && t.last_moment.id !== t.best_moment?.id ? _aMomentLine(t.last_moment) : ''}
++ ${!t.last_moment ? '<p class="arena-note">Nothing yet. Type its name in chat or say it on stream.</p>' : ''}
++ ${t.keywords?.length ? `<div class="arena-keywords">${t.keywords.slice(0, 6).map(k => `<span>${_aEsc(k)}</span>`).join('')}</div>` : ''}
++ </div>
++ </div>`;
++}
++function _aTopicRow(t, hot, me) {
++ const k = ARENA_KIND[t.kind] || ARENA_KIND.topic;
++ return `<div class="arena-topic-row arena-topic-${t.kind} ${t.hot ? 'is-hot' : ''}" data-topic="${t.id}">
++ <div class="arena-topic-row-heat">${_aHeat(t, hot)}</div>
++ <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"><span class="arena-kind"><i class="fa-solid ${k.icon}"></i> ${_aTopicTitle(t)}</span>${t.tagline ? ` · ${_aEsc(t.tagline)}` : ''}</div>
++ ${t.last_moment ? `<q class="arena-topic-row-last"><i class="fa-solid ${t.last_moment.kind === 'chat' ? 'fa-keyboard' : 'fa-microphone'}"></i> ${_aEsc(t.last_moment.username || 'anon')}: ${_aEsc(t.last_moment.text)}</q>` : `<span class="arena-note">${_aEsc(t.hint || 'nothing said yet')}</span>`}
++ </div>
++ <div class="arena-topic-row-side">
++ ${_aMentionChips(t)}
++ ${t.fighters.length ? _aAvatarStack(t.fighters, 4) : ''}
++ ${_aJoinBtn(t, me)}
</div>
</div>`;
}
function _aRenderBoard(bd) {
const me = _aMe();
-- const open = bd.open || [], resolved = bd.resolved || [];
++ const open = bd.open || [], archive = bd.archive || [];
++ const [top, ...rest] = open;
return `<div class="arena-board-head">
-- <h2><i class="fa-solid fa-comments"></i> The board <small>${open.length} open · sorted by heat</small></h2>
-- ${me ? `<button class="btn btn-ghost btn-sm" id="arena-board-new"><i class="fa-solid fa-plus"></i> Topic</button>` : '<span class="arena-note">sign in (or type <code>!topic</code> in any chat) to add one</span>'}
++ <h2><i class="fa-solid fa-comments"></i> The board <small>${open.length} subject${open.length === 1 ? '' : 's'} the site is on about · sorted by heat</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-topic-grid">${open.map(t => _aTopicCard(t, bd.hot_threshold || 12, { me })).join('')}</div>` : '<p class="arena-note">The board is empty — the pulse fills it in a moment, or start a topic.</p>'}
-- ${resolved.length ? `<details class="arena-done"><summary>Recently settled <small>${resolved.length}</small></summary><div class="arena-resolved">${resolved.map(t => `<div class="arena-resolved-row">${_aA(_aTopicLink(t), `<span class="arena-kind">${_aEsc((ARENA_KIND[t.kind] || ARENA_KIND.topic).label)}</span> ${_aTopicTitle(t)}`)}<span class="arena-note">${t.resolved?.headline ? _aEsc(t.resolved.headline) : t.winner_side ? `${_aEsc(t.winner_side === 'a' ? t.side_a : t.side_b)} won` : 'closed'}</span></div>`).join('')}</div></details>` : ''}`;
++ ${top ? _aFeatured(top, bd.hot_threshold || 12, me) : '<p class="arena-note">The board is empty — the AI is listening to chat and live mics; the moment people start on about something it shows up here.</p>'}
++ ${rest.length ? `<div class="arena-topic-feed">${rest.map(t => _aTopicRow(t, bd.hot_threshold || 12, me)).join('')}</div>` : ''}
++ ${archive.length ? `<details class="arena-done"><summary>Lore archive <small>${archive.length} cooled off</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>${t.lore ? `<p class="arena-lore-p is-clamp">${_aEsc(t.lore)}</p>` : ''}</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> Put a topic on the board</h3>
-- <p class="arena-note">Something the streamers should talk shit about. The AI cuts it into 3 angles; anyone who clears them levels up. One line, up to 140 characters.</p>
-- <textarea id="arena-composer-text" maxlength="140" rows="3" placeholder="e.g. Streamers who read donations in a baby voice"></textarea>
-- <div class="arena-composer-actions"><button class="btn btn-primary" id="arena-composer-go">Post it</button></div>
++ <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;
-- try { const r = await api('/arena/board/topics', { method: 'POST', body: { text } }); close(); _aToast('📌 On the board.', 'success'); navigate(`/arena/topic/${r.topic.id}`); }
-- catch (err) { _aToast(err?.message || 'Failed', 'error'); }
++ 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(`📌 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();
@@@ -425,16 +470,16 @@ 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.angles_cleared} angles · ${r.topics_conquered} conquered</small></span>`, 'arena-chip')}
++ ${_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 _aCloutList(rows) {
-- if (!rows.length) return '<p class="arena-note">Pick sides in beefs and debates (<code>!side</code>) — call it right and you show up here.</p>';
++function _aYappersList(rows) {
++ if (!rows.length) return '<p class="arena-note">Type about a subject on the board in any chat — your lines become moments and you show up here.</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>
-- <span class="arena-chip"><span><strong>${r.username ? _aA(_aChannelLink(r), _aEsc(r.name)) : _aEsc(r.name)}</strong><small>${r.wins}/${r.picks} right · ${r.accuracy}%${r.streak >= 2 ? ` · ${r.streak} streak` : ''}</small></span></span>
-- <span class="arena-lvl">${r.wins} W</span>
++ <span class="arena-chip"><span><strong>${r.username ? _aA(_aChannelLink(r), _aEsc(r.name)) : _aEsc(r.name)}</strong><small>${r.moments} moment${r.moments === 1 ? '' : 's'} on ${r.topics} subject${r.topics === 1 ? '' : 's'}${r.last ? ` · “${_aEsc(String(r.last).slice(0, 60))}”` : ''}</small></span></span>
++ <span class="arena-lvl">${r.moments}</span>
</div>`).join('')}</div>`;
}
@@@ -510,14 +555,13 @@ async function _aRenderBeef(root, id) {
<h2>${_aEsc(f.fighter_name)}${f.live ? ' <span class="arena-live-pill">LIVE</span>' : ''}</h2>
<div class="arena-beef-tape-meta">#${f.rank || '–'} · ${_aLevelPill(f.level)}${b.streaks?.[s] >= 2 ? ` · ${b.streaks[s]}-beef streak` : ''}</div>
<div class="arena-beef-tape-nums"><span><b>${f.hits}</b><small>hits</small></span><span><b>${f.score}</b><small>quality</small></span><span><b>${f.crowd}</b><small>crowd /${b.rules.crowd_max}</small></span><span><b>${f.total}</b><small>total</small></span></div>
-- ${open ? `<div class="arena-beef-tape-actions"><button class="btn btn-primary btn-sm arena-hype-btn" data-beef="${b.id}" data-side="${s}"><i class="fa-solid fa-fire"></i> Hype</button><button class="btn btn-ghost btn-sm arena-side-btn" data-beef="${b.id}" data-side="${s}"><i class="fa-solid fa-flag"></i> I'm with ${_aEsc(f.fighter_name.split(' ')[0])}</button></div>` : ''}
++ ${open ? `<div class="arena-beef-tape-actions"><button class="btn btn-primary btn-sm arena-hype-btn" data-beef="${b.id}" data-side="${s}"><i class="fa-solid fa-fire"></i> Hype ${_aEsc(f.fighter_name.split(' ')[0])}</button></div>` : ''}
${f.live ? _aA(_aConsoleLink(f.user), '<i class="fa-solid fa-ear-listen"></i> listen in', 'arena-tag') : ''}
</div>`; }).join('')}
</div>
${_aTug(b, { big: true })}
<div class="arena-beef-status">
${open ? _aClockTag(b) : ''}
-- <span class="arena-sides-tally" id="arena-sides-tally"><i class="fa-solid fa-people-group"></i> chat says ${b.sides.a}–${b.sides.b} (${b.sides.share_a}% ${_aEsc(b.a.fighter_name)})</span>
${b.ends_at && open ? `<span class="arena-note"><i class="fa-solid fa-hourglass-half"></i> hard end in ${_aEsc(_aClock((Date.parse(b.ends_at) - Date.now()) / 1000))}</span>` : ''}
</div>
<div class="arena-beef-cols">
@@@ -531,14 +575,13 @@ async function _aRenderBeef(root, id) {
<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>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}). Chat's side pick is clout, not score.</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>
</ul></section>
</aside>
</div>
</div>`;
root.querySelectorAll('.arena-hype-btn').forEach(btn => btn.onclick = () => _aHype(b.id, btn.dataset.side, btn));
-- root.querySelectorAll('.arena-side-btn').forEach(btn => btn.onclick = async () => { const r = await _aSide(b.id, btn.dataset.side); if (r) { const t = document.getElementById('arena-sides-tally'); if (t) t.innerHTML = `<i class="fa-solid fa-people-group"></i> chat says ${r.a}–${r.b} (${r.share_a}% ${_aEsc(b.a.fighter_name)})`; } });
_aBindSpeak(root);
};
draw();
@@@ -549,50 +592,61 @@ async function _aRenderBeef(root, id) {
// ── Topic detail ─────────────────────────────────────────────
async function _aRenderTopic(root, id) {
-- root.innerHTML = _aSpinner('Opening the topic…');
++ root.innerHTML = _aSpinner('Opening the lore…');
let t = await api(`/arena/board/topics/${id}`);
const me = _aMe();
const draw = () => {
const k = ARENA_KIND[t.kind] || ARENA_KIND.topic;
const onIt = me && t.talking_now.some(f => f.user.id === me.id);
const onRoster = me && (_arenaRoster?.fighters?.some(f => f.user.id === me.id) ?? true);
-- const members = t.members || [];
++ const moments = t.moments || [];
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' : ''}">
++ <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}</span>
-- ${_aHeat(t, 12)}
-- <span class="arena-topic-by">${t.created_by === 'ai' ? '<i class="fa-solid fa-heart-pulse"></i> from the pulse' : `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>` : ''}${t.status !== 'open' ? ` · <b>${_aEsc(t.status)}</b>` : ''}</span>
++ <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> surfaced from ${_aEsc(t.source_note || 'the community')}` : `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>
-- ${t.headline ? `<div class="arena-topic-headline">${_aEsc(t.headline)} ${_aSpeakBtn(t.headline, '')}</div>` : ''}
-- <h1 class="arena-topic-text">${_aTopicTitle(t)}</h1>
-- ${t.hint ? `<p class="arena-topic-hint">${_aEsc(t.hint)}</p>` : ''}
-- ${t.source_note ? `<p class="arena-note"><i class="fa-solid fa-satellite-dish"></i> ${_aEsc(t.source_note)}</p>` : ''}
++ <h1 class="arena-topic-headline-big">${_aEsc(t.headline || t.text)} ${_aSpeakBtn(t.headline || t.text, '')}</h1>
++ <div class="arena-featured-subject">${_aTopicTitle(t)}${t.tagline ? ` <span class="arena-tagline">— ${_aEsc(t.tagline)}</span>` : ''}</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 === 'debate' ? `<div class="arena-debate arena-debate-big">
-- <button class="btn btn-ghost arena-debate-side" data-topic="${t.id}" data-side="a" data-label="${_aEsc(t.side_a)}" ${t.status !== 'open' ? 'disabled' : ''}>${_aEsc(t.side_a)}</button>
-- <span class="arena-debate-tally">${_aDebateTally(t.sides || { a: 0, b: 0, share_a: 50 })}</span>
-- <button class="btn btn-ghost arena-debate-side" data-topic="${t.id}" data-side="b" data-label="${_aEsc(t.side_b)}" ${t.status !== 'open' ? 'disabled' : ''}>${_aEsc(t.side_b)}</button>
-- <p class="arena-note">Streamers: say which side you're on and argue it on mic. Chat: pick a side — the side with more talk + more chat wins when the clock runs out, and right picks earn clout.</p>
-- </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>. The mark can answer back on their own stream.</span></div>` : ''}
-- <div class="arena-topic-cta">
-- ${onRoster && t.status === 'open' && t.kind !== 'bounty' ? (onIt ? `<span class="arena-tag arena-tag-hot"><i class="fa-solid fa-microphone"></i> You're on this — talk on stream, the ears are listening</span> <button class="btn btn-ghost btn-sm" id="arena-topic-leave">Leave topic</button> ${_aA(_aConsoleLink(me), '<i class="fa-solid fa-ear-listen"></i> my ears', 'btn btn-ghost btn-sm')}` : `<button class="btn btn-primary" id="arena-topic-join"><i class="fa-solid fa-microphone"></i> Talk on this</button><span class="arena-note">Sets it as your active topic. Go live with transcription on and just talk — every 30 s the judge scores what you said.</span>`) : ''}
-- ${!me ? '<span class="arena-note">Sign in to talk on this (streamers) or hype whoever\'s on it.</span>' : ''}
++ <div class="arena-topic-cols">
++ <div class="arena-topic-main">
++ <section class="arena-lore-box">
++ <h3><i class="fa-solid fa-book-skull"></i> Lore <small>${t.lore_updated_at ? `rewritten ${_aEsc(_aAgo(t.lore_updated_at))}` : 'not written yet'}</small></h3>
++ <p class="arena-lore-p arena-lore-big">${_aEsc(t.lore || t.hint || 'Nothing on record. Say something about it in chat or on mic and the lore writes itself.')}</p>
++ ${t.keywords?.length ? `<div class="arena-keywords"><span class="arena-note">the words that count:</span>${t.keywords.map(k => `<span>${_aEsc(k)}</span>`).join('')}</div>` : ''}
++ </section>
++ ${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>. The mark can answer back on their own stream.</span></div>` : ''}
++ <div class="arena-topic-cta">
++ ${onRoster && t.status === 'open' && t.kind !== 'bounty' ? (onIt ? `<span class="arena-tag arena-tag-hot"><i class="fa-solid fa-microphone"></i> You're on this — talk on stream, the ears are listening</span> <button class="btn btn-ghost btn-sm" id="arena-topic-leave">Leave</button> ${_aA(_aConsoleLink(me), '<i class="fa-solid fa-ear-listen"></i> my ears', 'btn btn-ghost btn-sm')}` : `<button class="btn btn-primary" id="arena-topic-join"><i class="fa-solid fa-microphone"></i> Talk on this</button><span class="arena-note">Or just say it on mic — the ears pick it up either way. Every judged chunk = a moment + XP.</span>`) : ''}
++ ${!me ? '<span class="arena-note">Sign in to talk on this (streamers) or hype whoever\'s on it. Anyone can pile on from chat.</span>' : ''}
++ </div>
++ <section class="arena-moments">
++ <h3><i class="fa-solid fa-stream"></i> 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 yet.</p>'}
++ </section>
++ </div>
++ <aside class="arena-topic-aside">
++ <section>
++ <h3><i class="fa-solid fa-microphone-lines"></i> Fighters on it <small>${t.talking_now.length} live</small></h3>
++ ${t.fighters.length ? t.fighters.map(f => `<div class="arena-member ${f.active ? 'is-active' : ''}">
++ ${_aBriefChip(f, ` · ${f.moments} on-mic · ${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('') : '<p class="arena-note">No streamer has touched it yet.</p>'}
++ </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>${_aEsc(c.username)}</strong></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 lines</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, '')}</div></div>`).join('')}</section>` : ''}
++ </aside>
</div>
-- ${t.angles?.length ? `<section class="arena-angles"><h3><i class="fa-solid fa-diagram-project"></i> Angles <small>clear all ${t.angles.length} to conquer the topic (+60 XP)</small></h3>
-- <div class="arena-angle-grid">${t.angles.map(a => `<div class="arena-angle"><b>${a.idx + 1}. ${_aEsc(a.text)}</b><span>${_aEsc(a.hint || '')}</span></div>`).join('')}</div></section>` : (t.kind === 'topic' ? '<p class="arena-note">Angles get cut the moment someone joins.</p>' : '')}
-- ${members.length ? `<section class="arena-members"><h3><i class="fa-solid fa-microphone-lines"></i> Who's talking <small>${t.talking_now.length} live on it</small></h3>
-- ${members.map(m => { const prog = (t.progress || {})[m.user.id] || []; return `<div class="arena-member ${m.active ? 'is-active' : ''}">
-- ${_aBriefChip(m, ` · ${m.cleared}/${t.angles.length || '?'} angles · ${m.score} pts${m.conquered_at ? ' · <b>conquered</b>' : ''}`)}
-- <div class="arena-member-progress">${(t.angles || []).map(a => { const p = prog.find(x => x.angle_idx === a.idx) || { progress: 0 }; return `<span class="arena-progress ${p.cleared ? 'is-cleared' : ''}" title="${_aEsc(a.text)}: ${p.progress}%"><span class="arena-progress-fill" style="width:${p.progress}%"></span></span>`; }).join('')}</div>
-- ${me && m.user.id !== me.id && t.status === 'open' ? `<button class="btn btn-ghost btn-sm arena-topic-hype" data-topic="${t.id}" data-user="${m.user.id}"><i class="fa-solid fa-fire"></i> Hype</button>` : ''}
-- </div>`; }).join('')}</section>` : ''}
-- ${t.best_lines?.length ? `<section class="arena-best-lines"><h3><i class="fa-solid fa-quote-left"></i> Best lines <small>as heard on stream</small></h3>
-- ${t.best_lines.map(l => `<div class="arena-quote"><div><q>${_aEsc(l.text)}</q><small>${_aA(_aFighterLink(l.user), _aEsc(l.fighter_name))}${l.angle_idx >= 0 && t.angles[l.angle_idx] ? ` · angle ${l.angle_idx + 1}` : ''}</small></div><div class="arena-quote-actions">${_aPlay(l.vod_id, l.sec)} ${_aSpeakBtn(l.text, '')}</div></div>`).join('')}</section>` : ''}
</div>`;
-- document.getElementById('arena-topic-join')?.addEventListener('click', async () => { try { const r = await api(`/arena/board/topics/${t.id}/join`, { method: 'POST' }); t = r.topic; _aToast('🎤 You\'re on it. Go talk.', 'success'); draw(); } catch (err) { _aToast(err?.message || 'Failed', 'error'); } });
++ document.getElementById('arena-topic-join')?.addEventListener('click', async () => { try { await api(`/arena/board/topics/${t.id}/join`, { method: 'POST' }); t = await api(`/arena/board/topics/${id}`); _aToast('🎤 You\'re on it. Go talk.', 'success'); draw(); } catch (err) { _aToast(err?.message || 'Failed', 'error'); } });
document.getElementById('arena-topic-leave')?.addEventListener('click', async () => { try { await api(`/arena/board/topics/${t.id}/leave`, { method: 'POST' }); t = await api(`/arena/board/topics/${id}`); draw(); } catch (err) { _aToast(err?.message || 'Failed', 'error'); } });
_aBindHome(root);
};
@@@ -602,6 +656,7 @@ async function _aRenderTopic(root, id) {
// ── 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)}`);
@@@ -632,20 +687,22 @@ async function _aRenderConsole(root, username) {
<h3><i class="fa-solid fa-microphone"></i> Hot mic <small>last lines the ears heard</small></h3>
<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.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>' : ''}` : `no beef — ${_aEsc(L.last_beef_judgement.about || 'name dropped but not aimed at 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> Topic judge (${_aEsc(_aAgo(L.last_topic_judgement.at))}):</b> ${L.last_topic_judgement.applied ? `angle ${L.last_topic_judgement.angle_idx + 1} · ${L.last_topic_judgement.quality}/10 · +${L.last_topic_judgement.progress_gain || 0}% → ${L.last_topic_judgement.progress}%${L.last_topic_judgement.cleared_angle ? ' · <b>angle cleared</b>' : ''}${L.last_topic_judgement.conquered ? ' · <b>TOPIC CONQUERED</b>' : ''}` : `off topic — ${_aEsc(L.last_topic_judgement.about || 'keep it on the angles')}`}</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>` : ''}
</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>${_aTopicTitle(c.active_topic)}</b>`)}
-- <div class="arena-angle-list">${(c.active_topic.angles || []).map(a => { const p = (c.active_topic.my_progress || []).find(x => x.angle_idx === a.idx) || { progress: 0 }; return `<div class="arena-angle-row ${p.cleared_at ? 'is-cleared' : ''}"><span class="arena-progress"><span class="arena-progress-fill" style="width:${Math.round(p.progress || 0)}%"></span></span><span><b>${a.idx + 1}.</b> ${_aEsc(a.text)} <small>${Math.round(p.progress || 0)}%${p.cleared_at ? ' ✓' : ''}</small></span></div>`; }).join('')}</div>` : `<p class="arena-note">${mine ? 'No active topic. Pick one on the board and talk on it.' : 'Not on a board topic.'}</p>${mine ? _aA('/arena', 'Open the board', 'btn btn-ghost btn-sm') : ''}`}
++ ${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 ? 'No active subject. Pick one on the board — or just say one on mic, the ears auto-join you.' : 'Not on a board subject.'}</p>${mine ? _aA('/arena', 'Open the board', 'btn btn-ghost btn-sm') : ''}`}
</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 in a line opens a 45 s window; ≥20 words go to the beef judge.</li><li>Otherwise lines pool for the topic judge (also ≥20 words, ≥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 in a line opens a 45 s window; ≥20 words go to the beef judge.</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>
</aside>
</div>
</div>`;
@@@ -695,7 +752,7 @@ function _aLevelCard(f) {
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.angles_cleared || 0}</b><small>angles cleared</small></span><span><b>${l.topics_conquered || 0}</b><small>topics conquered</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>${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>
${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, '')}</div></div>` : ''}
</div>`;
}
@@@ -744,6 +801,8 @@ async function _aRenderFighter(root, username) {
</div>
<div class="arena-flavor">
<span><i class="fa-solid fa-comment"></i> “${_aEsc(p.taunt)}” ${_aSpeakBtn(p.taunt)}</span>
++ ${(p.taunts || []).map(x => `<span><i class="fa-solid fa-fire"></i> “${_aEsc(x)}” ${_aSpeakBtn(x)}</span>`).join('')}
++ ${p.typing_style ? `<span class="arena-note"><i class="fa-solid fa-keyboard"></i> ${_aEsc(p.typing_style)}</span>` : ''}
<span><i class="fa-solid fa-music"></i> ${_aEsc(p.entrance_music)}</span>
<span><i class="fa-solid fa-quote-left"></i> ${_aEsc(p.catchphrase)}</span>
</div>
ddiff --git a/server/arena/arena-chat.js b/server/arena/arena-chat.js
iindex 5418a9ee..f275aecc 100644
---- a/server/arena/arena-chat.js
++++ b/server/arena/arena-chat.js
@@@ -1,10 +1,9 @@
/**
* OpenVibe.Live — Arena chat commands (viewers play along from any stream chat)
*
-- * !topic <text> start a board topic from chat (one per person per 10 min)
++ * !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
-- * !side <name|a|b> pick a side in the streamer's newest open beef (clout if your pick wins)
* !beef what beefs this streamer has open + clocks
* !board the pulse + hottest events on the board
* !arena [user] fighter card summary + link
@@@ -16,12 +15,10 @@
const db = require('../db/database');
--const COMMANDS = ['!topic', '!bounty', '!hype', '!side', '!beef', '!board', '!arena'];
++const COMMANDS = ['!topic', '!bounty', '!hype', '!beef', '!board', '!arena'];
const RATE_MS = 4000;
--const TOPIC_RATE_MS = 10 * 60 * 1000;
const BOUNTY_RATE_MS = 60 * 60 * 1000;
const _last = new Map(); // voterKey → ms
--const _lastTopic = new Map(); // voterKey → ms
const _lastBounty = new Map();
function voterKey(client) {
@@@ -63,12 +60,11 @@ function handle(chat, ws, client, cmd, parts) {
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>');
-- if (limited(_lastTopic, key, TOPIC_RATE_MS)) return reply('One topic per 10 minutes — let the last one cook.');
const onRoster = !!arena.loadRoster().byId[client.user.id];
-- const t = board.createTopic({ text, createdBy: onRoster ? 'streamer' : 'chat', creatorUserId: client.user.id, creatorName: who });
-- board.ensureAngles(t).catch(() => {});
-- reply(`📌 Topic is on the board: “${t.text}” → ${base()}/arena/topic/${t.id}`);
-- room(`📌 ${who} put a topic on the Arena board: “${t.text}” — streamers, click it and start talking. ${base()}/arena/topic/${t.id}`);
++ const t = await board.submitTopic({ text, userId: client.user.id, ip: client.ip, creatorName: who, onRoster });
++ 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') {
@@@ -80,8 +76,9 @@ function handle(chat, ws, client, cmd, parts) {
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}. Anyone who talks shit about them on stream gets double XP.`, hint: `Put up by ${who}. Say the name, collect the bag.`, createdBy: 'chat', creatorUserId: client.user.id, creatorName: who, kind: 'bounty', targetUserId: target.id, headline: `WANTED: ${name} — chat wants smoke` });
++ 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;
@@@ -108,18 +105,6 @@ function handle(chat, ws, client, cmd, parts) {
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}`);
return;
}
-- if (cmd === '!side') {
-- if (!streamer) return reply("!side works inside a streamer's chat.");
-- const open = beef.openBeefsFor(streamer.id);
-- if (!open.length) return reply(`${streamer.display_name || streamer.username} has no open beef to pick a side in.`);
-- const b = beef.get(open[0].id);
-- const arg = String(parts[1] || '').toLowerCase().replace(/^@/, '');
-- const matches = (side) => [side.user.username, side.user.display_name, side.fighter_name].filter(Boolean).some(n => String(n).toLowerCase() === arg);
-- const pick = arg === 'a' || matches(b.a) ? 'a' : arg === 'b' || matches(b.b) ? 'b' : !arg ? (b.a.user.id === streamer.id ? 'a' : 'b') : null;
-- if (!pick) return reply(`Usage: !side <name> — (a) ${b.a.fighter_name} vs (b) ${b.b.fighter_name}`);
-- const r = beef.pickSide(b.id, key, pick, client.user?.id || null);
-- return reply(`🗳️ You're with ${(pick === 'a' ? b.a : b.b).fighter_name}. Crowd: ${r.a}–${r.b}. Pick right and you earn clout. ${base()}/arena/beef/${b.id}`);
-- }
if (cmd === '!beef') {
if (!streamer) return reply(`Open beefs → ${base()}/arena`);
const open = beef.openBeefsFor(streamer.id).map(b => beef.get(b.id));
@@@ -131,7 +116,7 @@ function handle(chat, ws, client, cmd, parts) {
}
if (cmd === '!board') {
const v = board.boardView();
-- const top = v.open.slice(0, 3).map(t => `${t.hot ? '🔥 ' : ''}${t.kind === 'debate' ? `${t.side_a} vs ${t.side_b}` : t.kind === 'bounty' ? `Bounty: ${t.target?.fighter_name}` : t.kind === 'phrase' ? `Say “${t.phrase}”` : t.text} (${t.heat} heat)`).join(' · ');
++ 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') {
ddiff --git a/server/arena/arena-job.js b/server/arena/arena-job.js
iindex 92612425..a8ef6df0 100644
---- a/server/arena/arena-job.js
++++ b/server/arena/arena-job.js
@@@ -53,12 +53,12 @@ function start() {
setTimeout(() => tick().catch(() => {}), 90_000).unref?.();
// The ears: every 15 s the listener reads live transcripts for name-drops (beefs) and topic talk.
try { require('./listener').start(); } catch (e) { console.warn('[Arena] listener not started:', e.message); }
-- // Clocks + pulse: forfeit beefs whose clock ran out, settle debates/bounties/phrases, and
-- // re-read the community every 30 min so the board follows what people are actually saying.
++ // 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.
_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 every 60 s; pulse every 30 min)');
++ 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)');
}
async function housekeeping() {
@@@ -66,7 +66,9 @@ async function housekeeping() {
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 { await board.refreshPulse(); } catch (e) { console.warn('[Arena] pulse:', 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
}
function stop() {
ddiff --git a/server/arena/arena-service.js b/server/arena/arena-service.js
iindex 4b00a430..5c49e545 100644
---- a/server/arena/arena-service.js
++++ b/server/arena/arena-service.js
@@@ -272,6 +272,9 @@ function gatherContext(userId) {
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 message FROM chat_messages WHERE user_id = ? AND COALESCE(is_deleted, 0) = 0 AND message NOT LIKE '!%' AND LENGTH(message) BETWEEN 6 AND 200 ORDER BY id DESC LIMIT 40`, [userId]).map(r => r.message).filter(t => !isBannedText(t)).slice(0, 20); } 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 = []; }
return ctx;
}
@@@ -279,7 +282,7 @@ const PERSONA_SCHEMA = {
name: 'arena_persona',
schema: {
type: 'object', additionalProperties: false,
-- required: ['fighter_name', 'title', 'class', 'element', 'signature_move', 'special', 'weakness', 'taunt', 'lore', 'catchphrase', 'entrance_music', 'stat_quips'],
++ required: ['fighter_name', 'title', 'class', 'element', 'signature_move', 'special', 'weakness', 'taunt', 'taunts', 'typing_style', 'lore', 'catchphrase', 'entrance_music', 'stat_quips'],
properties: {
fighter_name: { type: 'string', description: 'Arena ring name, 2–5 words, based on the streamer' },
title: { type: 'string', description: 'Epithet like "The Midnight Menace of Cozy Corner"' },
@@@ -287,13 +290,16 @@ const PERSONA_SCHEMA = {
element: { type: 'string', description: 'Single word element/vibe' },
signature_move: { type: 'object', additionalProperties: false, required: ['name', 'description'], properties: { name: { type: 'string' }, description: { type: 'string' } } },
special: { type: 'object', additionalProperties: false, required: ['name', 'description'], properties: { name: { type: 'string' }, description: { type: 'string' } } },
-- weakness: { type: 'string' }, taunt: { type: 'string', description: 'One-line trash talk they would say to an opponent' },
++ 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")' },
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.
++TAUNTS ARE THE MOST IMPORTANT PART. They must be RAGEBAIT: provocative, trolly, specific, the kind of line that makes a rival or a chat reply instantly — never generic gamer fluff like "try to keep up". Write every taunt in THIS PERSON'S OWN VOICE: study \`things_they_typed_in_chat\` (how they type as a chatter: caps, punctuation, slang, emoji, typos, pet names) and \`things_they_said_on_stream\`, plus \`ai_overview\` and \`chat_ai_notes\` (what they are known for, their opinions, their beefs, what they will not shut up about) and \`rooms_they_lurk_in\` (whose chat they hang in). Reference real recurring subjects, rivals from the roster and their obsessions. If there is no chat/transcript data, infer a voice from the overview and category 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 } = {}) {
@@@ -310,8 +316,10 @@ async function generatePersona(userId, { force = false } = {}) {
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, rooms_they_lurk_in: ctx.chat_rooms,
++ 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: 950, temperature: 0.95, timeoutMs: 30000 });
++ 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 });
const persona = r && r.json && r.json.fighter_name ? r.json : null;
if (!persona) { console.warn(`[Arena] persona generation failed for user ${userId}`); return row ? parseJson(row.persona_json) : null; }
db.run(`INSERT INTO arena_profiles (user_id, persona_json, persona_model, persona_generated_at, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
@@@ -330,7 +338,7 @@ function fallbackPersona(entry) {
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.`,
-- catchphrase: 'Let him cook.', entrance_music: 'Untitled Loop (feat. Notification Sound)',
++ catchphrase: 'Let him cook.', entrance_music: 'Untitled Loop (feat. Notification Sound)', taunts: [], typing_style: null,
stat_quips: Object.fromEntries(STAT_KEYS.map(k => [k, STAT_META[k].desc])), _fallback: true,
};
}
ddiff --git a/server/arena/beef.js b/server/arena/beef.js
iindex bac9a1c1..27892ded 100644
---- a/server/arena/beef.js
++++ b/server/arena/beef.js
@@@ -156,7 +156,7 @@ function recordHit(speakerId, targetId, hit) {
// 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 });
-- if (bounty) board().recordBountyHit(speakerId, bounty, quality);
++ 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 };
}
@@@ -187,31 +187,12 @@ function resolve(beef, resolution, winnerId) {
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);
-- // Viewers who picked a side: settle their clout.
-- const voters = db.all('SELECT voter_key, side FROM arena_beef_sides WHERE beef_id = ?', [beef.id]);
-- const winSide = winnerId == null ? null : (winnerId === beef.a_user_id ? 'a' : 'b');
-- try { board().settleClout && board().settleClout(voters.map(v => v.voter_key), new Set(voters.filter(v => v.side === winSide).map(v => v.voter_key))); } catch { /* */ }
console.log(`[Arena] beef #${beef.id} resolved: ${resolution}${winnerId ? ` winner user ${winnerId}` : ' draw'}${upset ? ' UPSET' : ''}`);
const id = beef.id;
headlineFor(resolution, ctx).then(h => { if (h) db.run('UPDATE arena_beefs SET result_headline = ? WHERE id = ?', [h, id]); }).catch(() => {});
try { arena().loadRoster(true); } catch { /* */ }
}
--/** Sides: viewers back a fighter in an open beef (`!side <name>` in chat or the button). */
--function pickSide(beefId, voterKey, side, userId = null) {
-- ensureTables();
-- const beef = db.get('SELECT * FROM arena_beefs WHERE id = ? AND status = ?', [beefId, 'open']);
-- if (!beef) throw new Error('No open beef with that id');
-- if (!['a', 'b'].includes(side)) throw new Error('side must be a or b');
-- db.run('INSERT INTO arena_beef_sides (beef_id, voter_key, side, user_id) VALUES (?, ?, ?, ?) ON CONFLICT(beef_id, voter_key) DO UPDATE SET side = excluded.side, created_at = CURRENT_TIMESTAMP', [beefId, voterKey, side, userId]);
-- return sidesTally(beefId);
--}
--function sidesTally(beefId) {
-- const r = db.get(`SELECT SUM(side = 'a') AS a, SUM(side = 'b') AS b FROM arena_beef_sides WHERE beef_id = ?`, [beefId]) || {};
-- const a = r.a || 0, b = r.b || 0;
-- return { a, b, share_a: a + b ? Math.round((a / (a + b)) * 100) : 50 };
--}
--
/** Current win streak (consecutive resolved beefs won). */
function streakFor(userId) {
ensureTables();
@@@ -293,7 +274,6 @@ function beefView(beef, roster) {
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,
-- sides: sidesTally(beef.id),
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),
@@@ -325,6 +305,6 @@ function forUser(userId, limit = 8) {
module.exports = {
ensureTables, recordHit, hype, tick, recordFor, recentWins, list, get, forUser, openBeefBetween, openBeefsFor, beefView, totals,
-- pickSide, sidesTally, streakFor, rivalry, rivalriesFor, nameOf,
++ streakFor, rivalry, rivalriesFor, nameOf,
RESPONSE_LIVE_MIN, RESPONSE_OFFLINE_HOURS, MAX_BEEF_HOURS, CROWD_MAX, XP_BEEF_WIN, XP_BEEF_OPEN, XP_BEEF_HIT,
};
ddiff --git a/server/arena/board.js b/server/arena/board.js
iindex a281ca01..ba44c6bb 100644
---- a/server/arena/board.js
++++ b/server/arena/board.js
@@@ -1,40 +1,50 @@
/**
-- * OpenVibe.Live — Arena Board: topics, angles, trash levels
++ * OpenVibe.Live — Arena Board: living lore profiles of whatever the community is on about
*
-- * topic → a thing to talk shit about. Made by chat (`!topic …`), by a streamer or a
-- * viewer on the page, or seeded by the AI when the board runs dry. Lives on
-- * the board for TOPIC_TTL_HOURS after its last activity.
-- * angles → when a streamer picks a topic, the AI generates 3–4 angles inside it
-- * ("defend your schedule", "roast the rival's setup"…). The listener judges
-- * their live speech against the angles; each cleared angle gives XP, clearing
-- * all of them conquers the topic (big XP).
-- * level → Trash Level is persistent per streamer (XP_PER_LEVEL per level) and feeds
-- * POWER (see arena-service.talkBonus): beefs, angles and chat hype all pay XP.
++ * 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 ANGLES_PER_TOPIC = 3;
const XP_PER_LEVEL = 50;
--const XP_ANGLE_CLEARED = 25;
--const XP_TOPIC_CONQUERED = 60;
++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;
++const USER_TOPIC_COOLDOWN_HOURS = 24;
--const SEED_TOPICS = [
-- { text: 'Whose chat is actually the smartest chat on this site?', hint: 'Bring receipts, or confidence. Confidence is cheaper.' },
-- { text: 'Stream schedules are a lifestyle. Defend yours against the calendar.', hint: 'Sleep is for people with fewer viewers.' },
-- { text: 'The last thing that went wrong on your stream was a strategic decision. Explain.', hint: 'The audio cutting out was a bit. Obviously.' },
-- { text: 'Sell your category to someone who has never heard of it.', hint: 'You have the mic. They have their doubts.' },
-- { text: 'The worst possible advice for a new streamer, delivered with total confidence.', hint: 'Every word must sound like wisdom.' },
-- { text: 'Your setup vs everyone else\'s setup.', hint: 'Cables count. Cable management counts double.' },
-- { text: 'Rank the top three fighters and explain why you\'re about to move up.', hint: 'A plan that cannot possibly work scores double.' },
-- { text: 'What your chat says about you when you step away.', hint: 'They say worse. Get ahead of it.' },
--];
++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() {
@@@ -54,20 +64,6 @@ function ensureTables() {
last_activity_at DATETIME DEFAULT CURRENT_TIMESTAMP,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
-- db.run(`CREATE TABLE IF NOT EXISTS arena_topic_progress (
-- topic_id INTEGER NOT NULL,
-- user_id INTEGER NOT NULL,
-- angle_idx INTEGER NOT NULL,
-- progress REAL DEFAULT 0,
-- score REAL DEFAULT 0,
-- hits INTEGER DEFAULT 0,
-- best_line TEXT,
-- best_vod_id INTEGER,
-- best_sec INTEGER,
-- cleared_at DATETIME,
-- updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
-- PRIMARY KEY (topic_id, user_id, angle_idx)
-- )`);
db.run(`CREATE TABLE IF NOT EXISTS arena_topic_members (
topic_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
@@@ -83,6 +79,22 @@ function ensureTables() {
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)');
db.run(`CREATE TABLE IF NOT EXISTS arena_trash_levels (
user_id INTEGER PRIMARY KEY,
xp INTEGER DEFAULT 0,
@@@ -104,25 +116,11 @@ function ensureTables() {
ref_id INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
-- for (const col of ["kind TEXT NOT NULL DEFAULT 'topic'", 'side_a TEXT', 'side_b TEXT', 'phrase TEXT', 'target_user_id INTEGER', 'headline TEXT', 'heat REAL DEFAULT 0', 'source_note TEXT', 'expires_at DATETIME', 'winner_side TEXT', 'resolved_json TEXT']) {
++ for (const col of ["kind TEXT NOT NULL DEFAULT 'topic'", 'target_user_id INTEGER', 'headline TEXT', 'heat REAL DEFAULT 0', 'source_note TEXT', 'expires_at DATETIME', 'resolved_json TEXT',
++ 'keywords_json TEXT', 'lore TEXT', 'tagline TEXT', 'lore_updated_at DATETIME', 'lore_moment_count INTEGER DEFAULT 0', 'chat_mentions INTEGER DEFAULT 0', 'mic_mentions INTEGER DEFAULT 0', 'last_mention_at DATETIME', 'origin_json TEXT', 'creator_ip_hash TEXT', 'submitted_text TEXT']) {
try { db.run(`ALTER TABLE arena_topics ADD COLUMN ${col}`); } catch { /* exists */ }
}
-- db.run(`CREATE TABLE IF NOT EXISTS arena_topic_sides (
-- topic_id INTEGER NOT NULL,
-- voter_key TEXT NOT NULL,
-- side TEXT NOT NULL,
-- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
-- PRIMARY KEY (topic_id, voter_key)
-- )`);
-- db.run(`CREATE TABLE IF NOT EXISTS arena_viewer_clout (
-- voter_key TEXT PRIMARY KEY,
-- user_id INTEGER,
-- picks INTEGER DEFAULT 0,
-- wins INTEGER DEFAULT 0,
-- streak INTEGER DEFAULT 0,
-- best_streak INTEGER DEFAULT 0,
-- updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
-- )`);
++ 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;
}
@@@ -130,13 +128,15 @@ function ensureTables() {
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); }
--// ── Levels ───────────────────────────────────────────────────
++// ── 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, angles_cleared: 0, topics_conquered: 0, beef_hits: 0 };
++ 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 = {}) {
@@@ -149,8 +149,8 @@ function addXp(userId, amount, reason, refId = null, extra = {}) {
[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.angle) sets.push('angles_cleared = angles_cleared + 1');
-- if (extra.topic) sets.push('topics_conquered = topics_conquered + 1');
++ 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)) {
@@@ -162,10 +162,9 @@ function addXp(userId, amount, reason, refId = null, extra = {}) {
return { ...after, leveled_up: after.level > before.level, gained: amount };
}
--/** XP earned in the last N days (POWER bonus input; recent effort matters more than history). */
function recentXp(userId, days = 7) {
ensureTables();
-- return db.get(`SELECT COALESCE(SUM(amount), 0) AS n FROM arena_xp_log WHERE user_id = ? AND created_at >= datetime('now', ?)`, [userId, `-${days} days`])?.n || 0;
++ 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) {
@@@ -173,338 +172,408 @@ function levelView(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,
-- angles_cleared: r.angles_cleared || 0, topics_conquered: r.topics_conquered || 0, beef_hits: r.beef_hits || 0,
++ 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,
};
}
--// ── Topics ───────────────────────────────────────────────────
++// ── Keywords + matching ──────────────────────────────────────
function cleanTopicText(text) {
-- let t = String(text || '').replace(/\s+/g, ' ').trim();
-- if (t.length < 8) throw new Error('Give the topic a few more words');
-- if (arena()._isBannedText(t)) throw new Error('That topic crosses the line (threats, minors, doxxing)');
-- t = t.slice(0, TOPIC_MAX_LEN);
-- if (!/[.?!]$/.test(t)) t += /^(who|what|why|how|when|where|which|is|are|does|do|can|should)\b/i.test(t) ? '?' : '.';
-- return t.charAt(0).toUpperCase() + t.slice(1);
++ 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);
++}
++function normalizeKeywords(list) {
++ return [...new Set((Array.isArray(list) ? list : []).map(k => String(k || '').toLowerCase().replace(/^@/, '').trim()).filter(k => k.length >= 3 && !STOPWORDS.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 }));
}
--const KIND_TTL_HOURS = { topic: TOPIC_TTL_HOURS, debate: 24, phrase: 6, bounty: 6 };
++// ── Topics ───────────────────────────────────────────────────
--function createTopic({ text, hint = null, createdBy, creatorUserId = null, creatorName = null, kind = 'topic', sideA = null, sideB = null, phrase = null, targetUserId = null, headline = null, sourceNote = null }) {
++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 }) {
ensureTables();
const clean = cleanTopicText(text);
-- kind = ['topic', 'debate', 'phrase', 'bounty'].includes(kind) ? kind : 'topic';
-- if (kind === 'debate' && !(sideA && sideB)) throw new Error('A debate needs two sides');
-- if (kind === 'phrase' && !phrase) throw new Error('A phrase challenge needs the phrase');
++ 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');
++ let kws = normalizeKeywords(keywords);
++ if (!kws.length) kws = keywordsFromText(clean);
++ if (kind === 'bounty' && targetUserId) { const u = db.getUserById(targetUserId); if (u) kws = normalizeKeywords([...kws, u.username, u.display_name, u.username.replace(/[_.-]+/g, ' ')]); }
++ 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);
-- // Debates carry their sides as the first two angles so the judge can classify a rant.
-- const angles = kind === 'debate' ? JSON.stringify([{ text: `FOR: ${sideA}`, hint: 'Argue this side like your channel depends on it.' }, { text: `AGAINST: ${sideB}`, hint: 'Tear the other side apart.' }, { text: 'Roast everyone who took this seriously', hint: 'The third option is always chaos.' }]) : null;
-- db.run(`INSERT INTO arena_topics (text, hint, created_by, creator_user_id, creator_name, kind, side_a, side_b, phrase, target_user_id, headline, source_note, expires_at, angles_json)
-- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
-- [clean, hint ? String(hint).slice(0, 120) : null, createdBy, creatorUserId, creatorName ? String(creatorName).slice(0, 40) : null, kind, sideA ? String(sideA).slice(0, 80) : null, sideB ? String(sideB).slice(0, 80) : null, phrase ? String(phrase).slice(0, 60) : null, targetUserId, headline ? String(headline).slice(0, 160) : null, sourceNote ? String(sourceNote).slice(0, 80) : null, expires, angles]);
++ 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');
-- console.log(`[Arena] ${kind} #${row.id} by ${createdBy}${creatorName ? ' ' + creatorName : ''}: "${clean}"`);
++ invalidateMatchers();
++ console.log(`[Arena] ${kind} #${row.id} by ${createdBy}${creatorName ? ' ' + creatorName : ''}: "${clean}" [${kws.join(', ')}]`);
return row;
}
--/** Heat = activity in the last hour (hits, joins, hype, side picks), decays with age. Hot things rise. */
--function computeHeat(topicId) {
-- const hits = db.get(`SELECT COUNT(*) AS n FROM arena_xp_log WHERE ref_id = ? AND reason IN ('topic_hit', 'angle_cleared', 'topic_conquered', 'phrase_hit', 'bounty_hit') AND created_at >= datetime('now', '-60 minutes')`, [topicId])?.n || 0;
-- 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 sides = db.get(`SELECT COUNT(*) AS n FROM arena_topic_sides 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 = hits * 3 + hype + sides + talking * 4;
-- db.run('UPDATE arena_topics SET heat = ? WHERE id = ?', [heat, topicId]);
-- return heat;
++/** 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 HOT_THRESHOLD = 12;
--/** Viewers pick a side on a debate (chat `!side a|b` or the page). Clout is settled at resolution. */
--function pickSide(topicId, voterKey, side, userId = null) {
++const REFINE_SCHEMA = { name: 'arena_refine_topic', schema: { type: 'object', additionalProperties: false, required: ['subject', 'headline', 'tagline', 'hint', 'keywords', 'reject'], properties: {
++ subject: { type: 'string', description: 'the subject as a short noun phrase ≤ 70 chars, 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' },
++ 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 noun-phrase subject, a tabloid headline, a tagline crediting the submitter, a hint on how to pile on, and the keywords people would actually type or say about it (slang, variants, misspellings). 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 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, submittedText: raw });
++ backfillMoments([t]);
++ 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 }) {
ensureTables();
-- const t = db.get(`SELECT * FROM arena_topics WHERE id = ? AND status = 'open' AND kind = 'debate'`, [topicId]);
-- if (!t) throw new Error('No open debate with that id');
-- if (!['a', 'b'].includes(side)) throw new Error('side must be a or b');
-- db.run('INSERT INTO arena_topic_sides (topic_id, voter_key, side) VALUES (?, ?, ?) ON CONFLICT(topic_id, voter_key) DO UPDATE SET side = excluded.side, created_at = CURRENT_TIMESTAMP', [topicId, voterKey, side]);
-- db.run('INSERT INTO arena_viewer_clout (voter_key, user_id, picks) VALUES (?, ?, 0) ON CONFLICT(voter_key) DO UPDATE SET user_id = COALESCE(excluded.user_id, arena_viewer_clout.user_id)', [voterKey, userId]);
-- db.run('UPDATE arena_topics SET last_activity_at = CURRENT_TIMESTAMP WHERE id = ?', [topicId]);
-- return sideTally(topicId);
--}
--function sideTally(topicId) {
-- const r = db.get(`SELECT SUM(side = 'a') AS a, SUM(side = 'b') AS b FROM arena_topic_sides WHERE topic_id = ?`, [topicId]) || {};
-- const a = r.a || 0, b = r.b || 0;
-- return { a, b, share_a: a + b ? Math.round((a / (a + b)) * 100) : 50 };
--}
--function settleClout(voterKeys, wonKeys) {
-- for (const k of voterKeys) {
-- const won = wonKeys.has(k);
-- db.run(`INSERT INTO arena_viewer_clout (voter_key, picks, wins, streak, best_streak) VALUES (?, 1, ?, ?, ?)
-- ON CONFLICT(voter_key) DO UPDATE SET picks = picks + 1, wins = wins + ?, streak = CASE WHEN ? THEN streak + 1 ELSE 0 END, best_streak = MAX(best_streak, CASE WHEN ? THEN streak + 1 ELSE 0 END), updated_at = CURRENT_TIMESTAMP`,
-- [k, won ? 1 : 0, won ? 1 : 0, won ? 1 : 0, won ? 1 : 0, won ? 1 : 0, won ? 1 : 0]);
++ const t = db.get(`SELECT id, kind, status 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;
++ db.run(`INSERT INTO arena_topic_moments (topic_id, kind, source, user_id, username, stream_id, vod_id, sec, text, quality, about) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
++ [topicId, kind, source, userId, username ? clip(username, 40) : null, streamId, vodId, sec, body, quality, about ? clip(about, 80) : null]);
++ db.run(`UPDATE arena_topics SET hits = hits + 1, ${kind === 'chat' ? 'chat_mentions = chat_mentions + 1' : 'mic_mentions = mic_mentions + 1'}, last_mention_at = CURRENT_TIMESTAMP, last_activity_at = CURRENT_TIMESTAMP WHERE id = ?`, [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 }); }
}
++ return db.get('SELECT * FROM arena_topic_moments ORDER BY id DESC LIMIT 1');
++}
++
++/** 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 });
}
--function cloutLeaderboard(limit = 10) {
++
++async function joinTopic(topicId, userId) {
ensureTables();
-- return db.all('SELECT * FROM arena_viewer_clout WHERE picks >= 2 ORDER BY wins DESC, streak DESC LIMIT ?', [limit]).map(r => {
-- const u = r.user_id ? db.getUserById(r.user_id) : null;
-- return { name: u ? (u.display_name || u.username) : 'anonymous', username: u ? u.username : null, picks: r.picks, wins: r.wins, streak: r.streak, best_streak: r.best_streak, accuracy: r.picks ? Math.round((r.wins / r.picks) * 100) : 0 };
-- });
++ 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;
}
--/** Debates and challenges resolve when they expire: side with more talk + more chat wins; clout settles. */
--function resolveExpired() {
++/** A judged on-mic chunk about a topic: { on_topic, quality, best_line, about, flagged } → judged moment + XP. */
++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));
++ 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();
-- const due = db.all(`SELECT * FROM arena_topics WHERE status = 'open' AND expires_at IS NOT NULL AND expires_at <= datetime('now')`);
-- const results = [];
-- for (const t of due) {
-- let resolved = { kind: t.kind };
-- if (t.kind === 'debate') {
-- const talk = db.get(`SELECT SUM(CASE WHEN angle_idx = 0 THEN score ELSE 0 END) AS a, SUM(CASE WHEN angle_idx = 1 THEN score ELSE 0 END) AS b FROM arena_topic_progress WHERE topic_id = ?`, [t.id]) || {};
-- const tally = sideTally(t.id);
-- const scoreA = (talk.a || 0) * 2 + tally.a, scoreB = (talk.b || 0) * 2 + tally.b;
-- const winner = scoreA === scoreB ? null : (scoreA > scoreB ? 'a' : 'b');
-- const mvp = db.get(`SELECT user_id, SUM(score) AS s FROM arena_topic_progress WHERE topic_id = ? AND angle_idx = ? GROUP BY user_id ORDER BY s DESC LIMIT 1`, [t.id, winner === 'b' ? 1 : 0]);
-- resolved = { kind: 'debate', winner, score_a: Number(scoreA.toFixed(1)), score_b: Number(scoreB.toFixed(1)), chat: tally, mvp_user_id: mvp?.user_id || null };
-- const voters = db.all('SELECT voter_key, side FROM arena_topic_sides WHERE topic_id = ?', [t.id]);
-- settleClout(voters.map(v => v.voter_key), new Set(voters.filter(v => v.side === winner).map(v => v.voter_key)));
-- if (mvp?.user_id) addXp(mvp.user_id, 30, 'debate_mvp', t.id);
-- } else if (t.kind === 'bounty') {
-- 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]);
-- resolved = { kind: 'bounty', claimed_by: top?.user_id || null, total: top?.s || 0 };
-- if (top?.user_id) addXp(top.user_id, 40, 'bounty_claimed', t.id);
-- } else if (t.kind === 'phrase') {
-- const top = db.get(`SELECT user_id, SUM(amount) AS s FROM arena_xp_log WHERE reason = 'phrase_hit' AND ref_id = ? GROUP BY user_id ORDER BY s DESC LIMIT 1`, [t.id]);
-- resolved = { kind: 'phrase', top_user_id: top?.user_id || null };
-- }
-- db.run(`UPDATE arena_topics SET status = 'resolved', winner_side = ?, resolved_json = ? WHERE id = ?`, [resolved.winner || null, 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] ${t.kind} #${t.id} resolved: ${JSON.stringify(resolved)}`);
-- }
-- return results;
++ 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]); }
++ 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 };
}
--/** Bounty check for a beef hit: doubles XP while a bounty on the target is open. Returns the bounty topic or null. */
++/** 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;
++ db.run('UPDATE arena_topics SET heat = ? WHERE id = ?', [heat, topicId]);
++ 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) {
++function recordBountyHit(hitterId, bounty, quality, line = null) {
addXp(hitterId, Math.round(quality), 'bounty_hit', bounty.id);
-- db.run('UPDATE arena_topics SET hits = hits + 1, last_activity_at = CURRENT_TIMESTAMP WHERE id = ?', [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]);
}
--/** Phrase challenges: no model needed — the phrase either came out of their mouth or it didn't. */
--function checkPhrases(userId, text, lineRef) {
++
++/** Bounties settle when they expire; topics just cool off into the archive (their lore stays). */
++function resolveExpired() {
ensureTables();
-- const hits = [];
-- for (const t of db.all(`SELECT * FROM arena_topics WHERE status = 'open' AND kind = 'phrase'`)) {
-- if (!t.phrase || !String(text).toLowerCase().includes(String(t.phrase).toLowerCase())) continue;
-- const already = db.get(`SELECT COUNT(*) AS n FROM arena_xp_log WHERE user_id = ? AND reason = 'phrase_hit' AND ref_id = ?`, [userId, t.id])?.n || 0;
-- const xp = already === 0 ? 15 : 5;
-- addXp(userId, xp, 'phrase_hit', t.id, { line: text.slice(0, 200), lineScore: 6, lineVodId: lineRef?.vod_id, lineSec: lineRef?.sec });
-- db.run('UPDATE arena_topics SET hits = hits + 1, last_activity_at = CURRENT_TIMESTAMP WHERE id = ?', [t.id]);
-- db.run('INSERT OR IGNORE INTO arena_topic_members (topic_id, user_id, active) VALUES (?, ?, 0)', [t.id, userId]);
-- hits.push({ topic_id: t.id, phrase: t.phrase, xp });
++ 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}`);
}
-- return hits;
++ if (results.length) invalidateMatchers();
++ return results;
}
function archiveStale(force = false) {
ensureTables();
-- db.run(`UPDATE arena_topics SET status = 'archived' WHERE status = 'open' AND last_activity_at < datetime('now', ?)`, [`-${TOPIC_TTL_HOURS} hours`]);
++ 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 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 r of extra) db.run(`UPDATE arena_topics SET status = 'archived' WHERE id = ?`, [r.id]);
++ 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(); }
}
--const TOPIC_SEED_SCHEMA = { name: 'arena_pulse', schema: { type: 'object', additionalProperties: false, required: ['pulse', 'events'], properties: {
-- pulse: { type: 'string', description: 'One sentence, ≤ 140 chars: what the community is on about right now, hype-caster voice, name names' },
-- events: { type: 'array', minItems: 2, maxItems: 5, items: { type: 'object', additionalProperties: false, required: ['kind', 'text', 'hint', 'source', 'headline', 'side_a', 'side_b', 'phrase', 'target_username'], properties: {
-- kind: { type: 'string', enum: ['topic', 'debate', 'phrase', 'bounty'], description: 'topic = thing to rant on; debate = a take people are split on (fill side_a/side_b); phrase = a trending phrase/meme to work into your trash talk (fill phrase); bounty = someone the community keeps naming (fill target_username with an exact username from the snapshot)' },
-- text: { type: 'string', description: '≤ 120 chars' }, hint: { type: 'string', description: '≤ 90 chars, cheeky' },
-- source: { type: 'string', description: '≤ 6 words: where it came from, e.g. "global chat", "Goosely\'s stream", "the Maticus beef"' },
-- headline: { type: 'string', description: 'Tabloid headline for the board, ≤ 90 chars, inflammatory but funny' },
-- side_a: { type: 'string', description: 'debate only, else empty' }, side_b: { type: 'string', description: 'debate only, else empty' },
-- phrase: { type: 'string', description: 'phrase only, else empty; 2–6 words exactly as people say it' }, target_username: { type: 'string', description: 'bounty only, else empty' },
-- } } } } } };
++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}`; }
++}
--/**
-- * What the site is talking about right now — the raw material for community topics.
-- * Pulled from every AI/data source we have: the global chat AI summary + memory, the
-- * chat timeline, the last hour of transcripts across live streams, recent stream
-- * memories (what the vision model saw), streamer and VOD overviews, open beefs.
-- */
--function communityPulseInput() {
-- const clip = (t, n) => String(t || '').replace(/\s+/g, ' ').trim().slice(0, n);
++// ── 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 FROM chat_messages WHERE id > ? AND COALESCE(is_deleted, 0) = 0 AND message IS NOT NULL 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;
++ 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 })) 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: ['subject', 'headline', 'tagline', 'keywords', 'source', 'hint'], properties: {
++ subject: { type: 'string', description: 'The SUBJECT people are talking about, as a short noun phrase ≤ 70 chars — e.g. "Pakistanis", "the cat enema joke", "Maticus\'s vet bill", "Meshtastic tent streams". Not a question, not a prompt.' },
++ headline: { type: 'string', description: 'Tabloid headline ≤ 90 chars, inflammatory, stupid-funny' },
++ tagline: { type: 'string', description: '≤ 100 chars: current state of it — who started it, who is on which end' },
++ keywords: { type: 'array', minItems: 2, maxItems: 8, items: { type: 'string' }, description: 'lowercase words/short phrases people actually use for this subject (incl. slang and misspellings) — used to match future chat and speech' },
++ source: { type: 'string', description: '≤ 6 words: where it came from ("global chat", "Goosely\'s stream")' },
++ hint: { type: 'string', description: '≤ 90 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 — part tabloid editor, part fight promoter, part 4chan ragebaiter with a head injury. 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 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. Ignore small talk. Do not repeat subjects already on the board (match by meaning). For each: a noun-phrase subject, a tabloid headline, a tagline (who started it, who is where on it), the keywords/slang people use for it, 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 { const g = db.getChatAiSummary('global', 0, 'rolling'); if (g) { const ov = parseJson(g.overview, null); out.global_chat = clip(ov ? (ov.today || ov.alltime) : g.overview, 700); out.global_chat_memory = clip(g.memory_json, 500); } } catch { /* */ }
-- try { out.chat_timeline = db.all(`SELECT label, detail FROM chat_timeline_events WHERE scope = 'global' AND created_at >= datetime('now', '-24 hours') ORDER BY id DESC LIMIT 12`).map(r => clip(`${r.label}: ${r.detail || ''}`, 140)); } catch { out.chat_timeline = []; }
-- try { out.chat_now = db.all(`SELECT username, message FROM chat_messages WHERE COALESCE(is_deleted, 0) = 0 AND timestamp >= datetime('now', '-2 hours') AND LENGTH(message) BETWEEN 12 AND 160 ORDER BY id DESC LIMIT 40`).filter(r => !arena()._isBannedText(r.message)).slice(0, 25).map(r => clip(`${r.username}: ${r.message}`, 160)); } catch { out.chat_now = []; }
-- try { out.on_stream_now = 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', '-60 minutes') AND LENGTH(e.text) BETWEEN 30 AND 180 ORDER BY e.id DESC LIMIT 60`).filter(r => !arena()._isBannedText(r.text)).slice(0, 25).map(r => clip(`${r.username}: ${r.text}`, 180)); } catch { out.on_stream_now = []; }
-- try { out.what_streams_looked_like = db.all(`SELECT u.username, m.description FROM stream_memories m JOIN users u ON u.id = m.user_id WHERE m.captured_at >= datetime('now', '-24 hours') ORDER BY m.id DESC LIMIT 10`).map(r => clip(`${r.username}: ${r.description}`, 200)); } catch { out.what_streams_looked_like = []; }
-- try { out.streamer_overviews = db.all(`SELECT u.username, o.overview FROM streamer_overviews o JOIN users u ON u.id = o.user_id WHERE o.generated_at >= datetime('now', '-3 days') ORDER BY o.generated_at DESC LIMIT 6`).map(r => clip(`${r.username}: ${r.overview}`, 220)); } catch { out.streamer_overviews = []; }
-- try { out.recent_vods = db.all(`SELECT v.title, va.ai_overview_short AS o FROM vods v LEFT JOIN vod_ai_state va ON va.vod_id = v.id WHERE v.is_public = 1 AND v.created_at >= datetime('now', '-3 days') ORDER BY v.created_at DESC LIMIT 6`).map(r => clip(`${r.title}: ${r.o || ''}`, 160)); } catch { out.recent_vods = []; }
-- try { out.open_beefs = db.all(`SELECT a.username AS a, b.username AS b, x.opener_line FROM arena_beefs x JOIN users a ON a.id = x.a_user_id JOIN users b ON b.id = x.b_user_id WHERE x.status = 'open' ORDER BY x.opened_at DESC LIMIT 5`).map(r => clip(`${r.a} vs ${r.b}: ${r.opener_line || ''}`, 160)); } catch { out.open_beefs = []; }
-- try { const roster = arena().loadRoster(); out.ladder_top = roster.order.slice(0, 6).map(id => (parseJson(db.get('SELECT persona_json FROM arena_profiles WHERE user_id = ?', [id])?.persona_json) || {}).fighter_name || roster.byId[id].user.display_name); } catch { out.ladder_top = []; }
-- out.existing_topics = db.all(`SELECT text FROM arena_topics ORDER BY id DESC LIMIT 15`).map(r => r.text);
++ try { out.chat = db.all(`SELECT c.username, c.message, c.stream_id FROM chat_messages c WHERE COALESCE(c.is_deleted, 0) = 0 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`, [`-${SCAN_WINDOW_MIN} minutes`]).reverse().filter(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`, [`-${SCAN_WINDOW_MIN} 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 text, keywords_json FROM arena_topics WHERE status = 'open' ORDER BY id DESC LIMIT 20`).map(r => `${r.text} [${(parseJson(r.keywords_json, []) || []).join(', ')}]`);
return out;
}
--const PULSE_SYSTEM = `You are the community consciousness of a live-streaming site's Arena — part tabloid editor, part fight promoter, part ragebaiter. You get a snapshot of what the community is ACTUALLY talking about right now: global chat, what people said on their streams in the last hour, what the streams looked like, AI overviews, open beefs, the ladder. Turn it into (1) one hype-caster sentence about what everyone is on about (name names), and (2) 2–5 EVENTS for the board, specific to what is really going on — never generic:
--- an argument or split opinion → a DEBATE with two sharp sides;
--- a phrase, meme or running joke people keep saying → a PHRASE challenge (the exact words);
--- someone the community keeps naming, dunking on or defending → a BOUNTY on that username;
--- anything else spicy → a TOPIC to rant on.
--Every event gets a tabloid HEADLINE that is inflammatory but funny and a source tag. Provoke: the point is to bait streamers into responding on stream and chat into picking sides. Profanity, disrespect, provocation and trolling are welcome; this platform does not censor language. Hard line: no direct threats of violence, nothing sexual about minors, no doxxing. Output only the JSON.`;
++/** 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], source: 'chat burst', hint: 'Say it on mic. Make it worse.' }));
++}
let _pulse = { text: null, at: null, sources: [] };
--const PULSE_REFRESH_MS = 30 * 60 * 1000;
--const PULSE_MIN_OPEN = 6;
--
--/** Refresh the community pulse + add community topics. Called by the job; safe to call often. */
--async function refreshPulse({ force = false } = {}) {
++/** 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 && _pulse.at && Date.now() - _pulse.at < PULSE_REFRESH_MS) return { skipped: true };
-- const open = db.get(`SELECT COUNT(*) AS n FROM arena_topics WHERE status = 'open'`)?.n || 0;
-- let made = 0;
++ 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 input = communityPulseInput();
-- const r = await llm.complete({ role: 'summary', kind: 'arena_pulse', source: 'arena', system: PULSE_SYSTEM, user: JSON.stringify(input), json: TOPIC_SEED_SCHEMA, maxTokens: 600, temperature: 0.95, timeoutMs: 30000 });
-- if (r && r.json) {
-- _pulse = { text: String(r.json.pulse || '').slice(0, 200), at: Date.now(), sources: Object.entries(input).filter(([k, v]) => Array.isArray(v) ? v.length : (v && k !== 'at' && k !== 'existing_topics')).map(([k]) => k) };
-- const want = Math.max(2, PULSE_MIN_OPEN - open);
-- for (const e of (r.json.events || []).slice(0, want)) {
-- try {
-- let targetUserId = null;
-- if (e.kind === 'bounty') { const u = db.getUserByUsername(String(e.target_username || '').replace(/^@/, '')); if (!u || !arena().loadRoster().byId[u.id]) continue; targetUserId = u.id; }
-- createTopic({ text: e.text, hint: e.hint, createdBy: 'community', creatorName: String(e.source || 'the community').slice(0, 40), kind: e.kind, sideA: e.side_a || null, sideB: e.side_b || null, phrase: e.phrase || null, targetUserId, headline: e.headline || null, sourceNote: e.source || null });
-- made++;
-- } catch { /* dup / invalid */ }
-- }
-- }
-- } catch (e) { console.warn('[Arena] pulse:', e.message); }
++ 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) { 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) }; }
++ } catch (e) { console.warn('[Arena] discover:', e.message); }
++ } else {
++ topics = heuristicDiscover(input);
}
-- if (!_pulse.at) _pulse = { text: null, at: Date.now(), sources: [] };
-- if (open + made < 3) {
-- for (const t of SEED_TOPICS.slice().sort(() => Math.random() - 0.5).slice(0, 3 - open - made)) { try { createTopic({ text: t.text, hint: t.hint, createdBy: 'ai' }); made++; } catch { /* dup */ } }
++ let made = 0;
++ const created = [];
++ for (const t of topics.slice(0, 4)) {
++ try { 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 })); made++; } catch { /* dup */ }
}
-- if (made) console.log(`[Arena] pulse refreshed — ${made} community topic(s) added`);
-- return { made, pulse: _pulse.text };
--}
--function pulse() { return { text: _pulse.text, at: _pulse.at ? new Date(_pulse.at).toISOString() : null, sources: _pulse.sources }; }
--async function seedIfEmpty() { return (await refreshPulse({ force: (db.get(`SELECT COUNT(*) AS n FROM arena_topics WHERE status = 'open'`)?.n || 0) < 3 })).made || 0; }
--
--const ANGLES_SCHEMA = { name: 'arena_topic_angles', schema: { type: 'object', additionalProperties: false, required: ['angles'], properties: { angles: { type: 'array', minItems: ANGLES_PER_TOPIC, maxItems: ANGLES_PER_TOPIC, items: { type: 'object', additionalProperties: false, required: ['text', 'hint'], properties: { text: { type: 'string', description: 'a specific angle inside the topic to talk shit about, ≤ 90 chars' }, hint: { type: 'string', description: '≤ 70 chars' } } } } } } };
--
--async function ensureAngles(topic) {
-- let angles = parseJson(topic.angles_json, null);
-- if (Array.isArray(angles) && angles.length) return angles;
-- if (aiOn()) {
++ for (const b of bounties.slice(0, 1)) {
try {
-- const r = await llm.complete({
-- role: 'summary', kind: 'arena_topic_angles', source: 'arena',
-- system: 'Break a shit-talk topic into distinct ANGLES a live-streamer can rant on for 30–60 seconds each: one brag angle, one roast angle (name names), one bit/absurd/troll angle. Savage and funny; this platform does not censor language. Hard line only: no direct threats of violence, nothing sexual about minors, no doxxing. Output only the JSON.',
-- user: JSON.stringify({ topic: topic.text, hint: topic.hint, created_by: topic.created_by }),
-- json: ANGLES_SCHEMA, maxTokens: 300, temperature: 0.9, timeoutMs: 20000,
-- });
-- if (r && r.json && Array.isArray(r.json.angles)) angles = r.json.angles.map(a => ({ text: String(a.text).slice(0, 120), hint: String(a.hint || '').slice(0, 100) }));
-- } catch (e) { console.warn('[Arena] angles:', e.message); }
++ 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 (!angles) angles = [
-- { text: `Brag: why you win this — "${topic.text}"`, hint: 'Numbers optional, confidence mandatory.' },
-- { text: `Roast: who is worst at this and why`, hint: 'Name a fighter. Lovingly.' },
-- { text: `Bit: the most unhinged take on it`, hint: 'Commit to the bit.' },
-- ];
-- db.run('UPDATE arena_topics SET angles_json = ? WHERE id = ?', [JSON.stringify(angles), topic.id]);
-- return angles;
++ if (created.length) backfillMoments(created);
++ if (made) console.log(`[Arena] discovered ${made} subject(s) from the last ${SCAN_WINDOW_MIN} min`);
++ return { made, pulse: _pulse.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');
-- // One active topic per streamer: the listener judges their speech against it.
-- 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]); }
-- db.run('UPDATE arena_topics SET last_activity_at = CURRENT_TIMESTAMP WHERE id = ?', [topicId]);
-- const angles = await ensureAngles(topic);
-- return { topic: { ...topic, angles }, angles };
++/** Seed a fresh topic with the lines from the window that match it, so it never starts empty. */
++function backfillMoments(topics) {
++ const chat = db.all(`SELECT id, stream_id, user_id, username, message FROM chat_messages WHERE COALESCE(is_deleted, 0) = 0 AND timestamp >= datetime('now', ?) AND LENGTH(message) BETWEEN 6 AND 300 ORDER BY id ASC LIMIT 400`, [`-${SCAN_WINDOW_MIN * 4} minutes`]);
++ const speech = db.all(`SELECT e.stream_id, e.user_id, e.vod_id, e.start_sec, e.text, 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 ASC LIMIT 300`, [`-${SCAN_WINDOW_MIN * 4} minutes`]);
++ for (const t of topics) {
++ const kws = normalizeKeywords(parseJson(t.keywords_json, [])).map(keywordRegex);
++ let n = 0;
++ for (const c of chat) if (!/^!/.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 })) 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 })) n++; }
++ if (n) console.log(`[Arena] topic #${t.id} seeded with ${n} moment(s)`);
++ }
}
++function pulse() { return { text: _pulse.text, at: _pulse.at ? new Date(_pulse.at).toISOString() : null, sources: _pulse.sources }; }
--function leaveTopic(userId) {
-- ensureTables();
-- db.run('UPDATE arena_topic_members SET active = 0 WHERE user_id = ? AND active = 1', [userId]);
++// ── Lore ─────────────────────────────────────────────────────
++
++const LORE_SCHEMA = { name: 'arena_lore', schema: { type: 'object', additionalProperties: false, required: ['lore', 'headline', 'tagline', 'keywords'], properties: {
++ lore: { type: 'string', description: '3–6 sentences. The evolving story of this subject on the site: who brought it up and where, who said what (quote the best lines verbatim, name usernames), who is on which end of it, how it escalated, current state. Tabloid / fight-promoter voice, dumb, petty, funny.' },
++ headline: { type: 'string', description: 'Updated tabloid headline ≤ 90 chars' },
++ tagline: { type: 'string', description: '≤ 100 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)' },
++} } };
++const LORE_SYSTEM = `You write and update the LORE of a subject the community of a live-streaming site is talking about. You get the subject, the previous lore (if any), and the newest moments: chat lines and on-mic lines with usernames. Rewrite the lore so it tells the whole story so far and the new developments — who started it, who said the wildest things (quote them verbatim with their username), who is defending, who is dunking, streamers vs chat, escalations, plot twists. Keep what still matters from the previous lore, drop filler. 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.`;
++
++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(' ');
}
--function activeTopicFor(userId) {
++/** Rewrite a topic's lore from its moments (AI when on, template otherwise). */
++async function buildLore(topicId, { force = false } = {}) {
ensureTables();
-- const m = 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]);
-- return m || null;
--}
--
--function progressFor(topicId, userId) {
-- return db.all('SELECT * FROM arena_topic_progress WHERE topic_id = ? AND user_id = ? ORDER BY angle_idx ASC', [topicId, userId]);
--}
--
--/**
-- * Apply a judged chunk to the streamer's active topic. `judgement` is the listener's
-- * topic judgement: { angle_idx (or -1), quality, progress_gain, best_line, about, flagged }.
-- */
--function applyTopicJudgement(userId, topic, judgement, lineRef) {
-- if (judgement.flagged || judgement.angle_idx == null || judgement.angle_idx < 0) return { applied: false };
-- const angles = parseJson(topic.angles_json, []);
-- const idx = Math.max(0, Math.min(angles.length - 1, judgement.angle_idx));
-- const cur = db.get('SELECT * FROM arena_topic_progress WHERE topic_id = ? AND user_id = ? AND angle_idx = ?', [topic.id, userId, idx]) || { progress: 0, score: 0, hits: 0, best_line: null, cleared_at: null };
-- const wasCleared = !!cur.cleared_at;
-- const progress = Math.min(100, (cur.progress || 0) + (judgement.progress_gain || 0));
-- const better = judgement.best_line && (judgement.quality || 0) > (cur.best_score || 0);
-- db.run(`INSERT INTO arena_topic_progress (topic_id, user_id, angle_idx, progress, score, hits, best_line, best_vod_id, best_sec, updated_at)
-- VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, CURRENT_TIMESTAMP)
-- ON CONFLICT(topic_id, user_id, angle_idx) DO UPDATE SET progress = ?, score = score + ?, hits = hits + 1,
-- best_line = CASE WHEN ? THEN ? ELSE best_line END, best_vod_id = CASE WHEN ? THEN ? ELSE best_vod_id END, best_sec = CASE WHEN ? THEN ? ELSE best_sec END, updated_at = CURRENT_TIMESTAMP`,
-- [topic.id, userId, idx, progress, judgement.quality || 0, judgement.best_line || null, lineRef?.vod_id || null, lineRef?.sec ?? null,
-- progress, judgement.quality || 0, better ? 1 : 0, judgement.best_line || null, better ? 1 : 0, lineRef?.vod_id || null, better ? 1 : 0, lineRef?.sec ?? null]);
-- db.run('UPDATE arena_topics SET hits = hits + 1, last_activity_at = CURRENT_TIMESTAMP WHERE id = ?', [topic.id]);
-- let xp = Math.round((judgement.quality || 0) * 0.8);
-- let clearedAngle = false, conquered = false;
-- if (progress >= 100 && !wasCleared) {
-- db.run('UPDATE arena_topic_progress SET cleared_at = CURRENT_TIMESTAMP WHERE topic_id = ? AND user_id = ? AND angle_idx = ?', [topic.id, userId, idx]);
-- xp += XP_ANGLE_CLEARED; clearedAngle = true;
-- const clearedCount = db.get('SELECT COUNT(*) AS n FROM arena_topic_progress WHERE topic_id = ? AND user_id = ? AND cleared_at IS NOT NULL', [topic.id, userId])?.n || 0;
-- if (clearedCount >= angles.length) {
-- const m = db.get('SELECT conquered_at FROM arena_topic_members WHERE topic_id = ? AND user_id = ?', [topic.id, userId]);
-- if (!m?.conquered_at) {
-- db.run('UPDATE arena_topic_members SET conquered_at = CURRENT_TIMESTAMP, active = 0 WHERE topic_id = ? AND user_id = ?', [topic.id, userId]);
-- db.run('UPDATE arena_topics SET conquered = conquered + 1 WHERE id = ?', [topic.id]);
-- xp += XP_TOPIC_CONQUERED; conquered = true;
-- }
-- }
++ 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, []),
++ 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); }
}
-- const lvl = addXp(userId, xp, clearedAngle ? (conquered ? 'topic_conquered' : 'angle_cleared') : 'topic_hit', topic.id, { angle: clearedAngle, topic: conquered, line: judgement.best_line, lineScore: judgement.quality, lineVodId: lineRef?.vod_id, lineSec: lineRef?.sec });
-- return { applied: true, angle_idx: idx, progress, cleared_angle: clearedAngle, conquered, xp, level: lvl };
++ 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, 1400), out.headline ? clip(out.headline, 160) : null, out.tagline ? clip(out.tagline, 120) : null, JSON.stringify(kws), total, topicId]);
++ invalidateMatchers();
++ return { lore: out.lore, headline: out.headline, tagline: out.tagline };
}
--function hypeTopic(topicId, userId, voterKey) {
++/** Sweep: refresh lore where enough new moments landed (a few per call so the budget stays sane). */
++async function loreSweep(max = 3) {
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);
-- 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 };
++ 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 ────────────────────────────────────────────────────
@@@ -521,27 +590,32 @@ function fighterBrief(userId, roster) {
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, stream_id: m.stream_id, vod_id: m.vod_id, sec: m.sec, text: m.text, quality: m.quality, about: m.about, at: m.created_at }; }
function topicView(topic, roster, { detail = false } = {}) {
-- const angles = parseJson(topic.angles_json, []);
-- const members = db.all(`SELECT m.*, COALESCE((SELECT COUNT(*) FROM arena_topic_progress p WHERE p.topic_id = m.topic_id AND p.user_id = m.user_id AND p.cleared_at IS NOT NULL), 0) AS cleared, COALESCE((SELECT SUM(score) FROM arena_topic_progress p WHERE p.topic_id = m.topic_id AND p.user_id = m.user_id), 0) AS score FROM arena_topic_members m WHERE m.topic_id = ? ORDER BY score DESC`, [topic.id]);
++ 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 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', side_a: topic.side_a, side_b: topic.side_b, phrase: topic.phrase, target: topic.target_user_id ? fighterBrief(topic.target_user_id, roster) : null,
-- headline: topic.headline, source_note: topic.source_note, heat: Math.round(heat), hot: heat >= HOT_THRESHOLD,
-- expires_at: topic.expires_at ? new Date(topic.expires_at + 'Z').toISOString() : null, winner_side: topic.winner_side, resolved: parseJson(topic.resolved_json),
-- sides: topic.kind === 'debate' ? sideTally(topic.id) : null,
-- angles: angles.map((a, i) => ({ idx: i, text: a.text, hint: a.hint })),
-- joins: topic.joins || 0, hits: topic.hits || 0, conquered: topic.conquered || 0,
++ 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,
talking_now: members.filter(m => m.active).map(m => fighterBrief(m.user_id, roster)),
-- members: members.slice(0, 12).map(m => ({ ...fighterBrief(m.user_id, roster), active: !!m.active, cleared: m.cleared, score: Number((m.score || 0).toFixed(1)), conquered_at: m.conquered_at })),
-- created_at: topic.created_at, last_activity_at: topic.last_activity_at,
++ 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.progress = {};
-- for (const m of members) out.progress[m.user_id] = progressFor(topic.id, m.user_id).map(p => ({ angle_idx: p.angle_idx, progress: Math.round(p.progress || 0), score: Number((p.score || 0).toFixed(1)), hits: p.hits, best_line: p.best_line, best_vod_id: p.best_vod_id, best_sec: p.best_sec, cleared: !!p.cleared_at }));
-- out.best_lines = db.all(`SELECT p.*, p.user_id FROM arena_topic_progress p WHERE p.topic_id = ? AND p.best_line IS NOT NULL ORDER BY p.score DESC LIMIT 8`, [topic.id]).map(p => ({ ...fighterBrief(p.user_id, roster), text: p.best_line, vod_id: p.best_vod_id, sec: p.best_sec, angle_idx: p.angle_idx }));
++ out.submitted_text = topic.submitted_text || null;
++ out.moments = db.all('SELECT * FROM arena_topic_moments WHERE topic_id = ? ORDER BY id DESC LIMIT 80', [topic.id]).map(momentView);
++ out.top_chatters = db.all(`SELECT username, COUNT(*) AS n FROM arena_topic_moments WHERE topic_id = ? AND kind = 'chat' AND username IS NOT NULL GROUP BY username ORDER BY n DESC LIMIT 6`, [topic.id]);
++ 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;
}
@@@ -554,26 +628,35 @@ function boardView() {
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 recent = db.all(`SELECT * FROM arena_topics WHERE status = 'resolved' AND kind != 'topic' ORDER BY id DESC LIMIT 6`).map(t => topicView(t, roster));
-- return { open: topics.map(t => topicView(t, roster)), resolved: recent, pulse: pulse(), hot_threshold: HOT_THRESHOLD };
++ 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 ORDER BY xp DESC LIMIT ?', [limit]).map(r => ({ ...fighterBrief(r.user_id, roster), xp: r.xp, level: levelFor(r.xp), angles_cleared: r.angles_cleared, topics_conquered: r.topics_conquered, beef_hits: r.beef_hits, best_line: r.best_line ? { text: r.best_line, vod_id: r.best_line_vod_id, sec: r.best_line_sec } : null }));
++ 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) {
++ ensureTables();
++ return db.all(`SELECT username, user_id, COUNT(*) AS n, COUNT(DISTINCT topic_id) AS topics FROM arena_topic_moments WHERE kind = 'chat' AND username IS NOT NULL AND created_at >= datetime('now', '-7 days') GROUP BY username ORDER BY n DESC LIMIT ?`, [limit])
++ .map(r => ({ name: r.username, username: r.username, user_id: r.user_id, moments: r.n, topics: r.topics, last: db.get('SELECT text FROM arena_topic_moments WHERE username = ? AND kind = ? ORDER BY id DESC LIMIT 1', [r.username, 'chat'])?.text || null }));
}
module.exports = {
-- ensureTables, createTopic, seedIfEmpty, ensureAngles, joinTopic, leaveTopic, activeTopicFor, progressFor, applyTopicJudgement, hypeTopic,
-- addXp, levelRow, levelView, levelFor, recentXp, boardView, topicDetail, levelsLeaderboard, fighterBrief, archiveStale, refreshPulse, pulse, communityPulseInput,
-- computeHeat, pickSide, sideTally, cloutLeaderboard, resolveExpired, openBountyOn, recordBountyHit, checkPhrases, HOT_THRESHOLD, KIND_TTL_HOURS,
-- XP_PER_LEVEL, XP_ANGLE_CLEARED, XP_TOPIC_CONQUERED, XP_HYPE, TOPIC_TTL_HOURS, SEED_TOPICS,
++ ensureTables, createTopic, submitTopic, assertCanSubmit, joinTopic, leaveTopic, activeTopicFor, 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/listener.js b/server/arena/listener.js
iindex d5a91591..ef76d5e8 100644
---- a/server/arena/listener.js
++++ b/server/arena/listener.js
@@@ -97,18 +97,17 @@ const TOPIC_SCHEMA = {
name: 'arena_topic_judgement',
schema: {
type: 'object', additionalProperties: false,
-- required: ['angle_idx', 'quality', 'progress_gain', 'best_line', 'about', 'flagged'],
++ required: ['on_topic', 'quality', 'best_line', 'about', 'flagged'],
properties: {
-- angle_idx: { type: 'integer', minimum: -1, maximum: 5, description: 'which angle (0-based) this chunk addressed, -1 if none / not trash talk' },
-- quality: { type: 'integer', minimum: 0, maximum: 10 },
-- progress_gain: { type: 'integer', minimum: 0, maximum: 60, description: 'how much of that angle this chunk covered' },
-- best_line: { type: 'string' },
-- about: { type: 'string', description: '≤ 10 words: what they talked about' },
-- flagged: { type: 'boolean' },
++ 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' },
++ 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' },
++ 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 shit on a BOARD TOPIC that has several angles. You get the topic, its angles (0-based) and the last ~60 seconds of speech-to-text. Say which angle (if any) the speech addressed with actual shit talk / bragging / roasting / trolling / a committed bit, score its quality (savage, profane, petty, specific and quotable = high; generic = low), how much of that angle it covered, the best verbatim line, and a ≤ 10-word summary. Ordinary gameplay chatter or off-topic talk → angle_idx -1, progress 0. Forgive transcription noise. 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_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. Decide whether the speech is really about the subject (shit talk, a take, a rant, a bit, dunking, defending — all count), score its quality (savage, specific, petty, funny, quotable = high; generic = low), pick the best verbatim line and summarise in ≤ 10 words. Ordinary gameplay chatter or unrelated talk → on_topic false. Forgive transcription noise. 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.`;
function heuristicBeef(text, targetNames) {
const t = text.toLowerCase();
@@@ -120,13 +119,16 @@ function heuristicBeef(text, targetNames) {
const pick = (named.length ? named : sentences).sort((a, b) => b.length - a.length)[0] || text;
return { aimed_at_target: spicy, quality, best_line: pick.trim().slice(0, 200), about: text.split(/\s+/).slice(0, 8).join(' '), flagged: false, _fallback: true };
}
--function heuristicTopic(text, angles) {
++function heuristicTopic(text, topic) {
++ const kws = (parseJson(topic.keywords_json, []) || []).map(k => String(k).toLowerCase());
const t = text.toLowerCase();
-- let best = -1, bestHits = 0;
-- angles.forEach((a, i) => { const stems = (String(a.text).toLowerCase().match(/[a-z]{4,}/g) || []).map(w => w.slice(0, 5)); const hits = stems.filter(s => t.includes(s)).length; if (hits > bestHits) { bestHits = hits; best = i; } });
++ 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 excl = (text.match(/!/g) || []).length;
-- const quality = Math.min(10, 3 + excl + Math.min(3, bestHits));
-- return { angle_idx: bestHits ? best : -1, quality, progress_gain: bestHits ? Math.min(60, 12 + bestHits * 10) : 0, best_line: (text.split(/(?<=[.!?])\s+/).sort((a, b) => b.length - a.length)[0] || '').slice(0, 200), about: text.split(/\s+/).slice(0, 8).join(' '), flagged: false, _fallback: true };
++ const quality = Math.min(10, 2 + (spicy ? 3 : 0) + excl + Math.min(3, hits));
++ 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 };
}
async function judgeBeef(speakerId, targetId, text, roster) {
@@@ -145,18 +147,16 @@ async function judgeBeef(speakerId, targetId, text, roster) {
}
async function judgeTopic(speakerId, topic, text) {
-- if (arena()._isBannedText(text)) return { angle_idx: -1, quality: 0, progress_gain: 0, best_line: '', about: 'voided', flagged: true };
-- const angles = parseJson(topic.angles_json, []);
++ if (arena()._isBannedText(text)) return { on_topic: false, quality: 0, best_line: '', about: 'voided', 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({ topic: topic.text, hint: topic.hint, angles: angles.map((a, i) => `${i}: ${a.text}`), speech: text }), json: TOPIC_SCHEMA, maxTokens: 220, temperature: 0.4, timeoutMs: 25000 });
++ 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 });
if (r && r.json && typeof r.json.quality === 'number') j = r.json;
} catch (e) { console.warn('[Arena] topic judge:', e.message); }
}
-- if (!j) j = heuristicTopic(text, angles);
-- const idx = Number.isInteger(j.angle_idx) && j.angle_idx >= 0 && j.angle_idx < angles.length ? j.angle_idx : -1;
-- return { angle_idx: j.flagged ? -1 : idx, quality: Math.max(0, Math.min(10, Math.round(Number(j.quality) || 0))), progress_gain: Math.max(0, Math.min(60, Math.round(Number(j.progress_gain) || 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 = 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 };
}
// ── Tick ─────────────────────────────────────────────────────
@@@ -186,7 +186,14 @@ async function tickStream(stream, roster, events) {
const now = Date.now();
for (const r of rows) {
const line = { t: String(r.text || ''), s: Math.floor(r.start_sec), v: r.vod_id || null };
-- try { const ph = board().checkPhrases(stream.user_id, line.t, { vod_id: line.v, sec: Math.max(0, line.s - 2) }); if (ph.length) events.push({ kind: 'phrase_hit', streamId: stream.id, speakerId: stream.user_id, hits: ph }); } catch { /* */ }
++ // 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 };
++ }
++ } catch (e) { console.warn('[Arena] topic match:', e.message); }
const targets = mentionsIn(line.t, stream.user_id, roster);
if (targets.length) {
for (const tid of targets) { const m = st.mention[tid] || (st.mention[tid] = { lines: [], lastAt: 0, lastSec: 0 }); m.lines.push(line); m.lastAt = now; m.lastSec = line.s; }
@@@ -222,18 +229,17 @@ async function tickStream(stream, roster, events) {
return; // one judge call per stream per tick
}
-- // 2) Active board topic.
-- const topic = board().activeTopicFor(stream.user_id);
++ // 2) Board subject: the one they chose, else the one they just brought up on mic.
++ let topic = board().activeTopicFor(stream.user_id);
++ if (!topic && st.lastTopic && now - st.lastTopic.at < 3 * 60 * 1000) topic = db.get(`SELECT * FROM arena_topics WHERE id = ? AND status = 'open'`, [st.lastTopic.id]);
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();
-- if (!parseJson(topic.angles_json, null)) await board().ensureAngles(topic);
-- const fresh = db.get('SELECT * FROM arena_topics WHERE id = ?', [topic.id]);
-- const j = await judgeTopic(stream.user_id, fresh, text);
-- const ref = lineRefFor(lines, j.best_line);
-- const res = board().applyTopicJudgement(stream.user_id, fresh, j, ref);
-- st.lastTopicJudgement = { at: new Date().toISOString(), ...j, applied: res.applied, angle_idx: res.applied ? res.angle_idx : -1, progress: res.progress, cleared_angle: res.cleared_angle, conquered: res.conquered };
++ 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);
++ 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 });
} else if (!topic && st.topic.lines.length > 80) {
st.topic.lines = st.topic.lines.slice(-40);
ddiff --git a/server/arena/routes.js b/server/arena/routes.js
iindex 7f77d194..667d720a 100644
---- a/server/arena/routes.js
++++ b/server/arena/routes.js
@@@ -5,11 +5,11 @@
* 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} anyone signed in starts a topic
-- * GET /board/topics/:id · POST …/join · …/leave · …/side {side} · …/hype {user_id}
++ * POST /board/topics {text} signed in, 1 per person + per IP per 24 h; AI rewrites it
++ * GET /board/topics/:id · POST …/join · …/leave · …/hype {user_id}
* POST /board/bounty {username} put a bounty on a fighter
-- * GET /beefs · /beefs/:id · POST /beefs/:id/hype {side} · POST /beefs/:id/side {side}
-- * GET /levels · /clout Trash Level ladder · viewers with the best picks
++ * GET /beefs · /beefs/:id · POST /beefs/:id/hype {side}
++ * GET /levels · /yappers Trash Level ladder · viewers who keep subjects alive from chat
*/
'use strict';
@@@ -85,7 +85,7 @@ router.get('/console/:user', (req, res) => {
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), my_progress: board.progressFor(active.id, user.id) } : null,
++ active_topic: active ? board.topicDetail(active.id) : null,
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,
@@@ -98,14 +98,14 @@ router.get('/board', (req, res) => {
try {
const v = board.boardView();
res.set('Cache-Control', 'no-store');
-- res.json({ ...v, levels: board.levelsLeaderboard(8), clout: board.cloutLeaderboard(8), ai: arena.aiOn() });
++ res.json({ ...v, levels: board.levelsLeaderboard(8), yappers: board.yappersLeaderboard(8), 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 = board.createTopic({ text: req.body?.text, hint: req.body?.hint || null, createdBy: onRoster ? 'streamer' : 'viewer', creatorUserId: req.user.id, creatorName: req.user.display_name || req.user.username });
-- board.ensureAngles(t).catch(() => {});
++ 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 });
++ board.buildLore(t.id, { force: true }).catch(() => {});
res.json({ ok: true, topic: board.topicDetail(t.id) });
} catch (err) { res.status(400).json({ error: err.message }); }
});
@@@ -115,7 +115,8 @@ router.post('/board/bounty', requireAuth, (req, res) => {
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;
-- const t = board.createTopic({ text: `Bounty: ${name}. Anyone who talks shit about them on stream gets double XP.`, 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, kind: 'bounty', targetUserId: target.id, headline: `WANTED: ${name} — chat wants smoke` });
++ 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 }); }
});
@@@ -132,16 +133,15 @@ router.post('/board/topics/:id/join', requireAuth, async (req, res) => {
catch (err) { res.status(400).json({ error: err.message }); }
});
router.post('/board/topics/:id/leave', requireAuth, (req, res) => { try { board.leaveTopic(req.user.id); res.json({ ok: true }); } catch (err) { res.status(400).json({ error: err.message }); } });
--router.post('/board/topics/:id/side', optionalAuth, (req, res) => {
-- try { res.json(board.pickSide(Number(req.params.id), arena.voterKeyFor(req), String(req.body?.side || ''), req.user?.id || null)); }
-- catch (err) { res.status(400).json({ error: err.message }); }
--});
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 { res.json(await board.refreshPulse({ force: true })); } catch (err) { fail(res, err, 'Pulse refresh failed'); }
++ 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 ──
@@@ -153,11 +153,7 @@ 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 }); }
});
--router.post('/beefs/:id/side', optionalAuth, (req, res) => {
-- try { res.json(beef.pickSide(Number(req.params.id), arena.voterKeyFor(req), String(req.body?.side || ''), req.user?.id || null)); } catch (err) { res.status(400).json({ error: err.message }); }
--});
--
router.get('/levels', (req, res) => { try { res.json({ levels: board.levelsLeaderboard(20) }); } catch (err) { fail(res, err, 'Failed'); } });
--router.get('/clout', (req, res) => { try { res.json({ clout: board.cloutLeaderboard(20) }); } catch (err) { fail(res, err, 'Failed'); } });
++router.get('/yappers', (req, res) => { try { res.json({ yappers: board.yappersLeaderboard(20) }); } catch (err) { fail(res, err, 'Failed'); } });
module.exports = router;
ddiff --git a/test/arena-beef-board.test.js b/test/arena-beef-board.test.js
iindex 8337b060..0ba33160 100644
---- a/test/arena-beef-board.test.js
++++ b/test/arena-beef-board.test.js
@@@ -36,88 +36,101 @@ assert.deepStrictEqual(roster.order, [u1, u2, u3], 'roster by power');
board.ensureTables(); beef.ensureTables();
(async () => {
-- // ── Board: topics from chat, fallback angles, one active topic per streamer ──
-- const t1 = board.createTopic({ text: 'Streamers who read donations in a baby voice', createdBy: 'chat', creatorUserId: viewer, creatorName: 'Viewer' });
-- assert.strictEqual(t1.kind, 'topic');
-- assert.throws(() => board.createTopic({ text: 'streamers who read donations in a baby voice', createdBy: 'chat' }), /already on the board/);
-- assert.throws(() => board.createTopic({ text: 'ok', createdBy: 'chat' }), /short|characters|topic/i);
-- const angles = await board.ensureAngles(t1);
-- assert.strictEqual(angles.length, 3, 'fallback angles without AI');
-- await assert.rejects(board.joinTopic(t1.id, viewer), /roster/, 'viewers cannot talk on a topic');
-- const j = await board.joinTopic(t1.id, u1);
-- assert.strictEqual(j.topic.id, t1.id);
-- assert.strictEqual(board.activeTopicFor(u1).id, t1.id);
-- const t2 = board.createTopic({ text: 'Whose chat is the dumbest chat on the site', createdBy: 'streamer', creatorUserId: u2, creatorName: 'Grizzly' });
-- await board.joinTopic(t2.id, u1);
-- assert.strictEqual(board.activeTopicFor(u1).id, t2.id, 'joining another topic switches the active one');
-- await board.joinTopic(t1.id, u1);
++ // ── Board: subjects with keywords, moments from chat + mic, lore, Trash Level ──
++ board._resetScan();
++ const t1 = board.createTopic({ text: 'People who read donations in a baby voice', createdBy: 'community', creatorName: 'global chat', headline: 'BABY VOICE GATE', keywords: ['baby voice', 'dono voice'] });
++ assert.deepStrictEqual(JSON.parse(t1.keywords_json), ['baby voice', 'dono voice']);
++ assert.throws(() => board.createTopic({ text: 'people who read donations in a baby voice', createdBy: 'chat' }), /already on the board/);
++ 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'), []);
-- // Judged chunks: progress → angle cleared (+25) → all angles → conquered (+60).
-- const fresh = () => db.get('SELECT * FROM arena_topics WHERE id = ?', [t1.id]);
-- let r = board.applyTopicJudgement(u1, fresh(), { angle_idx: 0, quality: 6, progress_gain: 60, best_line: 'Your baby voice sounds like a fax machine', about: 'baby voice' }, { vod_id: null, sec: 10 });
-- assert.deepStrictEqual([r.applied, r.progress, r.cleared_angle], [true, 60, false]);
-- assert.strictEqual(board.applyTopicJudgement(u1, fresh(), { angle_idx: -1, quality: 0, progress_gain: 0 }).applied, false, 'off-topic chunk does nothing');
-- assert.strictEqual(board.applyTopicJudgement(u1, fresh(), { angle_idx: 0, quality: 9, progress_gain: 50, flagged: true }).applied, false, 'flagged chunk (threat/minor/dox) is ignored');
-- r = board.applyTopicJudgement(u1, fresh(), { angle_idx: 0, quality: 8, progress_gain: 50, best_line: 'Read my dono in your real voice, coward', about: 'dono voice' }, { vod_id: null, sec: 40 });
-- assert.ok(r.cleared_angle && r.progress === 100 && !r.conquered);
-- assert.ok(r.xp >= board.XP_ANGLE_CLEARED, `angle clear pays: ${r.xp}`);
-- for (const idx of [1, 2]) r = board.applyTopicJudgement(u1, fresh(), { angle_idx: idx, quality: 7, progress_gain: 60, best_line: `line ${idx}`, about: 'x' }, {});
-- for (const idx of [1, 2]) r = board.applyTopicJudgement(u1, fresh(), { angle_idx: idx, quality: 7, progress_gain: 60, best_line: `line ${idx}b`, about: 'x' }, {});
-- assert.strictEqual(r.conquered, true, 'all angles cleared → topic conquered');
-- assert.strictEqual(board.activeTopicFor(u1), null, 'conquering ends the active topic');
++ // 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');
++ const scan = board.scanChat();
++ assert.strictEqual(scan.moments, 3, `three moments from four new messages (commands skipped): ${JSON.stringify(scan)}`);
++ 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');
++ 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.ok(lv.xp >= board.XP_ANGLE_CLEARED * 3 + board.XP_TOPIC_CONQUERED, `xp accrued: ${lv.xp}`);
-- assert.strictEqual(lv.level, board.levelFor(lv.xp));
-- assert.ok(lv.level >= 3, `Trash Level climbed: ${lv.level}`);
-- assert.strictEqual(lv.angles_cleared, 3); assert.strictEqual(lv.topics_conquered, 1);
++ 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');
-- const detail = board.topicDetail(t1.id);
-- assert.strictEqual(detail.conquered, 1); assert.strictEqual(detail.members[0].cleared, 3);
-- assert.ok(detail.best_lines.length >= 1 && detail.progress[u1].every(p => p.cleared));
-- console.log('✅ board topics: angles, progress, clears, conquest, Trash Level');
++ 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.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');
-- // Hype + heat.
-- assert.throws(() => board.hypeTopic(t2.id, u2, `user:${u2}`), /yourself/);
-- await board.joinTopic(t2.id, u2);
-- assert.deepStrictEqual(board.hypeTopic(t2.id, u2, 'anon:abc'), { added: true, hypers: 1 });
-- assert.deepStrictEqual(board.hypeTopic(t2.id, u2, 'anon:abc'), { added: false, hypers: 1 }, 'one hype per person');
-- assert.ok(board.computeHeat(t2.id) >= 5, 'talking + hype makes heat');
++ // 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, 'hottest topic first (7 judged hits in the last hour beat 1 talker + 1 hype)');
++ 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.ok(view.open.find(t => t.id === t2.id).talking_now.some(f => f.user.id === u2));
-- console.log('✅ hype + heat ordering');
++ 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.topics === 1));
++ console.log('✅ lore, hype, heat ordering, yappers');
-- // Debate: sides from chat + talk, resolves at expiry, clout for right picks.
-- const d = board.createTopic({ text: 'Face cam or no face cam', createdBy: 'ai', kind: 'debate', sideA: 'Face cam', sideB: 'No cam', headline: 'THE CAM WAR' });
-- assert.strictEqual(JSON.parse(d.angles_json).length, 3, 'debate carries its sides as angles');
-- assert.deepStrictEqual(board.pickSide(d.id, 'anon:v1', 'a', null), { a: 1, b: 0, share_a: 100 });
-- board.pickSide(d.id, 'anon:v2', 'b'); board.pickSide(d.id, 'anon:v1', 'b');
-- assert.deepStrictEqual(board.sideTally(d.id), { a: 0, b: 2, share_a: 0 }, 'changing sides never double-counts');
-- await board.joinTopic(d.id, u3);
-- board.applyTopicJudgement(u3, db.get('SELECT * FROM arena_topics WHERE id = ?', [d.id]), { angle_idx: 0, quality: 9, progress_gain: 30, best_line: 'cams are for cowards', about: 'x' }, {});
-- db.run(`UPDATE arena_topics SET expires_at = datetime('now', '-1 minute') WHERE id = ?`, [d.id]);
-- const res = board.resolveExpired();
-- assert.strictEqual(res.length, 1);
-- assert.strictEqual(res[0].winner, 'a', 'talk (9×2=18) beats chat (2 for b)');
-- assert.strictEqual(res[0].mvp_user_id, u3);
-- assert.strictEqual(board.topicDetail(d.id).status, 'resolved');
-- // second debate so the voter has ≥ 2 picks and shows on the clout board
-- const d2 = board.createTopic({ text: 'Keyboard sounds on stream', createdBy: 'ai', kind: 'debate', sideA: 'ASMR', sideB: 'Mute it' });
-- board.pickSide(d2.id, 'anon:v1', 'a'); board.pickSide(d2.id, 'anon:v2', 'a');
-- db.run(`UPDATE arena_topics SET expires_at = datetime('now', '-1 minute') WHERE id = ?`, [d2.id]);
-- board.resolveExpired();
-- const clout = board.cloutLeaderboard();
-- assert.ok(clout.length >= 1 && clout[0].picks === 2, `clout board: ${JSON.stringify(clout)}`);
-- console.log('✅ debates: sides, resolution, clout');
++ // 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');
++ console.log('✅ discovery from chat bursts + backfill');
-- // Phrase challenge: no model needed.
-- const ph = board.createTopic({ text: 'Say "certified yapper" on stream', createdBy: 'ai', kind: 'phrase', phrase: 'certified yapper' });
-- assert.deepStrictEqual(board.checkPhrases(u2, 'we are just a certified yapper household', { vod_id: null, sec: 1 }).map(h => h.xp), [15]);
-- assert.deepStrictEqual(board.checkPhrases(u2, 'CERTIFIED YAPPER again', {}).map(h => h.xp), [5], 'repeats pay less');
-- assert.deepStrictEqual(board.checkPhrases(u2, 'nothing here', {}), []);
-- assert.strictEqual(board.topicDetail(ph.id).hits, 2);
-- console.log('✅ phrase challenges');
++ // 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!' });
@@@ -139,7 +152,7 @@ board.ensureTables(); beef.ensureTables();
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.deepStrictEqual(beef.pickSide(v.id, 'anon:x', 'b'), { a: 0, b: 1, share_a: 0 });
++ 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');
@@@ -183,16 +196,17 @@ board.ensureTables(); beef.ensureTables();
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.
-- const bty = board.createTopic({ text: 'Bounty: Pixelqueen', createdBy: 'chat', creatorUserId: viewer, kind: 'bounty', targetUserId: u3 });
-- assert.strictEqual(board.openBountyOn(u3).id, bty.id);
-- assert.throws(() => board.createTopic({ text: 'Bounty again', createdBy: 'chat', kind: 'bounty', targetUserId: u3 }), /already a bounty/);
++ // 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.strictEqual(board.topicDetail(bty.id).hits, 1);
++ 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 ──
@@@ -204,21 +218,24 @@ board.ensureTables(); beef.ensureTables();
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('the roast angle: worst at this is grizzly because reasons', [{ text: 'Brag: why you win' }, { text: 'Roast: who is worst at this and why' }, { text: 'Bit: unhinged take' }]);
-- assert.strictEqual(ht.angle_idx, 1);
++ 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!', 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');
@@@ -232,25 +249,26 @@ board.ensureTables(); beef.ensureTables();
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 topic on the Arena board'));
-- assert.ok(db.get(`SELECT id FROM arena_topics WHERE text LIKE 'Streamers who blame lag%' AND created_by = 'chat'`));
++ 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(vc, '!side pixelqueen');
-- assert.ok(sent.pop().includes("You're with"));
-- assert.deepStrictEqual(beef.sidesTally(opened[0].id), { a: 0, b: 1, share_a: 0 });
-- 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 !hype !side !beef !board');
++ console.log('✅ chat: !topic (24 h limit) !hype !beef !board');
// ── Public API smoke ──
const express = require('express');
@@@ -258,12 +276,12 @@ board.ensureTables(); beef.ensureTables();
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.clout.length >= 1);
++ 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);
const lv2 = await get('/live'); assert.strictEqual(lv2.body.live[0].open_beefs, 1);
-- const tp = await get(`/board/topics/${t1.id}`); assert.strictEqual(tp.body.conquered, 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'));
assert.strictEqual((await get('/beefs/999')).status, 404);
assert.strictEqual((await get('/console/nobody')).status, 404);
srv.close();