diff --git a/bun.lock b/bun.lock index ae913d8..4184eb9 100644 --- a/bun.lock +++ b/bun.lock @@ -11,11 +11,13 @@ "puppeteer": "^24.2.0", "react": "^19.2.4", "react-dom": "^19.2.4", + "tmi.js": "^1.8.5", }, "devDependencies": { "@types/bun": "latest", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", + "@types/tmi.js": "^1.8.6", }, "peerDependencies": { "typescript": "^5", @@ -53,6 +55,8 @@ "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + "@types/tmi.js": ["@types/tmi.js@1.8.6", "", {}, "sha512-LVzNK7AxTMyh9qHLanAQR1o0I9XzfbIcXk85cx85igmCJHHO1Sm71sdhQ8Mj1WmRGzynPIoCXx6mVaFynWbsQw=="], + "@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="], "@vercel/oidc": ["@vercel/oidc@3.1.0", "", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="], @@ -207,6 +211,8 @@ "netmask": ["netmask@2.0.2", "", {}, "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg=="], + "node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], @@ -285,6 +291,10 @@ "text-decoder": ["text-decoder@1.2.7", "", { "dependencies": { "b4a": "^1.6.4" } }, "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ=="], + "tmi.js": ["tmi.js@1.8.5", "", { "dependencies": { "node-fetch": "^2.6.1", "ws": "^8.2.0" } }, "sha512-A9qrydfe1e0VWM9MViVhhxVgvLpnk7pFShVUWePsSTtoi+A1X+Zjdoa7OJd7/YsgHXGj3GkNEvnWop/1WwZuew=="], + + "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "type-fest": ["type-fest@5.4.4", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-JnTrzGu+zPV3aXIUhnyWJj4z/wigMsdYajGLIYakqyOW1nPllzXEJee0QQbHj+CTIQtXGlAjuK0UY+2xTyjVAw=="], @@ -297,6 +307,10 @@ "webdriver-bidi-protocol": ["webdriver-bidi-protocol@0.4.1", "", {}, "sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw=="], + "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], + + "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], + "widest-line": ["widest-line@6.0.0", "", { "dependencies": { "string-width": "^8.1.0" } }, "sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA=="], "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], diff --git a/chat-store.ts b/chat-store.ts new file mode 100644 index 0000000..06ee4ad --- /dev/null +++ b/chat-store.ts @@ -0,0 +1,134 @@ +import { db } from "./db.ts"; +import type { ChatMessage } from "./chat.ts"; + +// ── Schema ────────────────────────────────────────────────────────────────── + +db.exec(` + CREATE TABLE IF NOT EXISTS chat_messages ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + display_name TEXT NOT NULL, + content TEXT NOT NULL, + timestamp INTEGER NOT NULL, + badges TEXT, + is_subscriber INTEGER DEFAULT 0, + is_mod INTEGER DEFAULT 0, + round_num INTEGER + ); + CREATE INDEX IF NOT EXISTS idx_chat_round ON chat_messages(round_num); + CREATE INDEX IF NOT EXISTS idx_chat_timestamp ON chat_messages(timestamp); +`); + +// ── Persistence ───────────────────────────────────────────────────────────── + +const insertStmt = db.prepare(` + INSERT OR IGNORE INTO chat_messages + (id, username, display_name, content, timestamp, badges, is_subscriber, is_mod, round_num) + VALUES ($id, $username, $displayName, $content, $timestamp, $badges, $isSubscriber, $isMod, $roundNum) +`); + +const insertBatch = db.transaction((messages: ChatMessage[]) => { + for (const msg of messages) { + insertStmt.run({ + $id: msg.id, + $username: msg.username, + $displayName: msg.displayName, + $content: msg.content, + $timestamp: msg.timestamp, + $badges: JSON.stringify(msg.badges), + $isSubscriber: msg.isSubscriber ? 1 : 0, + $isMod: msg.isMod ? 1 : 0, + $roundNum: msg.roundNum, + }); + } +}); + +const pendingMessages: ChatMessage[] = []; +let flushTimer: ReturnType | null = null; + +const FLUSH_INTERVAL_MS = 5_000; + +function flush() { + if (pendingMessages.length === 0) return; + const batch = pendingMessages.splice(0); + try { + insertBatch(batch); + } catch (err) { + pendingMessages.unshift(...batch); + const detail = err instanceof Error ? err.message : String(err); + console.error(`[chat-store] flush failed, ${batch.length} messages re-queued: ${detail}`); + } +} + +export function queueMessage(message: ChatMessage) { + pendingMessages.push(message); + if (pendingMessages.length >= 50) { + flush(); + } +} + +export function startPersistence() { + if (flushTimer) clearInterval(flushTimer); + flushTimer = setInterval(flush, FLUSH_INTERVAL_MS); +} + +export function stopPersistence() { + if (flushTimer) { + clearInterval(flushTimer); + flushTimer = null; + } + flush(); +} + +// ── Queries ───────────────────────────────────────────────────────────────── + +type ChatRow = { + id: string; + username: string; + display_name: string; + content: string; + timestamp: number; + badges: string; + is_subscriber: number; + is_mod: number; + round_num: number | null; +}; + +function rowToMessage(row: ChatRow): ChatMessage { + return { + id: row.id, + username: row.username, + displayName: row.display_name, + content: row.content, + timestamp: row.timestamp, + badges: JSON.parse(row.badges || "{}"), + isSubscriber: row.is_subscriber === 1, + isMod: row.is_mod === 1, + roundNum: row.round_num, + }; +} + +export function getRecentChat(limit = 50): ChatMessage[] { + const rows = db + .query( + "SELECT * FROM chat_messages ORDER BY timestamp DESC LIMIT $limit", + ) + .all({ $limit: limit }) as ChatRow[]; + return rows.reverse().map(rowToMessage); +} + +export function getChatForRound(roundNum: number): ChatMessage[] { + const rows = db + .query( + "SELECT * FROM chat_messages WHERE round_num = $roundNum ORDER BY timestamp ASC", + ) + .all({ $roundNum: roundNum }) as ChatRow[]; + return rows.map(rowToMessage); +} + +export function getChatCount(): number { + const result = db + .query("SELECT COUNT(*) as count FROM chat_messages") + .get() as { count: number }; + return result.count; +} diff --git a/chat.ts b/chat.ts new file mode 100644 index 0000000..96c0680 --- /dev/null +++ b/chat.ts @@ -0,0 +1,233 @@ +import tmi from "tmi.js"; +import { log } from "./game.ts"; + +// ── Types ─────────────────────────────────────────────────────────────────── + +export type ChatMessage = { + id: string; + username: string; + displayName: string; + content: string; + timestamp: number; + badges: Record; + isSubscriber: boolean; + isMod: boolean; + roundNum: number | null; +}; + +export type ChatStats = { + totalMessages: number; + uniqueChatters: number; + messagesPerMinute: number; + topChatters: { username: string; count: number }[]; +}; + +type ChatListener = (message: ChatMessage) => void; + +// ── Config ────────────────────────────────────────────────────────────────── + +const TWITCH_CHANNEL = process.env.TWITCH_CHANNEL ?? "quipslop"; +const MIN_MESSAGE_LENGTH = 2; +const BUFFER_WINDOW_MS = 60_000; +const STATS_TOP_N = 10; + +// ── Chat client ───────────────────────────────────────────────────────────── + +const listeners: ChatListener[] = []; +const recentMessages: ChatMessage[] = []; +const chatterCounts = new Map(); +let totalMessages = 0; +let currentRoundNum: number | null = null; +let client: tmi.Client | null = null; + +function isSpam(content: string): boolean { + const trimmed = content.trim(); + + // Allow single-char vote messages (A, B, 1, 2) through the filter + if (/^[AB12]$/i.test(trimmed)) return false; + + if (trimmed.length < MIN_MESSAGE_LENGTH) return true; + + // Pure emote spam or repeated single characters + if (/^(.)\1{4,}$/.test(trimmed)) return true; + + // Common Twitch spam patterns + if (/^(lul|kekw|omegalul|poggers|copium|pepega|monkas|sadge|widepeepo)\s*$/i.test(trimmed)) return true; + + // Bot commands + if (trimmed.startsWith("!")) return true; + + // URL spam + if (/https?:\/\/\S+/i.test(trimmed)) return true; + + // Excessive caps (>80% uppercase, 10+ chars) + if (trimmed.length >= 10) { + const upperCount = (trimmed.match(/[A-Z]/g) || []).length; + if (upperCount / trimmed.length > 0.8) return true; + } + + return false; +} + +export function onChatMessage(listener: ChatListener) { + listeners.push(listener); +} + +export function setCurrentRound(roundNum: number | null) { + currentRoundNum = roundNum; +} + +export function getRecentMessages(limit = 50): ChatMessage[] { + if (limit <= 0) return []; + return recentMessages.slice(-limit); +} + +export function getChatStats(): ChatStats { + const now = Date.now(); + const windowMessages = recentMessages.filter( + (m) => now - m.timestamp <= BUFFER_WINDOW_MS, + ); + + const sorted = [...chatterCounts.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, STATS_TOP_N) + .map(([username, count]) => ({ username, count })); + + return { + totalMessages, + uniqueChatters: chatterCounts.size, + messagesPerMinute: windowMessages.length, + topChatters: sorted, + }; +} + +export async function startChat(): Promise { + const username = process.env.TWITCH_BOT_USERNAME; + const password = process.env.TWITCH_OAUTH_TOKEN; + + const identity = + username && password ? { username, password } : undefined; + + client = new tmi.Client({ + options: { debug: false }, + connection: { reconnect: true, secure: true }, + identity, + channels: [TWITCH_CHANNEL], + }); + + client.on("message", (_channel, tags, message, _self) => { + if (isSpam(message)) return; + + const chatMessage: ChatMessage = { + id: tags.id ?? crypto.randomUUID(), + username: tags.username ?? "anonymous", + displayName: tags["display-name"] ?? tags.username ?? "anonymous", + content: message.trim(), + timestamp: Number(tags["tmi-sent-ts"]) || Date.now(), + badges: (tags.badges as Record) ?? {}, + isSubscriber: Boolean(tags.subscriber), + isMod: Boolean(tags.mod), + roundNum: currentRoundNum, + }; + + totalMessages++; + recentMessages.push(chatMessage); + chatterCounts.set( + chatMessage.username, + (chatterCounts.get(chatMessage.username) ?? 0) + 1, + ); + + // Trim buffer to last 500 messages + if (recentMessages.length > 500) { + recentMessages.splice(0, recentMessages.length - 500); + } + + for (const listener of listeners) { + try { + listener(chatMessage); + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + log("ERROR", "chat", "Listener error", { error: detail }); + } + } + }); + + client.on("connected", (addr, port) => { + log("INFO", "chat", `Connected to Twitch IRC`, { + address: addr, + port, + channel: TWITCH_CHANNEL, + authenticated: Boolean(identity), + }); + }); + + client.on("disconnected", (reason) => { + log("WARN", "chat", "Disconnected from Twitch IRC", { reason }); + }); + + try { + await client.connect(); + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + log("ERROR", "chat", "Failed to connect to Twitch IRC", { + error: detail, + channel: TWITCH_CHANNEL, + }); + } +} + +// ── Audience voting ───────────────────────────────────────────────────────── + +type AudienceVotes = { a: number; b: number; voters: Set }; + +let audienceVotes: AudienceVotes = { a: 0, b: 0, voters: new Set() }; +let votingOpen = false; + +export function openAudienceVoting() { + audienceVotes = { a: 0, b: 0, voters: new Set() }; + votingOpen = true; +} + +export function closeAudienceVoting(): { a: number; b: number } { + votingOpen = false; + return { a: audienceVotes.a, b: audienceVotes.b }; +} + +export function getAudienceVotes(): { a: number; b: number; total: number } { + return { + a: audienceVotes.a, + b: audienceVotes.b, + total: audienceVotes.a + audienceVotes.b, + }; +} + +// Accepts: "A", "a", "1", "A!", "a lol" — first non-punctuation char wins +// Note: "vote A" won't match because 'v' is alphanumeric +function processVote(username: string, content: string) { + if (!votingOpen) return; + if (audienceVotes.voters.has(username)) return; // one vote per person + + const match = content.trim().match(/^[^a-z0-9]*([ab12])/i); + if (!match) return; + + const vote = match[1]!.toUpperCase(); + if (vote === "A" || vote === "1") { + audienceVotes.a++; + audienceVotes.voters.add(username); + } else if (vote === "B" || vote === "2") { + audienceVotes.b++; + audienceVotes.voters.add(username); + } +} + +// Hook vote detection into message pipeline +onChatMessage((msg) => processVote(msg.username, msg.content)); + +// ── Lifecycle ─────────────────────────────────────────────────────────────── + +export async function stopChat() { + if (client) { + await client.disconnect().catch(() => {}); + client = null; + } +} diff --git a/game.ts b/game.ts index 87d10b7..cd7233f 100644 --- a/game.ts +++ b/game.ts @@ -184,16 +184,56 @@ export function cleanResponse(text: string): string { // ── AI functions ──────────────────────────────────────────────────────────── import { ALL_PROMPTS } from "./prompts"; +import { + getRecentMessages, + getChatStats, + openAudienceVoting, + closeAudienceVoting, +} from "./chat.ts"; + +// Humor reaction patterns — when chat uses these, the previous answer landed well +const HYPE_PATTERNS = /\b(lmao|lol|dead|omg|bruh|no way|im crying|help|based|goated)\b|[💀😂🤣]/iu; +const CRINGE_PATTERNS = /\b(mid|boring|cringe|meh|yawn|weak|trash)\b|[😴👎]/iu; + +function analyzeAudienceReactions(messages: ReturnType): string { + let hype = 0; + let cringe = 0; + for (const msg of messages) { + if (HYPE_PATTERNS.test(msg.content)) hype++; + if (CRINGE_PATTERNS.test(msg.content)) cringe++; + } + if (hype === 0 && cringe === 0) return ""; + if (hype > cringe * 2) return " The audience is LOVING IT — keep the energy high, go bigger."; + if (cringe > hype) return " The audience wants better — surprise them with something unexpected."; + return " The audience is engaged — match their energy."; +} + +function buildAudienceContext(): string { + const stats = getChatStats(); + if (stats.totalMessages === 0) return ""; + + const recent = getRecentMessages(12); + if (recent.length === 0) return ""; + + const chatSnippets = recent + .map((m) => `${m.displayName}: ${m.content}`) + .join("\n"); + + const reactionNote = analyzeAudienceReactions(recent); + + return `\n\nThe live Twitch audience (${stats.uniqueChatters} chatters, ${stats.messagesPerMinute} msgs/min) is watching. Here's what they're saying:\n${chatSnippets}\n\nPlay off the audience energy! If they're hyped about something, lean into it. If they're roasting a model, get spicier.${reactionNote}`; +} function buildPromptSystem(): string { const examples = shuffle([...ALL_PROMPTS]).slice(0, 80); + const audienceCtx = buildAudienceContext(); return `You are a comedy writer for the game Quiplash. Generate a single funny fill-in-the-blank prompt that players will try to answer. The prompt should be surprising and designed to elicit hilarious responses. Return ONLY the prompt text, nothing else. Keep it short (under 15 words). Use a wide VARIETY of prompt formats. Do NOT always use "The worst thing to..." — mix it up! Here are examples of the range of styles: ${examples.map((p) => `- ${p}`).join("\n")} -Come up with something ORIGINAL — don't copy these examples.`; +Come up with something ORIGINAL — don't copy these examples.${audienceCtx}`; } export async function callGeneratePrompt(model: Model): Promise { @@ -217,13 +257,15 @@ export async function callGenerateAnswer( model: Model, prompt: string, ): Promise { + const audienceCtx = buildAudienceContext(); log("INFO", `answer:${model.name}`, "Calling API", { modelId: model.id, prompt, + hasAudience: audienceCtx.length > 0, }); const { text, usage, reasoning } = await generateText({ model: openrouter.chat(model.id), - system: `You are playing Quiplash! You'll be given a fill-in-the-blank prompt. Give the FUNNIEST possible answer. Be creative, edgy, unexpected, and concise. Reply with ONLY your answer — no quotes, no explanation, no preamble. Keep it short (under 12 words). Keep it concise and witty.`, + system: `You are playing Quiplash! You'll be given a fill-in-the-blank prompt. Give the FUNNIEST possible answer. Be creative, edgy, unexpected, and concise. Reply with ONLY your answer — no quotes, no explanation, no preamble. Keep it short (under 12 words). Keep it concise and witty.${audienceCtx}`, prompt: `Fill in the blank: ${prompt}`, }); @@ -248,7 +290,7 @@ export async function callVote( }); const { text, usage, reasoning } = await generateText({ model: openrouter.chat(voter.id), - system: `You are a judge in a comedy game. You'll see a fill-in-the-blank prompt and two answers. Pick which answer is FUNNIER. You MUST respond with exactly "A" or "B" — nothing else.`, + system: `You are a judge in a comedy game. You'll see a fill-in-the-blank prompt and two answers. Pick which answer is FUNNIER. You MUST respond with exactly "A" or "B" — nothing else. Judge based on humor, creativity, and unexpectedness.`, prompt: `Prompt: "${prompt}"\n\nAnswer A: "${a.answer}"\nAnswer B: "${b.answer}"\n\nWhich is funnier? Reply with just A or B.`, }); @@ -393,6 +435,9 @@ export async function runGame( const answerB = round.answerTasks[1].result!; const voteStart = Date.now(); round.votes = voters.map((v) => ({ voter: v, startedAt: voteStart })); + + // Open audience voting — viewers type A or B in Twitch chat + openAudienceVoting(); rerender(); await Promise.all( @@ -442,18 +487,35 @@ export async function runGame( } // ── Score ── + const audienceResult = closeAudienceVoting(); let votesA = 0; let votesB = 0; for (const v of round.votes) { if (v.votedFor === contA) votesA++; else if (v.votedFor === contB) votesB++; } - round.scoreA = votesA * 100; - round.scoreB = votesB * 100; + + // Audience votes count as bonus points (10 per audience vote) + // This makes chat participation meaningful without overriding AI judges + const audienceBonusA = audienceResult.a * 10; + const audienceBonusB = audienceResult.b * 10; + round.scoreA = votesA * 100 + audienceBonusA; + round.scoreB = votesB * 100 + audienceBonusB; + + if (audienceResult.a + audienceResult.b > 0) { + log("INFO", "round", `Audience votes: A=${audienceResult.a} B=${audienceResult.b}`, { + roundNum: r, + audienceA: audienceResult.a, + audienceB: audienceResult.b, + bonusA: audienceBonusA, + bonusB: audienceBonusB, + }); + } round.phase = "done"; - if (votesA > votesB) { + // Winner determined by total score (AI votes + audience bonus) + if (round.scoreA > round.scoreB) { state.scores[contA.name] = (state.scores[contA.name] || 0) + 1; - } else if (votesB > votesA) { + } else if (round.scoreB > round.scoreA) { state.scores[contB.name] = (state.scores[contB.name] || 0) + 1; } rerender(); diff --git a/package.json b/package.json index 50f949b..3993e7e 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "devDependencies": { "@types/bun": "latest", "@types/react": "^19.2.14", - "@types/react-dom": "^19.2.3" + "@types/react-dom": "^19.2.3", + "@types/tmi.js": "^1.8.6" }, "peerDependencies": { "typescript": "^5" @@ -24,6 +25,7 @@ "ink": "^6.8.0", "puppeteer": "^24.2.0", "react": "^19.2.4", - "react-dom": "^19.2.4" + "react-dom": "^19.2.4", + "tmi.js": "^1.8.5" } } diff --git a/server.ts b/server.ts index 6a4f9fe..5fd4077 100644 --- a/server.ts +++ b/server.ts @@ -13,6 +13,22 @@ import { type GameState, type RoundState, } from "./game.ts"; +import { + startChat, + stopChat, + onChatMessage, + setCurrentRound, + getRecentMessages, + getChatStats, + getAudienceVotes, +} from "./chat.ts"; +import { + queueMessage, + startPersistence, + stopPersistence, + getRecentChat, + getChatForRound, +} from "./chat-store.ts"; const VERSION = crypto.randomUUID().slice(0, 8); @@ -227,11 +243,12 @@ function getClientState() { done: gameState.done, isPaused: gameState.isPaused, generation: gameState.generation, + audienceVotes: getAudienceVotes(), }; } function broadcast() { - const msg = JSON.stringify({ + const stateMsg = JSON.stringify({ type: "state", data: getClientState(), totalRounds: runs, @@ -239,8 +256,11 @@ function broadcast() { version: VERSION, }); for (const ws of clients) { - ws.send(msg); + ws.send(stateMsg); } + + // Keep chat in sync with active round + setCurrentRound(gameState.active?.num ?? null); } let viewerCountTimer: ReturnType | null = null; @@ -533,6 +553,67 @@ const server = Bun.serve({ }); } + // ── Chat endpoints ────────────────────────────────────────────────────── + + if (url.pathname === "/api/chat/recent") { + if (isRateLimited(`history:${ip}`, HISTORY_LIMIT_PER_MIN, WINDOW_MS)) { + return new Response("Too Many Requests", { status: 429 }); + } + const rawLimit = parseInt(url.searchParams.get("limit") || "50", 10); + const limit = Number.isFinite(rawLimit) + ? Math.min(Math.max(rawLimit, 1), 200) + : 50; + const messages = getRecentChat(limit); + return new Response(JSON.stringify({ messages }), { + headers: { + "Content-Type": "application/json", + "Cache-Control": "no-store", + }, + }); + } + + if (url.pathname.startsWith("/api/chat/round/")) { + if (isRateLimited(`history:${ip}`, HISTORY_LIMIT_PER_MIN, WINDOW_MS)) { + return new Response("Too Many Requests", { status: 429 }); + } + const parts = url.pathname.split("/"); + const roundNum = parseInt(parts[parts.length - 1] ?? "", 10); + if (!Number.isFinite(roundNum) || roundNum < 1) { + return new Response("Invalid round number", { status: 400 }); + } + const messages = getChatForRound(roundNum); + return new Response(JSON.stringify({ roundNum, messages }), { + headers: { + "Content-Type": "application/json", + "Cache-Control": "public, max-age=30", + }, + }); + } + + if (url.pathname === "/api/chat/votes") { + if (isRateLimited(`history:${ip}`, HISTORY_LIMIT_PER_MIN, WINDOW_MS)) { + return new Response("Too Many Requests", { status: 429 }); + } + return new Response(JSON.stringify(getAudienceVotes()), { + headers: { + "Content-Type": "application/json", + "Cache-Control": "no-store", + }, + }); + } + + if (url.pathname === "/api/chat/stats") { + if (isRateLimited(`history:${ip}`, HISTORY_LIMIT_PER_MIN, WINDOW_MS)) { + return new Response("Too Many Requests", { status: 429 }); + } + return new Response(JSON.stringify(getChatStats()), { + headers: { + "Content-Type": "application/json", + "Cache-Control": "no-store", + }, + }); + } + if (url.pathname === "/ws") { if (req.method !== "GET") { return new Response("Method Not Allowed", { @@ -632,6 +713,49 @@ log("INFO", "server", `Web server started on port ${server.port}`, { models: MODELS.map((m) => m.id), }); +// ── Twitch chat ───────────────────────────────────────────────────────────── + +let chatBroadcastTimer: ReturnType | null = null; + +onChatMessage((msg) => { + queueMessage(msg); +}); + +startPersistence(); + +startChat().then(() => { + // Broadcast chat stats to spectators every 3 seconds + chatBroadcastTimer = setInterval(() => { + if (clients.size === 0) return; + const stats = getChatStats(); + if (stats.totalMessages === 0) return; + const recent = getRecentMessages(10); + const msg = JSON.stringify({ + type: "chat", + stats, + recent: recent.map((m) => ({ + username: m.displayName, + content: m.content, + badges: m.badges, + isMod: m.isMod, + isSubscriber: m.isSubscriber, + })), + }); + for (const ws of clients) { + ws.send(msg); + } + }, 3_000); +}); + +function shutdown() { + if (chatBroadcastTimer) clearInterval(chatBroadcastTimer); + stopChat(); + stopPersistence(); + process.exit(0); +} +process.on("SIGTERM", shutdown); +process.on("SIGINT", shutdown); + // ── Start game ────────────────────────────────────────────────────────────── runGame(runs, gameState, broadcast).then(() => {