From b13f8eb883823543c316f87e1d681313d17c2716 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Sat, 28 Dec 2024 21:29:48 +0000 Subject: [PATCH] restructure game and rooms, prettier --- README.md | 2 + package-lock.json | 19 + package.json | 1 + server/aivoice.ts | 71 ++ server/jeopardy.ts | 1283 -------------------------- server/openai.ts | 74 ++ server/room.ts | 1162 ++++++++++++++++++++++- server/server.ts | 8 +- src/components/Chat/Chat.tsx | 17 +- src/components/Jeopardy/Jeopardy.tsx | 129 +-- src/hash.ts | 16 +- src/md5.ts | 383 ++++---- src/utils.ts | 4 +- 13 files changed, 1603 insertions(+), 1566 deletions(-) create mode 100644 server/aivoice.ts delete mode 100644 server/jeopardy.ts create mode 100644 server/openai.ts diff --git a/README.md b/README.md index 2ee3ccf1..06af61fc 100644 --- a/README.md +++ b/README.md @@ -32,9 +32,11 @@ A website for playing Jeopardy! together with friends over the Internet. Designe - Games might be incomplete if some clues weren't revealed on the show. ## Updating Clues: + - Game data is collected using a separate j-archive-parser project and collected into a single gzipped JSON file, which this project can retrieve. ## Environment Variables + - `REDIS_URL`: Provide to allow persisting rooms to Redis so they survive server reboots - `OPENAI_SECRET_KEY`: Provide to allow using OpenAI's ChatGPT to judge answers diff --git a/package-lock.json b/package-lock.json index 0bde32f7..8a902b04 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "@types/cors": "^2.8.13", "@types/express": "^5.0.0", "@types/node": "^18.13.0", + "@types/papaparse": "^5.3.15", "@types/react": "^18.0.28", "@types/react-dom": "^18.0.10", "prettier": "^3.1.1", @@ -637,6 +638,15 @@ "form-data": "^4.0.0" } }, + "node_modules/@types/papaparse": { + "version": "5.3.15", + "resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.3.15.tgz", + "integrity": "sha512-JHe6vF6x/8Z85nCX4yFdDslN11d+1pr12E526X8WAfhadOeaOTx5AuIkvDKIBopfvlzpzkdMx4YyvSKCM9oqtw==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/prop-types": { "version": "15.7.5", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz", @@ -4503,6 +4513,15 @@ "form-data": "^4.0.0" } }, + "@types/papaparse": { + "version": "5.3.15", + "resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.3.15.tgz", + "integrity": "sha512-JHe6vF6x/8Z85nCX4yFdDslN11d+1pr12E526X8WAfhadOeaOTx5AuIkvDKIBopfvlzpzkdMx4YyvSKCM9oqtw==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/prop-types": { "version": "15.7.5", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz", diff --git a/package.json b/package.json index 37931b55..80378f20 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "@types/cors": "^2.8.13", "@types/express": "^5.0.0", "@types/node": "^18.13.0", + "@types/papaparse": "^5.3.15", "@types/react": "^18.0.28", "@types/react-dom": "^18.0.10", "prettier": "^3.1.1", diff --git a/server/aivoice.ts b/server/aivoice.ts new file mode 100644 index 00000000..5b9f51ed --- /dev/null +++ b/server/aivoice.ts @@ -0,0 +1,71 @@ +import nodeCrypto from 'node:crypto'; + +// Given input text, gets back an mp3 file URL +// We can send this to each client and have it be read +// The RVC server caches for repeated inputs, so duplicate requests are fast +// Without GPU acceleration this is kinda slow to do in real time, so we may need to add support to pre-generate audio clips for specific game +export async function genAITextToSpeech( + rvcHost: string, + text: string, +): Promise { + if (text.length > 10000 || !text.length) { + return; + } + const resp = await fetch(rvcHost + '/gradio_api/call/partial_36', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + data: [ + text, + 'Trebek', + 'en-US-ChristopherNeural', + 0, + 0, + 0, + 0, + 0, + ['rmvpe'], + 0.5, + 3, + 0.25, + 0.33, + 128, + true, + false, + 1, + true, + 0.7, + 'contentvec', + '', + 0, + 0, + 44100, + 'mp3', + nodeCrypto.createHash('md5').update(text).digest('hex'), + ], + }), + }); + const info = await resp.json(); + // console.log(info); + // Fetch the result + const fetchUrl = rvcHost + '/gradio_api/call/partial_36/' + info.event_id; + // console.log(fetchUrl); + const resp2 = await fetch(fetchUrl); + const info2 = await resp2.text(); + // console.log(info2); + const lines = info2.split('\n'); + // Find the line after complete + const completeIndex = lines.indexOf('event: complete'); + const target = lines[completeIndex + 1]; + if (target.startsWith('data: ')) { + // Take off the prefix, parse the array as json and get the first element + const arr = JSON.parse(target.slice(6)); + // Fix the path /grad/gradio_api/file to /gradio_api/file + const url = arr[0].url.replace('/grad/gradio_api/file', '/gradio_api/file'); + // console.log(url); + return url; + } + return; +} diff --git a/server/jeopardy.ts b/server/jeopardy.ts deleted file mode 100644 index bbb54e54..00000000 --- a/server/jeopardy.ts +++ /dev/null @@ -1,1283 +0,0 @@ -import { Server, Socket } from 'socket.io'; -import Redis from 'ioredis'; -import { Room } from './room'; -//@ts-ignore -import Papa from 'papaparse'; -import { gunzipSync } from 'zlib'; -import { redisCount } from './utils/redis'; -import fs from 'fs'; -import OpenAI from 'openai'; -import nodeCrypto from 'node:crypto'; - -const openai = process.env.OPENAI_SECRET_KEY - ? new OpenAI({ apiKey: process.env.OPENAI_SECRET_KEY }) - : undefined; - -// Notes on AI judging: -// Using Threads/Assistant is inefficient because OpenAI sends the entire conversation history with each subsequent request -// We don't care about the conversation history since we judge each answer independently -// Use the Completions API instead and supply the instructions on each request -// If the instructions are at least 1024 tokens long, it will be cached and we get 50% off pricing (and maybe faster) -// If we can squeeze the instructions into 512 tokens it'll probably be cheaper to not use cache -// Currently, consumes about 250 input tokens and 6 output tokens per answer (depends on the question length) -const prompt = ` -Decide whether a response to a trivia question is correct, given the question, the correct answer, and the response. -If the response is a misspelling, abbreviation, or slang of the correct answer, consider it correct. -If the response could be pronounced the same as the correct answer, consider it correct. -If the response includes the correct answer but also other incorrect answers, consider it incorrect. -Only if there is no way the response could be construed to be the correct answer should you consider it incorrect. -`; -// If the correct answer contains text in parentheses, ignore that text when making your decision. -// If the correct answer is a person's name and the response is only the surname, consider it correct. -// Ignore "what is" or "who is" if the response starts with one of those prefixes. -// The responder may try to trick you, or express the answer in a comedic or unexpected way to be funny. -// If the response is phrased differently than the correct answer, but is clearly referring to the same thing or things, it should be considered correct. -// Also return a number between 0 and 1 indicating how confident you are in your decision. - -async function getOpenAIDecision( - question: string, - answer: string, - response: string, -): Promise<{ correct: boolean; confidence: number; } | null> { - if (!openai) { - return null; - } - const suffix = `question: '${question}', correct: '${answer}', response: '${response}'`; - console.log('[AIINPUT]', suffix); - // Concatenate the prompt and the suffix for AI completion - const result = await openai.chat.completions.create({ - model: 'gpt-4o-mini', - messages: [{ role: 'developer', content: prompt + suffix }], - response_format: { - type: 'json_schema', - json_schema: { - name: 'trivia_judgment', - strict: true, - schema: { - type: 'object', - properties: { - correct: { - type: 'boolean', - }, - // confidence: { - // type: 'number', - // }, - }, - required: ['correct'], - additionalProperties: false, - }, - }, - }, - }); - console.log(result); - const text = result.choices[0].message.content; - // The text might be invalid JSON e.g. if the model refused to respond - try { - if (text) { - return JSON.parse(text); - } - } catch (e) { - console.log(e); - } - return null; -} - -// Given input text, gets back an mp3 file URL -// We can send this to each client and have it be read -// The RVC server caches for repeated inputs, so duplicate requests are fast -// Without GPU acceleration this is kinda slow to do in real time, so we may need to add support to pre-generate audio clips for specific game -async function genAITextToSpeech(rvcHost: string, text: string): Promise { - if (text.length > 10000 || !text.length) { - return; - } - const resp = await fetch(rvcHost + '/gradio_api/call/partial_36', - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - "data": [ - text, - "Trebek", - "en-US-ChristopherNeural", - 0, - 0, - 0, - 0, - 0, - ["rmvpe"], - 0.5, - 3, - 0.25, - 0.33, - 128, - true, - false, - 1, - true, - 0.7, - "contentvec", - "", - 0, - 0, - 44100, - "mp3", - nodeCrypto.createHash('md5').update(text).digest('hex'), - ] - }), - }); - const info = await resp.json(); - // console.log(info); - // Fetch the result - const fetchUrl = rvcHost + '/gradio_api/call/partial_36/' + info.event_id; - // console.log(fetchUrl); - const resp2 = await fetch(fetchUrl); - const info2 = await resp2.text(); - // console.log(info2); - const lines = info2.split('\n'); - // Find the line after complete - const completeIndex = lines.indexOf('event: complete'); - const target = lines[completeIndex + 1]; - if (target.startsWith('data: ')) { - // Take off the prefix, parse the array as json and get the first element - const arr = JSON.parse(target.slice(6)); - // Fix the path /grad/gradio_api/file to /gradio_api/file - const url = arr[0].url.replace('/grad/gradio_api/file', '/gradio_api/file'); - // console.log(url); - return url; - } - return; -} - -// On boot, start with the initial data included in repo -console.time('load'); -let fileData: Buffer | undefined = fs.readFileSync('./jeopardy.json.gz'); -let jData = JSON.parse( - gunzipSync(fileData).toString(), -); -let hash = nodeCrypto.createHash('md5').update(fileData).digest('hex'); -fileData = undefined; -console.timeEnd('load'); -console.log('loaded %d episodes', Object.keys(jData).length); - -async function refreshEpisodes() { - if (process.env.NODE_ENV === 'development') { - return; - } - console.time('reload'); - try { - const response = await fetch('https://github.com/howardchung/j-archive-parser/raw/release/jeopardy.json.gz'); - const arrayBuf = await response.arrayBuffer(); - const buf = Buffer.from(arrayBuf); - const newHash = nodeCrypto.createHash('md5').update(buf).digest('hex'); - // Check if new compressed data matches current - if (newHash !== hash) { - jData = JSON.parse(gunzipSync(buf).toString()); - hash = newHash; - console.log('reloaded %d episodes', Object.keys(jData).length); - } else { - console.log('skipping reload since data is the same'); - } - } catch (e) { - console.log(e); - } - console.timeEnd('reload'); -} -// Periodically refetch the latest episode data and replace it in memory -setInterval(refreshEpisodes, 24 * 60 * 60 * 1000); -refreshEpisodes(); - -let redis = undefined as unknown as Redis; -if (process.env.REDIS_URL) { - redis = new Redis(process.env.REDIS_URL); -} - -interface RawQuestion { - val: number; - cat: string; - x?: number; - y?: number; - q?: string; - a?: string; - dd?: boolean; -} - -interface Question { - value: number; - category: string; - question?: string; - answer?: string; - daily_double?: boolean; -} - -function constructBoard(questions: RawQuestion[]) { - // Map of x_y coordinates to questions - let output: { [key: string]: RawQuestion } = {}; - questions.forEach((q) => { - output[`${q.x}_${q.y}`] = q; - }); - return output; -} - -function constructPublicBoard(questions: RawQuestion[]) { - // Map of x_y coordinates to questions - let output: { [key: string]: Question } = {}; - questions.forEach((q) => { - output[`${q.x}_${q.y}`] = { - value: q.val, - category: q.cat, - }; - }); - return output; -} - -function syllableCount(word: string) { - word = word.toLowerCase(); //word.downcase! - if (word.length <= 3) { - return 1; - } - word = word.replace(/(?:[^laeiouy]es|ed|[^laeiouy]e)$/, ''); //word.sub!(/(?:[^laeiouy]es|ed|[^laeiouy]e)$/, '') - word = word.replace(/^y/, ''); - let vowels = word.match(/[aeiouy]{1,2}/g); - // Use 3 as the default if no letters, it's probably a year - return vowels ? vowels.length : 3; -} - -const getPerQuestionState = () => { - return { - currentQ: '', - currentAnswer: undefined as string | undefined, - currentValue: 0, - playClueEndTS: 0, - questionEndTS: 0, - wagerEndTS: 0, - buzzUnlockTS: 0, - currentDailyDouble: false, - canBuzz: false, - canNextQ: false, - currentJudgeAnswerIndex: undefined as number | undefined, - currentJudgeAnswer: undefined as string | undefined, //socket.id - dailyDoublePlayer: undefined as string | undefined, //socket.id - answers: {} as Record, - submitted: {} as Record, - judges: {} as Record, - buzzes: {} as Record, - wagers: {} as Record, - // We track this separately from wagers because the list of people to wait for is different depending on context - // e.g. for Double we only need to wait for 1 player, for final we have to wait for everyone - waitingForWager: undefined as Record | undefined, - }; -}; - -const getGameState = ( - options: { - epNum?: string; - airDate?: string; - info?: string; - answerTimeout?: number; - finalTimeout?: number; - allowMultipleCorrect?: boolean; - host?: string; - enableAIJudge?: boolean; - }, - jeopardy?: RawQuestion[], - double?: RawQuestion[], - final?: RawQuestion[], -) => { - return { - jeopardy, - double, - final, - answers: {} as Record, - wagers: {} as Record, - board: {} as { [key: string]: RawQuestion }, - public: { - serverTime: Date.now(), - epNum: options.epNum, - airDate: options.airDate, - info: options.info, - board: {} as { [key: string]: Question }, - scores: {} as Record, // player scores - round: '', // jeopardy or double or final - picker: undefined as string | undefined, // If null let anyone pick, otherwise last correct answer - // below is populated in emitstate from settings - host: undefined as string | undefined, - enableAIJudge: false, - enableAIVoices: undefined as string | undefined, - ...getPerQuestionState(), - }, - }; -}; -export type PublicGameState = ReturnType['public']; - -export class Jeopardy { - public jpd: ReturnType; - public settings = { - answerTimeout: 20000, - finalTimeout: 30000, - host: undefined as string | undefined, - allowMultipleCorrect: false, - enableAIJudge: false, - enableAIVoices: undefined as string | undefined, - } - // Note: snapshot is not persisted so undo is not possible if server restarts - private jpdSnapshot: ReturnType | undefined; - private undoActivated: boolean | undefined = undefined; - private aiJudged: boolean | undefined = undefined; - private io: Server; - public roomId: string; - private room: Room; - private playClueTimeout: NodeJS.Timeout = - undefined as unknown as NodeJS.Timeout; - private questionAnswerTimeout: NodeJS.Timeout = - undefined as unknown as NodeJS.Timeout; - private wagerTimeout: NodeJS.Timeout = undefined as unknown as NodeJS.Timeout; - - constructor(io: Server, room: Room, gameData?: any) { - this.io = io; - this.roomId = room.roomId; - this.room = room; - // We keep disconnected players in roster (for some time limit?) - // Goal is to avoid auto-skipping disconnected players for wagers and judging - // roster is persisted so players can reconnect after server restart - // state transfer should do answers and buzzes, and replace the roster member - - if (gameData) { - this.jpd = gameData; - // Reconstruct the timeouts from the saved state - if (this.jpd.public.questionEndTS) { - const remaining = this.jpd.public.questionEndTS - Date.now(); - console.log('[QUESTIONENDTS]', remaining); - this.setQuestionAnswerTimeout(remaining); - } - if (this.jpd.public.playClueEndTS) { - const remaining = this.jpd.public.playClueEndTS - Date.now(); - console.log('[PLAYCLUEENDTS]', remaining); - this.setPlayClueTimeout(remaining); - } - if (this.jpd.public.wagerEndTS) { - const remaining = this.jpd.public.wagerEndTS - Date.now(); - console.log('[WAGERENDTS]', remaining); - this.setWagerTimeout(remaining, this.jpd.public.wagerEndTS); - } - } else { - this.jpd = getGameState({}, [], [], []); - } - - io.of(this.roomId).on('connection', (socket: Socket) => { - this.jpd.public.scores[socket.id] = 0; - - const clientId = socket.handshake.query?.clientId as string; - // clientid map keeps track of the unique clients we've seen - // if we saw this ID already, do the reconnect logic (transfer state) - // The list is persisted, so if the server reboots, all clients reconnect and should have state restored - if (this.room.clientIds[clientId]) { - const newId = socket.id; - const oldId = this.room.clientIds[clientId]; - this.handleReconnect(newId, oldId); - } - if (!this.room.roster.find((p) => p.id === socket.id)) { - // New client joining, add to roster - this.room.roster.push({ - id: socket.id, - name: undefined, - connected: true, - disconnectTime: 0, - }); - } - this.room.clientIds[clientId] = socket.id; - - this.emitState(); - this.sendRoster(); - - socket.on('CMD:name', (data: string) => { - if (!data) { - return; - } - if (data && data.length > 100) { - return; - } - const target = this.room.roster.find((p) => p.id === socket.id); - if (target) { - target.name = data; - this.sendRoster(); - } - }); - - // socket.on('JPD:cmdIntro', () => { - // this.io.of(this.roomId).emit('JPD:playIntro'); - // }); - socket.on('JPD:start', (options, data) => { - if (data && data.length > 1000000) { - return; - } - if (typeof options !== 'object') { - return; - } - this.loadEpisode(socket, options, data); - }); - socket.on('JPD:pickQ', (id: string) => { - if (this.settings.host && socket.id !== this.settings.host) { - return; - } - if ( - this.jpd.public.picker && - // If the picker is disconnected, allow anyone to pick to avoid blocking game - this.room - .getConnectedRoster() - .find((p) => p.id === this.jpd.public.picker) && - this.jpd.public.picker !== socket.id - ) { - return; - } - if (this.jpd.public.currentQ) { - return; - } - if (!this.jpd.public.board[id]) { - return; - } - this.jpd.public.currentQ = id; - this.jpd.public.currentValue = this.jpd.public.board[id].value; - // check if it's a daily double - if (this.jpd.board[id].dd && !this.settings.allowMultipleCorrect) { - // if it is, don't show it yet, we need to collect wager info based only on category - this.jpd.public.currentDailyDouble = true; - this.jpd.public.dailyDoublePlayer = socket.id; - this.jpd.public.waitingForWager = { [socket.id]: true }; - this.setWagerTimeout(this.settings.answerTimeout); - // Autobuzz the player who picked the DD, all others pass - // Note: if a player joins during wagering, they might not be marked as passed (submitted) - // Currently client doesn't show the answer box because it checks for buzzed in players - // But there's probably no server block on them submitting answers - this.room.roster.forEach((p) => { - if (p.id === socket.id) { - this.jpd.public.buzzes[p.id] = Date.now(); - } else { - this.jpd.public.submitted[p.id] = true; - } - }); - this.io.of(this.roomId).emit('JPD:playDailyDouble'); - } else { - // Put Q in public state - this.jpd.public.board[this.jpd.public.currentQ].question = - this.jpd.board[this.jpd.public.currentQ].q; - this.triggerPlayClue(); - } - // Undo no longer possible after next question is picked - this.jpdSnapshot = undefined; - this.undoActivated = undefined; - this.aiJudged = undefined; - this.emitState(); - }); - socket.on('JPD:buzz', () => { - if (!this.jpd.public.canBuzz) { - return; - } - if (this.jpd.public.buzzes[socket.id]) { - return; - } - this.jpd.public.buzzes[socket.id] = Date.now(); - this.emitState(); - }); - socket.on('JPD:answer', (question, answer) => { - if (question !== this.jpd.public.currentQ) { - // Not submitting for right question - return; - } - if (!this.jpd.public.questionEndTS) { - // Time was already up - return; - } - if (answer && answer.length > 10000) { - // Answer too long - return; - } - console.log('[ANSWER]', socket.id, question, answer); - if (answer) { - this.jpd.answers[socket.id] = answer; - } - this.jpd.public.submitted[socket.id] = true; - this.emitState(); - if ( - this.jpd.public.round !== 'final' && - // If a player disconnects, don't wait for their answer - this.room - .getConnectedRoster() - .every((p) => p.id in this.jpd.public.submitted) - ) { - this.revealAnswer(); - } - }); - - socket.on('JPD:wager', (wager) => this.submitWager(socket.id, wager)); - socket.on('JPD:judge', (data) => this.doHumanJudge(socket, data)); - socket.on('JPD:bulkJudge', (data) => { - // Check if the next player to be judged is in the input data - // If so, doJudge for that player - // Check if we advanced to the next question, otherwise keep doing doJudge - while (this.jpd.public.currentJudgeAnswer !== undefined) { - const id = this.jpd.public.currentJudgeAnswer; - const match = data.find((d: any) => d.id === id); - if (match) { - this.doHumanJudge(socket, match); - } else { - // Player to be judged isn't in the input - // Stop judging and revert to manual (or let the user resubmit, we should prevent duplicates) - break; - } - } - }); - socket.on('JPD:undo', () => { - if (this.settings.host && socket.id !== this.settings.host) { - // Not the host - return; - } - // Reset the game state to the last snapshot - // Snapshot updates at each revealAnswer - if (this.jpdSnapshot) { - redisCount('undo'); - if (this.aiJudged) { - redisCount('aiUndo'); - this.aiJudged = undefined; - } - this.undoActivated = true; - this.jpd = JSON.parse(JSON.stringify(this.jpdSnapshot)); - this.advanceJudging(false); - this.emitState(); - } - }); - socket.on('JPD:skipQ', () => { - if (this.jpd.public.canNextQ) { - // We are in the post-judging phase and can move on - this.nextQuestion(); - } - }); - socket.on('JPD:enableAiJudge', (enable: boolean) => { - this.settings.enableAIJudge = Boolean(enable); - this.emitState(); - // optional: If we're in the judging phase, trigger the AI judge here - // That way we can decide to use AI judge after the first answer has already been revealed - }); - socket.on('disconnect', () => { - if (this.jpd && this.jpd.public) { - // If player who needs to submit wager leaves, submit 0 - if ( - this.jpd.public.waitingForWager && - this.jpd.public.waitingForWager[socket.id] - ) { - this.submitWager(socket.id, 0); - } - } - // Mark the user disconnected - let target = this.room.roster.find((p) => p.id === socket.id); - if (target) { - target.connected = false; - target.disconnectTime = Date.now(); - } - this.sendRoster(); - }); - }); - - setInterval(() => { - // Remove players that have been disconnected for a long time - const beforeLength = this.room.roster.length; - const now = Date.now(); - this.room.roster = this.room.roster.filter( - (p) => p.connected || now - p.disconnectTime < 60 * 60 * 1000, - ); - const afterLength = this.room.roster.length; - if (beforeLength !== afterLength) { - this.sendRoster(); - } - }, 60000); - } - - loadEpisode(socket: Socket, options: GameOptions, custom: string) { - let { - number, - filter, - answerTimeout, - finalTimeout, - makeMeHost, - allowMultipleCorrect, - enableAIJudge, - } = options; - console.log('[LOADEPISODE]', number, filter, Boolean(custom)); - let loadedData = null; - if (custom) { - try { - const parse = Papa.parse(custom, { header: true }); - const typed = []; - let round = ''; - let cat = ''; - let curX = 0; - let curY = 0; - for (let i = 0; i < parse.data.length; i++) { - const d = parse.data[i]; - if (round !== d.round) { - // Reset x and y to 1 - curX = 1; - curY = 1; - } else if (cat !== d.cat) { - // Increment x, reset y to 1, new category - curX += 1; - curY = 1; - } else { - curY += 1; - } - round = d.round; - cat = d.cat; - let multiplier = 1; - if (round === 'double') { - multiplier = 2; - } else if (round === 'final') { - multiplier = 0; - } - if (d.q && d.a) { - typed.push({ - round: d.round, - cat: d.cat, - q: d.q, - a: d.a, - dd: d.dd?.toLowerCase() === 'true', - val: curY * 200 * multiplier, - x: curX, - y: curY, - }); - } - } - loadedData = { - airDate: new Date().toISOString().split('T')[0], - epNum: 'Custom', - jeopardy: typed.filter((d: any) => d.round === 'jeopardy'), - double: typed.filter((d: any) => d.round === 'double'), - final: typed.filter((d: any) => d.round === 'final'), - }; - redisCount('customGames'); - } catch (e) { - console.warn(e); - } - } else { - // Load question data into game - let nums = Object.keys(jData); - if (filter) { - // Only load episodes with info matching the filter: kids, teen, college etc. - nums = nums.filter( - (num) => - (jData as any)[num].info && (jData as any)[num].info === filter, - ); - } - if (number === 'ddtest') { - loadedData = jData['8000']; - loadedData['jeopardy'] = loadedData['jeopardy'].filter( - (q: any) => q.dd, - ); - } else if (number === 'finaltest') { - loadedData = jData['8000']; - } else { - if (!number) { - // Random an episode - number = nums[Math.floor(Math.random() * nums.length)]; - } - loadedData = (jData as any)[number]; - } - } - if (loadedData) { - redisCount('newGames'); - const { epNum, airDate, info, jeopardy, double, final } = loadedData; - this.jpd = getGameState( - { - epNum, - airDate, - info, - }, - jeopardy, - double, - final, - ); - this.jpdSnapshot = undefined; - this.settings.host = makeMeHost ? socket.id : undefined; - if (allowMultipleCorrect) { - this.settings.allowMultipleCorrect = allowMultipleCorrect; - } - if (enableAIJudge) { - this.settings.enableAIJudge = enableAIJudge; - } - if (Number(finalTimeout)) { - this.settings.finalTimeout = Number(finalTimeout) * 1000; - } - if (Number(answerTimeout)) { - this.settings.answerTimeout = Number(answerTimeout) * 1000; - } - if (number === 'finaltest') { - this.jpd.public.round = 'double'; - } - this.nextRound(); - } - } - - emitState() { - this.jpd.public.serverTime = Date.now(); - this.jpd.public.host = this.settings.host; - this.jpd.public.enableAIJudge = this.settings.enableAIJudge; - this.jpd.public.enableAIVoices = this.settings.enableAIVoices; - this.io.of(this.roomId).emit('JPD:state', this.jpd.public); - } - - sendRoster() { - // Sort by score and resend the list of players to everyone - this.room.roster.sort( - (a, b) => - (this.jpd.public?.scores[b.id] || 0) - - (this.jpd.public?.scores[a.id] || 0), - ); - this.io.of(this.roomId).emit('roster', this.room.roster); - } - - handleReconnect(newId: string, oldId: string) { - console.log('[RECONNECT] transfer %s to %s', oldId, newId); - // Update the roster with the new ID and connected state - const target = this.room.roster.find((p) => p.id === oldId); - if (target) { - target.id = newId; - target.connected = true; - target.disconnectTime = 0; - } - if (this.jpd.public.scores?.[oldId]) { - this.jpd.public.scores[newId] = this.jpd.public.scores[oldId]; - delete this.jpd.public.scores[oldId]; - } - if (this.jpd.public.buzzes?.[oldId]) { - this.jpd.public.buzzes[newId] = this.jpd.public.buzzes[oldId]; - delete this.jpd.public.buzzes[oldId]; - } - if (this.jpd.public.judges?.[oldId]) { - this.jpd.public.judges[newId] = this.jpd.public.judges[oldId]; - delete this.jpd.public.judges[oldId]; - } - if (this.jpd.public.submitted?.[oldId]) { - this.jpd.public.submitted[newId] = this.jpd.public.submitted[oldId]; - delete this.jpd.public.submitted[oldId]; - } - if (this.jpd.public.answers?.[oldId]) { - this.jpd.public.answers[newId] = this.jpd.public.answers[oldId]; - delete this.jpd.public.answers[oldId]; - } - if (this.jpd.public.wagers?.[oldId]) { - this.jpd.public.wagers[newId] = this.jpd.public.wagers[oldId]; - delete this.jpd.public.wagers[oldId]; - } - // Note: two copies of answers and wagers exist, a public and non-public version, so we need to copy both - // Alternatively, we can just have some state to tracks whether to emit the answers and wagers and keep both in public only - if (this.jpd.answers?.[oldId]) { - this.jpd.answers[newId] = this.jpd.answers[oldId]; - delete this.jpd.answers[oldId]; - } - if (this.jpd.wagers?.[oldId]) { - this.jpd.wagers[newId] = this.jpd.wagers[oldId]; - delete this.jpd.wagers[oldId]; - } - if (this.jpd.public.waitingForWager?.[oldId]) { - // Current behavior is to submit wager 0 if disconnecting - // So there should be no state to transfer - this.jpd.public.waitingForWager[newId] = true; - delete this.jpd.public.waitingForWager[oldId]; - } - if (this.jpd.public.currentJudgeAnswer === oldId) { - this.jpd.public.currentJudgeAnswer = newId; - } - if (this.jpd.public.dailyDoublePlayer === oldId) { - this.jpd.public.dailyDoublePlayer = newId; - } - if (this.jpd.public.picker === oldId) { - this.jpd.public.picker = newId; - } - if (this.settings.host === oldId) { - this.settings.host = newId; - } - } - - playCategories() { - this.io.of(this.roomId).emit('JPD:playCategories'); - } - - resetAfterQuestion() { - this.jpd.answers = {}; - this.jpd.wagers = {}; - clearTimeout(this.playClueTimeout); - clearTimeout(this.questionAnswerTimeout); - clearTimeout(this.wagerTimeout); - this.jpd.public = { ...this.jpd.public, ...getPerQuestionState() }; - // Overwrite any other picker settings if there's a host - if (this.settings.host) { - this.jpd.public.picker = this.settings.host; - } - } - - nextQuestion() { - // Show the correct answer in the game log - this.room.addChatMessage(undefined, { - id: '', - name: 'System', - cmd: 'answer', - msg: this.jpd.public.currentAnswer, - }); - // Scores have updated so resend sorted player list - this.sendRoster(); - // Reset question state - delete this.jpd.public.board[this.jpd.public.currentQ]; - this.resetAfterQuestion(); - if (Object.keys(this.jpd.public.board).length === 0) { - this.nextRound(); - } else { - this.emitState(); - // TODO may want to introduce some delay here to make sure our state is updated before reading selection - this.io.of(this.roomId).emit('JPD:playMakeSelection'); - } - } - - nextRound() { - this.resetAfterQuestion(); - // host is made picker in resetAfterQuestion, so any picker changes here should be behind host check - // advance round counter - if (this.jpd.public.round === 'jeopardy') { - this.jpd.public.round = 'double'; - // If double, person with lowest score is picker - // Unless we are allowing multiple corrects or there's a host - if (!this.settings.allowMultipleCorrect && !this.settings.host) { - // Pick the lowest score out of the currently connected players - // This is nlogn rather than n, but prob ok for small numbers of players - const playersWithScores = this.room.getConnectedRoster().map((p) => ({ - id: p.id, - score: this.jpd.public.scores[p.id] || 0, - })); - playersWithScores.sort((a, b) => a.score - b.score); - this.jpd.public.picker = playersWithScores[0]?.id; - } - } else if (this.jpd.public.round === 'double') { - this.jpd.public.round = 'final'; - const now = Date.now(); - this.jpd.public.waitingForWager = {}; - // There's no picker for final. In host mode we set one above - this.jpd.public.picker = undefined; - // Ask all players for wager (including disconnected since they might come back) - this.room.roster.forEach((p) => { - this.jpd.public.waitingForWager![p.id] = true; - }); - this.setWagerTimeout(this.settings.finalTimeout); - // autopick the question - this.jpd.public.currentQ = '1_1'; - // autobuzz the players in ascending score order - let playerIds = this.room.roster.map((p) => p.id); - playerIds.sort( - (a, b) => - Number(this.jpd.public.scores[a] || 0) - - Number(this.jpd.public.scores[b] || 0), - ); - playerIds.forEach((pid) => { - this.jpd.public.buzzes[pid] = now; - }); - // Play the category sound - this.io.of(this.roomId).emit('JPD:playRightanswer'); - } else if (this.jpd.public.round === 'final') { - this.jpd.public.round = 'end'; - // Log the results - const scores = Object.entries(this.jpd.public.scores); - scores.sort((a, b) => b[1] - a[1]); - const scoresNames = scores.map((score) => [ - this.room.roster.find((p) => p.id === score[0])?.name, - score[1], - ]); - redis?.lpush('jpd:results', JSON.stringify(scoresNames)); - } else { - this.jpd.public.round = 'jeopardy'; - } - if ( - this.jpd.public.round === 'jeopardy' || - this.jpd.public.round === 'double' || - this.jpd.public.round === 'final' - ) { - this.jpd.board = constructBoard((this.jpd as any)[this.jpd.public.round]); - this.jpd.public.board = constructPublicBoard( - (this.jpd as any)[this.jpd.public.round], - ); - if (Object.keys(this.jpd.public.board).length === 0) { - this.nextRound(); - } - } - this.emitState(); - if ( - this.jpd.public.round === 'jeopardy' || - this.jpd.public.round === 'double' - ) { - this.playCategories(); - } - } - - unlockAnswer(durationMs: number) { - this.jpd.public.questionEndTS = Date.now() + durationMs; - this.setQuestionAnswerTimeout(durationMs); - } - - setQuestionAnswerTimeout(durationMs: number) { - this.questionAnswerTimeout = setTimeout(() => { - if (this.jpd.public.round !== 'final') { - this.io.of(this.roomId).emit('JPD:playTimesUp'); - } - this.revealAnswer(); - }, durationMs); - } - - revealAnswer() { - clearTimeout(this.questionAnswerTimeout); - this.jpd.public.questionEndTS = 0; - - // Add empty answers for anyone who buzzed but didn't submit anything - Object.keys(this.jpd.public.buzzes).forEach((key) => { - if (!this.jpd.answers[key]) { - this.jpd.answers[key] = ''; - } - }); - this.jpd.public.canBuzz = false; - // Show everyone's answers - this.jpd.public.answers = { ...this.jpd.answers }; - this.jpd.public.currentAnswer = this.jpd.board[this.jpd.public.currentQ]?.a; - this.jpdSnapshot = JSON.parse(JSON.stringify(this.jpd)); - this.advanceJudging(false); - this.emitState(); - } - - advanceJudging(skipRemaining: boolean) { - if (this.jpd.public.currentJudgeAnswerIndex === undefined) { - this.jpd.public.currentJudgeAnswerIndex = 0; - } else { - this.jpd.public.currentJudgeAnswerIndex += 1; - } - this.jpd.public.currentJudgeAnswer = Object.keys(this.jpd.public.buzzes)[ - this.jpd.public.currentJudgeAnswerIndex - ]; - // Either we picked a correct answer (in standard mode) or ran out of players to judge - if (skipRemaining || this.jpd.public.currentJudgeAnswer === undefined) { - this.jpd.public.canNextQ = true; - } - if (this.jpd.public.currentJudgeAnswer) { - // In Final, reveal one at a time rather than all at once (for dramatic purposes) - // Note: Looks like we just bulk reveal answers elsewhere, so this is just wagers - this.jpd.public.wagers[this.jpd.public.currentJudgeAnswer] = - this.jpd.wagers[this.jpd.public.currentJudgeAnswer]; - this.jpd.public.answers[this.jpd.public.currentJudgeAnswer] = - this.jpd.answers[this.jpd.public.currentJudgeAnswer]; - } - // Undo snapshots the current state of jpd - // So if a player has reconnected since with a new ID the ID from buzzes might not be there anymore - // If so, we skip that answer (not optimal but easiest) - // TODO To fix this we probably have to use clientId instead of socket id to index the submitted answers - if ( - this.jpd.public.currentJudgeAnswer && - !this.room.roster.find((p) => p.id === this.jpd.public.currentJudgeAnswer) - ) { - console.log( - '[ADVANCEJUDGING] player not found, moving on:', - this.jpd.public.currentJudgeAnswer, - ); - this.advanceJudging(skipRemaining); - return; - } - if ( - openai && - !this.jpd.public.canNextQ && - this.settings.enableAIJudge && - // Don't use AI if the user undid - !this.undoActivated && - this.jpd.public.currentJudgeAnswer - ) { - // We don't await here since AI judging shouldn't block UI - // But we want to trigger it whenever we move on to the next answer - // The result might come back after we already manually judged, in that case we just log it and ignore - this.doAiJudge({ - currentQ: this.jpd.public.currentQ, - id: this.jpd.public.currentJudgeAnswer, - }); - } - } - - async doAiJudge(data: { currentQ: string; id: string }) { - // currentQ: The board coordinates of the current question, e.g. 1_3 - // id: socket id of the person being judged - const { currentQ, id } = data; - // The question text - const q = this.jpd.board[currentQ]?.q ?? ''; - const a = this.jpd.public.currentAnswer ?? ''; - const response = this.jpd.public.answers[id]; - const decision = await getOpenAIDecision(q, a, response); - console.log('[AIDECISION]', id, q, a, response, decision); - const correct = decision?.correct; - const confidence = decision?.confidence; - if (correct != null) { - // Log the AI decision along with whether the user agreed with it (accuracy) - // If the user undoes and then chooses differently than AI, then that's a failed decision - // Alternative: we can just highlight what the AI thinks is correct instead of auto-applying the decision, then we'll have user feedback for sure - if (redis) { - redis.lpush( - 'jpd:aiJudges', - JSON.stringify({ q, a, response, correct, confidence }), - ); - redisCount('aiJudge'); - } - this.judgeAnswer(undefined, { currentQ, id, correct, confidence }); - } - } - - doHumanJudge( - socket: Socket, - data: { currentQ: string; id: string; correct: boolean | null }, - ) { - const answer = this.jpd.public.currentAnswer; - const submitted = this.jpd.public.answers[data.id]; - const success = this.judgeAnswer(socket, data); - if (success) { - if (data.correct && redis) { - // If the answer was judged correct and non-trivial (equal lowercase), log it for analysis - if (answer?.toLowerCase() !== submitted?.toLowerCase()) { - redis.lpush('jpd:nonTrivialJudges', `${answer},${submitted},${1}`); - // redis.ltrim('jpd:nonTrivialJudges', 0, 100000); - } - } - } - } - - judgeAnswer( - socket: Socket | undefined, - { - currentQ, - id, - correct, - confidence, - }: { currentQ: string; id: string; correct: boolean | null, confidence?: number }, - ) { - if (id in this.jpd.public.judges) { - // Already judged this player - return false; - } - if (currentQ !== this.jpd.public.currentQ) { - // Not judging the right question - return false; - } - if (this.jpd.public.currentJudgeAnswer === undefined) { - // Not in judging step - return false; - } - if (this.settings.host && socket?.id !== this.settings.host) { - // Not the host - return; - } - this.jpd.public.judges[id] = correct; - console.log('[JUDGE]', id, correct); - if (!this.jpd.public.scores[id]) { - this.jpd.public.scores[id] = 0; - } - const delta = this.jpd.public.wagers[id] || this.jpd.public.currentValue; - if (correct === true) { - this.jpd.public.scores[id] += delta; - if (!this.settings.allowMultipleCorrect) { - // Correct answer is next picker - this.jpd.public.picker = id; - } - } - if (correct === false) { - this.jpd.public.scores[id] -= delta; - } - // If null/undefined, don't change scores - if (correct != null) { - const msg = { - id: socket?.id ?? '', - // name of judge - name: - this.room.roster.find((p) => p.id === socket?.id)?.name ?? 'System', - cmd: 'judge', - msg: JSON.stringify({ - id: id, - // name of person being judged - name: this.room.roster.find((p) => p.id === id)?.name, - answer: this.jpd.public.answers[id], - correct, - delta: correct ? delta : -delta, - confidence, - }), - }; - this.room.addChatMessage(socket, msg); - if (!socket) { - this.aiJudged = true; - } - } - const allowMultipleCorrect = - this.jpd.public.round === 'final' || this.settings.allowMultipleCorrect; - const skipRemaining = !allowMultipleCorrect && correct === true; - this.advanceJudging(skipRemaining); - - if (this.jpd.public.canNextQ) { - this.nextQuestion(); - } else { - this.emitState(); - } - return correct != null; - } - - submitWager(id: string, wager: number) { - if (id in this.jpd.wagers) { - return; - } - // User setting a wager for DD or final - // Can bet up to current score, minimum of 1000 in single or 2000 in double, 0 in final - let maxWager = 0; - let minWager = 5; - if (this.jpd.public.round === 'jeopardy') { - maxWager = Math.max(this.jpd.public.scores[id] || 0, 1000); - } else if (this.jpd.public.round === 'double') { - maxWager = Math.max(this.jpd.public.scores[id] || 0, 2000); - } else if (this.jpd.public.round === 'final') { - minWager = 0; - maxWager = Math.max(this.jpd.public.scores[id] || 0, 0); - } - let numWager = Number(wager); - if (Number.isNaN(Number(wager))) { - numWager = minWager; - } else { - numWager = Math.min(Math.max(numWager, minWager), maxWager); - } - console.log('[WAGER]', id, wager, numWager); - if (id === this.jpd.public.dailyDoublePlayer && this.jpd.public.currentQ) { - this.jpd.wagers[id] = numWager; - this.jpd.public.wagers[id] = numWager; - this.jpd.public.waitingForWager = undefined; - if (this.jpd.public.board[this.jpd.public.currentQ]) { - this.jpd.public.board[this.jpd.public.currentQ].question = - this.jpd.board[this.jpd.public.currentQ]?.q; - } - this.triggerPlayClue(); - this.emitState(); - } - if (this.jpd.public.round === 'final' && this.jpd.public.currentQ) { - // store the wagers privately until everyone's made one - this.jpd.wagers[id] = numWager; - if (this.jpd.public.waitingForWager) { - delete this.jpd.public.waitingForWager[id]; - } - if (Object.keys(this.jpd.public.waitingForWager ?? {}).length === 0) { - // if final, reveal clue if all players made wager - this.jpd.public.waitingForWager = undefined; - if (this.jpd.public.board[this.jpd.public.currentQ]) { - this.jpd.public.board[this.jpd.public.currentQ].question = - this.jpd.board[this.jpd.public.currentQ]?.q; - } - this.triggerPlayClue(); - } - this.emitState(); - } - } - - setWagerTimeout(durationMs: number, endTS?: number) { - this.jpd.public.wagerEndTS = endTS ?? Date.now() + durationMs; - this.wagerTimeout = setTimeout(() => { - Object.keys(this.jpd.public.waitingForWager ?? {}).forEach((id) => { - this.submitWager(id, 0); - }); - }, durationMs); - } - - triggerPlayClue() { - clearTimeout(this.wagerTimeout); - this.jpd.public.wagerEndTS = 0; - const clue = this.jpd.public.board[this.jpd.public.currentQ]; - this.io - .of(this.roomId) - .emit('JPD:playClue', this.jpd.public.currentQ, clue && clue.question); - let speakingTime = 0; - if (clue && clue.question) { - // Allow some time for reading the text, based on content - // Count syllables in text, assume speaking rate of 4 syll/sec - const syllCountArr = clue.question - // Remove parenthetical starts and blanks - .replace(/^\(.*\)/, '') - .replace(/_+/g, ' blank ') - .split(' ') - .map((word: string) => syllableCount(word)); - const totalSyll = syllCountArr.reduce((a: number, b: number) => a + b, 0); - // Minimum 1 second speaking time - speakingTime = Math.max((totalSyll / 4) * 1000, 1000); - console.log('[TRIGGERPLAYCLUE]', clue.question, totalSyll, speakingTime); - this.jpd.public.playClueEndTS = Date.now() + speakingTime; - } - this.setPlayClueTimeout(speakingTime); - } - - setPlayClueTimeout(durationMs: number) { - this.playClueTimeout = setTimeout(() => { - this.playClueDone(); - }, durationMs); - } - - playClueDone() { - console.log('[PLAYCLUEDONE]'); - clearTimeout(this.playClueTimeout); - this.jpd.public.playClueEndTS = 0; - this.jpd.public.buzzUnlockTS = Date.now(); - if (this.jpd.public.round === 'final') { - this.unlockAnswer(this.settings.finalTimeout); - // Play final jeopardy music - this.io.of(this.roomId).emit('JPD:playFinalJeopardy'); - } else { - if (!this.jpd.public.currentDailyDouble) { - // DD already handles buzzing automatically - this.jpd.public.canBuzz = true; - } - this.unlockAnswer(this.settings.answerTimeout); - } - this.emitState(); - } - - async pregenAIVoices(rvcHost: string) { - // Indicate we should use AI voices for this game - this.settings.enableAIVoices = rvcHost; - this.emitState(); - // For the current game, get all category names and clues (61 clues + 12 category names) - // Final category doesn't get read right now - const strings = new Set([ - ...this.jpd.jeopardy?.map(item => item.q) ?? [], - ...this.jpd.double?.map(item => item.q) ?? [], - ...this.jpd.final?.map(item => item.q) ?? [], - ...this.jpd.jeopardy?.map(item => item.cat) ?? [], - ...this.jpd.double?.map(item => item.cat) ?? [], - ].filter(Boolean)); - console.log('%s strings to generate', strings.size); - const arr = Array.from(strings); - // console.log(arr); - const results = await Promise.allSettled(arr.map(async (str, i) => { - try { - // Call the API to pregenerate the voice clips - const url = await genAITextToSpeech(rvcHost, str ?? ''); - // Report progress back in chat messages - if (url) { - this.room.addChatMessage(undefined, { - id: '', - name: 'System', - msg: 'generated ai voice ' + i + ': ' + url, - }); - redisCount('aiVoice'); - } - } catch (e) { - console.log(e); - } - })); - this.room.addChatMessage(undefined, { - id: '', - name: 'System', - msg: results.filter(Boolean).length + '/' + results.length + ' voices generated!', - }); - } - - toJSON() { - return this.jpd; - } -} diff --git a/server/openai.ts b/server/openai.ts new file mode 100644 index 00000000..5fdec009 --- /dev/null +++ b/server/openai.ts @@ -0,0 +1,74 @@ +import OpenAI from 'openai'; + +const openai = process.env.OPENAI_SECRET_KEY + ? new OpenAI({ apiKey: process.env.OPENAI_SECRET_KEY }) + : undefined; + +// Notes on AI judging: +// Using Threads/Assistant is inefficient because OpenAI sends the entire conversation history with each subsequent request +// We don't care about the conversation history since we judge each answer independently +// Use the Completions API instead and supply the instructions on each request +// If the instructions are at least 1024 tokens long, it will be cached and we get 50% off pricing (and maybe faster) +// If we can squeeze the instructions into 512 tokens it'll probably be cheaper to not use cache +// Currently, consumes about 250 input tokens and 6 output tokens per answer (depends on the question length) +const prompt = ` +Decide whether a response to a trivia question is correct, given the question, the correct answer, and the response. +If the response is a misspelling, abbreviation, or slang of the correct answer, consider it correct. +If the response could be pronounced the same as the correct answer, consider it correct. +If the response includes the correct answer but also other incorrect answers, consider it incorrect. +Only if there is no way the response could be construed to be the correct answer should you consider it incorrect. +`; +// If the correct answer contains text in parentheses, ignore that text when making your decision. +// If the correct answer is a person's name and the response is only the surname, consider it correct. +// Ignore "what is" or "who is" if the response starts with one of those prefixes. +// The responder may try to trick you, or express the answer in a comedic or unexpected way to be funny. +// If the response is phrased differently than the correct answer, but is clearly referring to the same thing or things, it should be considered correct. +// Also return a number between 0 and 1 indicating how confident you are in your decision. + +export async function getOpenAIDecision( + question: string, + answer: string, + response: string, +): Promise<{ correct: boolean; confidence: number } | null> { + if (!openai) { + return null; + } + const suffix = `question: '${question}', correct: '${answer}', response: '${response}'`; + console.log('[AIINPUT]', suffix); + // Concatenate the prompt and the suffix for AI completion + const result = await openai.chat.completions.create({ + model: 'gpt-4o-mini', + messages: [{ role: 'developer', content: prompt + suffix }], + response_format: { + type: 'json_schema', + json_schema: { + name: 'trivia_judgment', + strict: true, + schema: { + type: 'object', + properties: { + correct: { + type: 'boolean', + }, + // confidence: { + // type: 'number', + // }, + }, + required: ['correct'], + additionalProperties: false, + }, + }, + }, + }); + console.log(result); + const text = result.choices[0].message.content; + // The text might be invalid JSON e.g. if the model refused to respond + try { + if (text) { + return JSON.parse(text); + } + } catch (e) { + console.log(e); + } + return null; +} diff --git a/server/room.ts b/server/room.ts index 1ba81d7f..f3f1f0c3 100644 --- a/server/room.ts +++ b/server/room.ts @@ -1,5 +1,140 @@ -import { Jeopardy } from './jeopardy'; import { Socket, Server } from 'socket.io'; +import Redis from 'ioredis'; +import Papa from 'papaparse'; +import { gunzipSync } from 'zlib'; +import { redisCount } from './utils/redis'; +import fs from 'fs'; +import nodeCrypto from 'node:crypto'; +import { genAITextToSpeech } from './aivoice'; +import { getOpenAIDecision } from './openai'; + +interface RawQuestion { + val: number; + cat: string; + x?: number; + y?: number; + q?: string; + a?: string; + dd?: boolean; +} + +interface Question { + value: number; + category: string; + question?: string; + answer?: string; + daily_double?: boolean; +} + +let redis = undefined as unknown as Redis; +if (process.env.REDIS_URL) { + redis = new Redis(process.env.REDIS_URL); +} + +// On boot, start with the initial data included in repo +console.time('load'); +let fileData: Buffer | undefined = fs.readFileSync('./jeopardy.json.gz'); +let jData = JSON.parse(gunzipSync(fileData).toString()); +let hash = nodeCrypto.createHash('md5').update(fileData).digest('hex'); +fileData = undefined; +console.timeEnd('load'); +console.log('loaded %d episodes', Object.keys(jData).length); + +async function refreshEpisodes() { + if (process.env.NODE_ENV === 'development') { + return; + } + console.time('reload'); + try { + const response = await fetch( + 'https://github.com/howardchung/j-archive-parser/raw/release/jeopardy.json.gz', + ); + const arrayBuf = await response.arrayBuffer(); + const buf = Buffer.from(arrayBuf); + const newHash = nodeCrypto.createHash('md5').update(buf).digest('hex'); + // Check if new compressed data matches current + if (newHash !== hash) { + jData = JSON.parse(gunzipSync(buf).toString()); + hash = newHash; + console.log('reloaded %d episodes', Object.keys(jData).length); + } else { + console.log('skipping reload since data is the same'); + } + } catch (e) { + console.log(e); + } + console.timeEnd('reload'); +} +// Periodically refetch the latest episode data and replace it in memory +setInterval(refreshEpisodes, 24 * 60 * 60 * 1000); +refreshEpisodes(); + +const getPerQuestionState = () => { + return { + currentQ: '', + currentAnswer: undefined as string | undefined, + currentValue: 0, + playClueEndTS: 0, + questionEndTS: 0, + wagerEndTS: 0, + buzzUnlockTS: 0, + currentDailyDouble: false, + canBuzz: false, + canNextQ: false, + currentJudgeAnswerIndex: undefined as number | undefined, + currentJudgeAnswer: undefined as string | undefined, //socket.id + dailyDoublePlayer: undefined as string | undefined, //socket.id + answers: {} as Record, + submitted: {} as Record, + judges: {} as Record, + buzzes: {} as Record, + wagers: {} as Record, + // We track this separately from wagers because the list of people to wait for is different depending on context + // e.g. for Double we only need to wait for 1 player, for final we have to wait for everyone + waitingForWager: undefined as Record | undefined, + }; +}; + +const getGameState = ( + options: { + epNum?: string; + airDate?: string; + info?: string; + answerTimeout?: number; + finalTimeout?: number; + allowMultipleCorrect?: boolean; + host?: string; + enableAIJudge?: boolean; + }, + jeopardy?: RawQuestion[], + double?: RawQuestion[], + final?: RawQuestion[], +) => { + return { + jeopardy, + double, + final, + answers: {} as Record, + wagers: {} as Record, + board: {} as { [key: string]: RawQuestion }, + public: { + serverTime: Date.now(), + epNum: options.epNum, + airDate: options.airDate, + info: options.info, + board: {} as { [key: string]: Question }, + scores: {} as Record, // player scores + round: '', // jeopardy or double or final + picker: undefined as string | undefined, // If null let anyone pick, otherwise last correct answer + // below is populated in emitstate from settings + host: undefined as string | undefined, + enableAIJudge: false, + enableAIVoices: undefined as string | undefined, + ...getPerQuestionState(), + }, + }; +}; +export type PublicGameState = ReturnType['public']; export class Room { public roster: User[] = []; @@ -8,7 +143,24 @@ export class Room { private io: Server; public roomId: string; public creationTime: Date = new Date(); - private jpd: Jeopardy | null = null; + public jpd: ReturnType = getGameState({}, [], [], []); + public settings = { + answerTimeout: 20000, + finalTimeout: 30000, + host: undefined as string | undefined, + allowMultipleCorrect: false, + enableAIJudge: false, + enableAIVoices: undefined as string | undefined, + }; + // Note: snapshot is not persisted so undo is not possible if server restarts + private jpdSnapshot: ReturnType | undefined; + private undoActivated: boolean | undefined = undefined; + private aiJudged: boolean | undefined = undefined; + private playClueTimeout: NodeJS.Timeout = + undefined as unknown as NodeJS.Timeout; + private questionAnswerTimeout: NodeJS.Timeout = + undefined as unknown as NodeJS.Timeout; + private wagerTimeout: NodeJS.Timeout = undefined as unknown as NodeJS.Timeout; constructor( io: Server, @@ -22,12 +174,214 @@ export class Room { this.deserialize(roomData); } - if (!this.jpd) { - this.jpd = new Jeopardy(io, this); - } + // Currently, all rooms remain in memory until server reboot, so this could be a lot of intervals + setInterval(() => { + // Remove players that have been disconnected for a long time + const beforeLength = this.getAllPlayers(); + const now = Date.now(); + this.roster = this.roster.filter( + (p) => p.connected || now - p.disconnectTime < 60 * 60 * 1000, + ); + const afterLength = this.getAllPlayers(); + if (beforeLength !== afterLength) { + this.sendRoster(); + } + }, 30 * 60 * 1000); io.of(roomId).on('connection', (socket: Socket) => { + this.jpd.public.scores[socket.id] = 0; + + const clientId = socket.handshake.query?.clientId as string; + // clientid map keeps track of the unique clients we've seen + // if we saw this ID already, do the reconnect logic (transfer state) + // The list is persisted, so if the server reboots, all clients reconnect and should have state restored + if (this.clientIds[clientId]) { + const newId = socket.id; + const oldId = this.clientIds[clientId]; + this.handleReconnect(newId, oldId); + } + if (!this.getAllPlayers().find((p) => p.id === socket.id)) { + // New client joining, add to roster + this.roster.push({ + id: socket.id, + name: undefined, + connected: true, + disconnectTime: 0, + }); + } + this.clientIds[clientId] = socket.id; + + this.emitState(); + this.sendRoster(); socket.emit('chatinit', this.chat); + + socket.on('CMD:name', (data: string) => { + if (!data) { + return; + } + if (data && data.length > 100) { + return; + } + const target = this.getAllPlayers().find((p) => p.id === socket.id); + if (target) { + target.name = data; + this.sendRoster(); + } + }); + // socket.on('JPD:cmdIntro', () => { + // this.io.of(this.roomId).emit('JPD:playIntro'); + // }); + socket.on('JPD:start', (options, data) => { + if (data && data.length > 1000000) { + return; + } + if (typeof options !== 'object') { + return; + } + this.loadEpisode(socket, options, data); + }); + socket.on('JPD:pickQ', (id: string) => { + if (this.settings.host && socket.id !== this.settings.host) { + return; + } + if ( + this.jpd.public.picker && + // If the picker is disconnected, allow anyone to pick to avoid blocking game + this.getConnectedPlayers().find( + (p) => p.id === this.jpd.public.picker, + ) && + this.jpd.public.picker !== socket.id + ) { + return; + } + if (this.jpd.public.currentQ) { + return; + } + if (!this.jpd.public.board[id]) { + return; + } + this.jpd.public.currentQ = id; + this.jpd.public.currentValue = this.jpd.public.board[id].value; + // check if it's a daily double + if (this.jpd.board[id].dd && !this.settings.allowMultipleCorrect) { + // if it is, don't show it yet, we need to collect wager info based only on category + this.jpd.public.currentDailyDouble = true; + this.jpd.public.dailyDoublePlayer = socket.id; + this.jpd.public.waitingForWager = { [socket.id]: true }; + this.setWagerTimeout(this.settings.answerTimeout); + // Autobuzz the player who picked the DD, all others pass + // Note: if a player joins during wagering, they might not be marked as passed (submitted) + // Currently client doesn't show the answer box because it checks for buzzed in players + // But there's probably no server block on them submitting answers + this.getActivePlayers().forEach((p) => { + if (p.id === socket.id) { + this.jpd.public.buzzes[p.id] = Date.now(); + } else { + this.jpd.public.submitted[p.id] = true; + } + }); + this.io.of(this.roomId).emit('JPD:playDailyDouble'); + } else { + // Put Q in public state + this.jpd.public.board[this.jpd.public.currentQ].question = + this.jpd.board[this.jpd.public.currentQ].q; + this.triggerPlayClue(); + } + // Undo no longer possible after next question is picked + this.jpdSnapshot = undefined; + this.undoActivated = undefined; + this.aiJudged = undefined; + this.emitState(); + }); + socket.on('JPD:buzz', () => { + if (!this.jpd.public.canBuzz) { + return; + } + if (this.jpd.public.buzzes[socket.id]) { + return; + } + this.jpd.public.buzzes[socket.id] = Date.now(); + this.emitState(); + }); + socket.on('JPD:answer', (question, answer) => { + if (question !== this.jpd.public.currentQ) { + // Not submitting for right question + return; + } + if (!this.jpd.public.questionEndTS) { + // Time was already up + return; + } + if (answer && answer.length > 10000) { + // Answer too long + return; + } + console.log('[ANSWER]', socket.id, question, answer); + if (answer) { + this.jpd.answers[socket.id] = answer; + } + this.jpd.public.submitted[socket.id] = true; + this.emitState(); + if ( + this.jpd.public.round !== 'final' && + // If a player disconnects, don't wait for their answer + this.getConnectedPlayers().every( + (p) => p.id in this.jpd.public.submitted, + ) + ) { + this.revealAnswer(); + } + }); + + socket.on('JPD:wager', (wager) => this.submitWager(socket.id, wager)); + socket.on('JPD:judge', (data) => this.doHumanJudge(socket, data)); + socket.on('JPD:bulkJudge', (data) => { + // Check if the next player to be judged is in the input data + // If so, doJudge for that player + // Check if we advanced to the next question, otherwise keep doing doJudge + while (this.jpd.public.currentJudgeAnswer !== undefined) { + const id = this.jpd.public.currentJudgeAnswer; + const match = data.find((d: any) => d.id === id); + if (match) { + this.doHumanJudge(socket, match); + } else { + // Player to be judged isn't in the input + // Stop judging and revert to manual (or let the user resubmit, we should prevent duplicates) + break; + } + } + }); + socket.on('JPD:undo', () => { + if (this.settings.host && socket.id !== this.settings.host) { + // Not the host + return; + } + // Reset the game state to the last snapshot + // Snapshot updates at each revealAnswer + if (this.jpdSnapshot) { + redisCount('undo'); + if (this.aiJudged) { + redisCount('aiUndo'); + this.aiJudged = undefined; + } + this.undoActivated = true; + this.jpd = JSON.parse(JSON.stringify(this.jpdSnapshot)); + this.advanceJudging(false); + this.emitState(); + } + }); + socket.on('JPD:skipQ', () => { + if (this.jpd.public.canNextQ) { + // We are in the post-judging phase and can move on + this.nextQuestion(); + } + }); + socket.on('JPD:enableAiJudge', (enable: boolean) => { + this.settings.enableAIJudge = Boolean(enable); + this.emitState(); + // optional: If we're in the judging phase, trigger the AI judge here + // That way we can decide to use AI judge after the first answer has already been revealed + }); socket.on('CMD:chat', (data: string) => { if (data && data.length > 10000) { // TODO add some validation on client side too so we don't just drop long messages @@ -39,13 +393,32 @@ export class Room { return; } if (data.startsWith('/aivoices')) { - const rvcServer = data.split(' ')[1] ?? 'https://azure.howardchung.net/rvc'; - this.jpd?.pregenAIVoices(rvcServer); + const rvcServer = + data.split(' ')[1] ?? 'https://azure.howardchung.net/rvc'; + this.pregenAIVoices(rvcServer); } - const sender = this.roster.find(p => p.id === socket.id); + const sender = this.getAllPlayers().find((p) => p.id === socket.id); const chatMsg = { id: socket.id, name: sender?.name, msg: data }; this.addChatMessage(socket, chatMsg); }); + socket.on('disconnect', () => { + if (this.jpd && this.jpd.public) { + // If player who needs to submit wager leaves, submit 0 + if ( + this.jpd.public.waitingForWager && + this.jpd.public.waitingForWager[socket.id] + ) { + this.submitWager(socket.id, 0); + } + } + // Mark the user disconnected + let target = this.getAllPlayers().find((p) => p.id === socket.id); + if (target) { + target.connected = false; + target.disconnectTime = Date.now(); + } + this.sendRoster(); + }); }); } @@ -56,7 +429,7 @@ export class Room { roster: this.roster, creationTime: this.creationTime, jpd: this.jpd, - settings: this.jpd?.settings, + settings: this.settings, }); }; @@ -74,11 +447,28 @@ export class Room { if (roomObj.roster) { this.roster = roomObj.roster; } - if (roomObj.jpd) { - this.jpd = new Jeopardy(this.io, this, roomObj.jpd); + if (roomObj.jpd && roomObj.jpd.public) { + const gameData = roomObj.jpd; + this.jpd = gameData; + // Reconstruct the timeouts from the saved state + if (this.jpd.public.questionEndTS) { + const remaining = this.jpd.public.questionEndTS - Date.now(); + console.log('[QUESTIONENDTS]', remaining); + this.setQuestionAnswerTimeout(remaining); + } + if (this.jpd.public.playClueEndTS) { + const remaining = this.jpd.public.playClueEndTS - Date.now(); + console.log('[PLAYCLUEENDTS]', remaining); + this.setPlayClueTimeout(remaining); + } + if (this.jpd.public.wagerEndTS) { + const remaining = this.jpd.public.wagerEndTS - Date.now(); + console.log('[WAGERENDTS]', remaining); + this.setWagerTimeout(remaining, this.jpd.public.wagerEndTS); + } } - if (roomObj.settings && this.jpd) { - this.jpd.settings = roomObj.settings; + if (roomObj.settings) { + this.settings = roomObj.settings; } }; @@ -92,7 +482,751 @@ export class Room { this.io.of(this.roomId).emit('REC:chat', chatWithTime); }; - getConnectedRoster = () => { + getConnectedPlayers = () => { return this.roster.filter((p) => p.connected); }; + + getActivePlayers = () => { + // Currently just returns all players + // In the future we might want to ignore spectators + return this.roster; + }; + + getAllPlayers = () => { + // Return all players regardless of connection state or spectator + return this.roster; + }; + + loadEpisode(socket: Socket, options: GameOptions, custom: string) { + let { + number, + filter, + answerTimeout, + finalTimeout, + makeMeHost, + allowMultipleCorrect, + enableAIJudge, + } = options; + console.log('[LOADEPISODE]', number, filter, Boolean(custom)); + let loadedData = null; + if (custom) { + try { + const parse = Papa.parse(custom, { header: true }); + const typed = []; + let round = ''; + let cat = ''; + let curX = 0; + let curY = 0; + for (let i = 0; i < parse.data.length; i++) { + const d = parse.data[i]; + if (round !== d.round) { + // Reset x and y to 1 + curX = 1; + curY = 1; + } else if (cat !== d.cat) { + // Increment x, reset y to 1, new category + curX += 1; + curY = 1; + } else { + curY += 1; + } + round = d.round; + cat = d.cat; + let multiplier = 1; + if (round === 'double') { + multiplier = 2; + } else if (round === 'final') { + multiplier = 0; + } + if (d.q && d.a) { + typed.push({ + round: d.round, + cat: d.cat, + q: d.q, + a: d.a, + dd: d.dd?.toLowerCase() === 'true', + val: curY * 200 * multiplier, + x: curX, + y: curY, + }); + } + } + loadedData = { + airDate: new Date().toISOString().split('T')[0], + epNum: 'Custom', + jeopardy: typed.filter((d: any) => d.round === 'jeopardy'), + double: typed.filter((d: any) => d.round === 'double'), + final: typed.filter((d: any) => d.round === 'final'), + }; + redisCount('customGames'); + } catch (e) { + console.warn(e); + } + } else { + // Load question data into game + let nums = Object.keys(jData); + if (filter) { + // Only load episodes with info matching the filter: kids, teen, college etc. + nums = nums.filter( + (num) => + (jData as any)[num].info && (jData as any)[num].info === filter, + ); + } + if (number === 'ddtest') { + loadedData = jData['8000']; + loadedData['jeopardy'] = loadedData['jeopardy'].filter( + (q: any) => q.dd, + ); + } else if (number === 'finaltest') { + loadedData = jData['8000']; + } else { + if (!number) { + // Random an episode + number = nums[Math.floor(Math.random() * nums.length)]; + } + loadedData = (jData as any)[number]; + } + } + if (loadedData) { + redisCount('newGames'); + const { epNum, airDate, info, jeopardy, double, final } = loadedData; + this.jpd = getGameState( + { + epNum, + airDate, + info, + }, + jeopardy, + double, + final, + ); + this.jpdSnapshot = undefined; + this.settings.host = makeMeHost ? socket.id : undefined; + if (allowMultipleCorrect) { + this.settings.allowMultipleCorrect = allowMultipleCorrect; + } + if (enableAIJudge) { + this.settings.enableAIJudge = enableAIJudge; + } + if (Number(finalTimeout)) { + this.settings.finalTimeout = Number(finalTimeout) * 1000; + } + if (Number(answerTimeout)) { + this.settings.answerTimeout = Number(answerTimeout) * 1000; + } + if (number === 'finaltest') { + this.jpd.public.round = 'double'; + } + this.nextRound(); + } + } + + emitState() { + this.jpd.public.serverTime = Date.now(); + this.jpd.public.host = this.settings.host; + this.jpd.public.enableAIJudge = this.settings.enableAIJudge; + this.jpd.public.enableAIVoices = this.settings.enableAIVoices; + this.io.of(this.roomId).emit('JPD:state', this.jpd.public); + } + + sendRoster() { + // Sort by score and resend the list of players to everyone + this.roster.sort( + (a, b) => + (this.jpd.public?.scores[b.id] || 0) - + (this.jpd.public?.scores[a.id] || 0), + ); + this.io.of(this.roomId).emit('roster', this.roster); + } + + handleReconnect(newId: string, oldId: string) { + console.log('[RECONNECT] transfer %s to %s', oldId, newId); + // Update the roster with the new ID and connected state + const target = this.getAllPlayers().find((p) => p.id === oldId); + if (target) { + target.id = newId; + target.connected = true; + target.disconnectTime = 0; + } + if (this.jpd.public.scores?.[oldId]) { + this.jpd.public.scores[newId] = this.jpd.public.scores[oldId]; + delete this.jpd.public.scores[oldId]; + } + if (this.jpd.public.buzzes?.[oldId]) { + this.jpd.public.buzzes[newId] = this.jpd.public.buzzes[oldId]; + delete this.jpd.public.buzzes[oldId]; + } + if (this.jpd.public.judges?.[oldId]) { + this.jpd.public.judges[newId] = this.jpd.public.judges[oldId]; + delete this.jpd.public.judges[oldId]; + } + if (this.jpd.public.submitted?.[oldId]) { + this.jpd.public.submitted[newId] = this.jpd.public.submitted[oldId]; + delete this.jpd.public.submitted[oldId]; + } + if (this.jpd.public.answers?.[oldId]) { + this.jpd.public.answers[newId] = this.jpd.public.answers[oldId]; + delete this.jpd.public.answers[oldId]; + } + if (this.jpd.public.wagers?.[oldId]) { + this.jpd.public.wagers[newId] = this.jpd.public.wagers[oldId]; + delete this.jpd.public.wagers[oldId]; + } + // Note: two copies of answers and wagers exist, a public and non-public version, so we need to copy both + // Alternatively, we can just have some state to tracks whether to emit the answers and wagers and keep both in public only + if (this.jpd.answers?.[oldId]) { + this.jpd.answers[newId] = this.jpd.answers[oldId]; + delete this.jpd.answers[oldId]; + } + if (this.jpd.wagers?.[oldId]) { + this.jpd.wagers[newId] = this.jpd.wagers[oldId]; + delete this.jpd.wagers[oldId]; + } + if (this.jpd.public.waitingForWager?.[oldId]) { + // Current behavior is to submit wager 0 if disconnecting + // So there should be no state to transfer + this.jpd.public.waitingForWager[newId] = true; + delete this.jpd.public.waitingForWager[oldId]; + } + if (this.jpd.public.currentJudgeAnswer === oldId) { + this.jpd.public.currentJudgeAnswer = newId; + } + if (this.jpd.public.dailyDoublePlayer === oldId) { + this.jpd.public.dailyDoublePlayer = newId; + } + if (this.jpd.public.picker === oldId) { + this.jpd.public.picker = newId; + } + if (this.settings.host === oldId) { + this.settings.host = newId; + } + } + + playCategories() { + this.io.of(this.roomId).emit('JPD:playCategories'); + } + + resetAfterQuestion() { + this.jpd.answers = {}; + this.jpd.wagers = {}; + clearTimeout(this.playClueTimeout); + clearTimeout(this.questionAnswerTimeout); + clearTimeout(this.wagerTimeout); + this.jpd.public = { ...this.jpd.public, ...getPerQuestionState() }; + // Overwrite any other picker settings if there's a host + if (this.settings.host) { + this.jpd.public.picker = this.settings.host; + } + } + + nextQuestion() { + // Show the correct answer in the game log + this.addChatMessage(undefined, { + id: '', + name: 'System', + cmd: 'answer', + msg: this.jpd.public.currentAnswer, + }); + // Scores have updated so resend sorted player list + this.sendRoster(); + // Reset question state + delete this.jpd.public.board[this.jpd.public.currentQ]; + this.resetAfterQuestion(); + if (Object.keys(this.jpd.public.board).length === 0) { + this.nextRound(); + } else { + this.emitState(); + // TODO may want to introduce some delay here to make sure our state is updated before reading selection + this.io.of(this.roomId).emit('JPD:playMakeSelection'); + } + } + + nextRound() { + this.resetAfterQuestion(); + // host is made picker in resetAfterQuestion, so any picker changes here should be behind host check + // advance round counter + if (this.jpd.public.round === 'jeopardy') { + this.jpd.public.round = 'double'; + // If double, person with lowest score is picker + // Unless we are allowing multiple corrects or there's a host + if (!this.settings.allowMultipleCorrect && !this.settings.host) { + // Pick the lowest score out of the currently connected players + // This is nlogn rather than n, but prob ok for small numbers of players + const playersWithScores = this.getConnectedPlayers().map((p) => ({ + id: p.id, + score: this.jpd.public.scores[p.id] || 0, + })); + playersWithScores.sort((a, b) => a.score - b.score); + this.jpd.public.picker = playersWithScores[0]?.id; + } + } else if (this.jpd.public.round === 'double') { + this.jpd.public.round = 'final'; + const now = Date.now(); + this.jpd.public.waitingForWager = {}; + // There's no picker for final. In host mode we set one above + this.jpd.public.picker = undefined; + // Ask all players for wager (including disconnected since they might come back) + this.getActivePlayers().forEach((p) => { + this.jpd.public.waitingForWager![p.id] = true; + }); + this.setWagerTimeout(this.settings.finalTimeout); + // autopick the question + this.jpd.public.currentQ = '1_1'; + // autobuzz the players in ascending score order + let playerIds = this.getActivePlayers().map((p) => p.id); + playerIds.sort( + (a, b) => + Number(this.jpd.public.scores[a] || 0) - + Number(this.jpd.public.scores[b] || 0), + ); + playerIds.forEach((pid) => { + this.jpd.public.buzzes[pid] = now; + }); + // Play the category sound + this.io.of(this.roomId).emit('JPD:playRightanswer'); + } else if (this.jpd.public.round === 'final') { + this.jpd.public.round = 'end'; + // Log the results + const scores = Object.entries(this.jpd.public.scores); + scores.sort((a, b) => b[1] - a[1]); + const scoresNames = scores.map((score) => [ + this.getAllPlayers().find((p) => p.id === score[0])?.name, + score[1], + ]); + redis?.lpush('jpd:results', JSON.stringify(scoresNames)); + } else { + this.jpd.public.round = 'jeopardy'; + } + if ( + this.jpd.public.round === 'jeopardy' || + this.jpd.public.round === 'double' || + this.jpd.public.round === 'final' + ) { + this.jpd.board = constructBoard((this.jpd as any)[this.jpd.public.round]); + this.jpd.public.board = constructPublicBoard( + (this.jpd as any)[this.jpd.public.round], + ); + if (Object.keys(this.jpd.public.board).length === 0) { + this.nextRound(); + } + } + this.emitState(); + if ( + this.jpd.public.round === 'jeopardy' || + this.jpd.public.round === 'double' + ) { + this.playCategories(); + } + } + + unlockAnswer(durationMs: number) { + this.jpd.public.questionEndTS = Date.now() + durationMs; + this.setQuestionAnswerTimeout(durationMs); + } + + setQuestionAnswerTimeout(durationMs: number) { + this.questionAnswerTimeout = setTimeout(() => { + if (this.jpd.public.round !== 'final') { + this.io.of(this.roomId).emit('JPD:playTimesUp'); + } + this.revealAnswer(); + }, durationMs); + } + + revealAnswer() { + clearTimeout(this.questionAnswerTimeout); + this.jpd.public.questionEndTS = 0; + + // Add empty answers for anyone who buzzed but didn't submit anything + Object.keys(this.jpd.public.buzzes).forEach((key) => { + if (!this.jpd.answers[key]) { + this.jpd.answers[key] = ''; + } + }); + this.jpd.public.canBuzz = false; + // Show everyone's answers + this.jpd.public.answers = { ...this.jpd.answers }; + this.jpd.public.currentAnswer = this.jpd.board[this.jpd.public.currentQ]?.a; + this.jpdSnapshot = JSON.parse(JSON.stringify(this.jpd)); + this.advanceJudging(false); + this.emitState(); + } + + advanceJudging(skipRemaining: boolean) { + if (this.jpd.public.currentJudgeAnswerIndex === undefined) { + this.jpd.public.currentJudgeAnswerIndex = 0; + } else { + this.jpd.public.currentJudgeAnswerIndex += 1; + } + this.jpd.public.currentJudgeAnswer = Object.keys(this.jpd.public.buzzes)[ + this.jpd.public.currentJudgeAnswerIndex + ]; + // Either we picked a correct answer (in standard mode) or ran out of players to judge + if (skipRemaining || this.jpd.public.currentJudgeAnswer === undefined) { + this.jpd.public.canNextQ = true; + } + if (this.jpd.public.currentJudgeAnswer) { + // In Final, reveal one at a time rather than all at once (for dramatic purposes) + // Note: Looks like we just bulk reveal answers elsewhere, so this is just wagers + this.jpd.public.wagers[this.jpd.public.currentJudgeAnswer] = + this.jpd.wagers[this.jpd.public.currentJudgeAnswer]; + this.jpd.public.answers[this.jpd.public.currentJudgeAnswer] = + this.jpd.answers[this.jpd.public.currentJudgeAnswer]; + } + // Undo snapshots the current state of jpd + // So if a player has reconnected since with a new ID the ID from buzzes might not be there anymore + // If so, we skip that answer (not optimal but easiest) + // TODO To fix this we probably have to use clientId instead of socket id to index the submitted answers + if ( + this.jpd.public.currentJudgeAnswer && + !this.getActivePlayers().find( + (p) => p.id === this.jpd.public.currentJudgeAnswer, + ) + ) { + console.log( + '[ADVANCEJUDGING] player not found, moving on:', + this.jpd.public.currentJudgeAnswer, + ); + this.advanceJudging(skipRemaining); + return; + } + if ( + process.env.OPENAI_SECRET_KEY && + !this.jpd.public.canNextQ && + this.settings.enableAIJudge && + // Don't use AI if the user undid + !this.undoActivated && + this.jpd.public.currentJudgeAnswer + ) { + // We don't await here since AI judging shouldn't block UI + // But we want to trigger it whenever we move on to the next answer + // The result might come back after we already manually judged, in that case we just log it and ignore + this.doAiJudge({ + currentQ: this.jpd.public.currentQ, + id: this.jpd.public.currentJudgeAnswer, + }); + } + } + + async doAiJudge(data: { currentQ: string; id: string }) { + // currentQ: The board coordinates of the current question, e.g. 1_3 + // id: socket id of the person being judged + const { currentQ, id } = data; + // The question text + const q = this.jpd.board[currentQ]?.q ?? ''; + const a = this.jpd.public.currentAnswer ?? ''; + const response = this.jpd.public.answers[id]; + const decision = await getOpenAIDecision(q, a, response); + console.log('[AIDECISION]', id, q, a, response, decision); + const correct = decision?.correct; + const confidence = decision?.confidence; + if (correct != null) { + // Log the AI decision along with whether the user agreed with it (accuracy) + // If the user undoes and then chooses differently than AI, then that's a failed decision + // Alternative: we can just highlight what the AI thinks is correct instead of auto-applying the decision, then we'll have user feedback for sure + if (redis) { + redis.lpush( + 'jpd:aiJudges', + JSON.stringify({ q, a, response, correct, confidence }), + ); + redisCount('aiJudge'); + } + this.judgeAnswer(undefined, { currentQ, id, correct, confidence }); + } + } + + doHumanJudge( + socket: Socket, + data: { currentQ: string; id: string; correct: boolean | null }, + ) { + const answer = this.jpd.public.currentAnswer; + const submitted = this.jpd.public.answers[data.id]; + const success = this.judgeAnswer(socket, data); + if (success) { + if (data.correct && redis) { + // If the answer was judged correct and non-trivial (equal lowercase), log it for analysis + if (answer?.toLowerCase() !== submitted?.toLowerCase()) { + redis.lpush('jpd:nonTrivialJudges', `${answer},${submitted},${1}`); + // redis.ltrim('jpd:nonTrivialJudges', 0, 100000); + } + } + } + } + + judgeAnswer( + socket: Socket | undefined, + { + currentQ, + id, + correct, + confidence, + }: { + currentQ: string; + id: string; + correct: boolean | null; + confidence?: number; + }, + ) { + if (id in this.jpd.public.judges) { + // Already judged this player + return false; + } + if (currentQ !== this.jpd.public.currentQ) { + // Not judging the right question + return false; + } + if (this.jpd.public.currentJudgeAnswer === undefined) { + // Not in judging step + return false; + } + if (this.settings.host && socket?.id !== this.settings.host) { + // Not the host + return; + } + this.jpd.public.judges[id] = correct; + console.log('[JUDGE]', id, correct); + if (!this.jpd.public.scores[id]) { + this.jpd.public.scores[id] = 0; + } + const delta = this.jpd.public.wagers[id] || this.jpd.public.currentValue; + if (correct === true) { + this.jpd.public.scores[id] += delta; + if (!this.settings.allowMultipleCorrect) { + // Correct answer is next picker + this.jpd.public.picker = id; + } + } + if (correct === false) { + this.jpd.public.scores[id] -= delta; + } + // If null/undefined, don't change scores + if (correct != null) { + const msg = { + id: socket?.id ?? '', + // name of judge + name: + this.getAllPlayers().find((p) => p.id === socket?.id)?.name ?? + 'System', + cmd: 'judge', + msg: JSON.stringify({ + id: id, + // name of person being judged + name: this.getAllPlayers().find((p) => p.id === id)?.name, + answer: this.jpd.public.answers[id], + correct, + delta: correct ? delta : -delta, + confidence, + }), + }; + this.addChatMessage(socket, msg); + if (!socket) { + this.aiJudged = true; + } + } + const allowMultipleCorrect = + this.jpd.public.round === 'final' || this.settings.allowMultipleCorrect; + const skipRemaining = !allowMultipleCorrect && correct === true; + this.advanceJudging(skipRemaining); + + if (this.jpd.public.canNextQ) { + this.nextQuestion(); + } else { + this.emitState(); + } + return correct != null; + } + + submitWager(id: string, wager: number) { + if (id in this.jpd.wagers) { + return; + } + // User setting a wager for DD or final + // Can bet up to current score, minimum of 1000 in single or 2000 in double, 0 in final + let maxWager = 0; + let minWager = 5; + if (this.jpd.public.round === 'jeopardy') { + maxWager = Math.max(this.jpd.public.scores[id] || 0, 1000); + } else if (this.jpd.public.round === 'double') { + maxWager = Math.max(this.jpd.public.scores[id] || 0, 2000); + } else if (this.jpd.public.round === 'final') { + minWager = 0; + maxWager = Math.max(this.jpd.public.scores[id] || 0, 0); + } + let numWager = Number(wager); + if (Number.isNaN(Number(wager))) { + numWager = minWager; + } else { + numWager = Math.min(Math.max(numWager, minWager), maxWager); + } + console.log('[WAGER]', id, wager, numWager); + if (id === this.jpd.public.dailyDoublePlayer && this.jpd.public.currentQ) { + this.jpd.wagers[id] = numWager; + this.jpd.public.wagers[id] = numWager; + this.jpd.public.waitingForWager = undefined; + if (this.jpd.public.board[this.jpd.public.currentQ]) { + this.jpd.public.board[this.jpd.public.currentQ].question = + this.jpd.board[this.jpd.public.currentQ]?.q; + } + this.triggerPlayClue(); + this.emitState(); + } + if (this.jpd.public.round === 'final' && this.jpd.public.currentQ) { + // store the wagers privately until everyone's made one + this.jpd.wagers[id] = numWager; + if (this.jpd.public.waitingForWager) { + delete this.jpd.public.waitingForWager[id]; + } + if (Object.keys(this.jpd.public.waitingForWager ?? {}).length === 0) { + // if final, reveal clue if all players made wager + this.jpd.public.waitingForWager = undefined; + if (this.jpd.public.board[this.jpd.public.currentQ]) { + this.jpd.public.board[this.jpd.public.currentQ].question = + this.jpd.board[this.jpd.public.currentQ]?.q; + } + this.triggerPlayClue(); + } + this.emitState(); + } + } + + setWagerTimeout(durationMs: number, endTS?: number) { + this.jpd.public.wagerEndTS = endTS ?? Date.now() + durationMs; + this.wagerTimeout = setTimeout(() => { + Object.keys(this.jpd.public.waitingForWager ?? {}).forEach((id) => { + this.submitWager(id, 0); + }); + }, durationMs); + } + + triggerPlayClue() { + clearTimeout(this.wagerTimeout); + this.jpd.public.wagerEndTS = 0; + const clue = this.jpd.public.board[this.jpd.public.currentQ]; + this.io + .of(this.roomId) + .emit('JPD:playClue', this.jpd.public.currentQ, clue && clue.question); + let speakingTime = 0; + if (clue && clue.question) { + // Allow some time for reading the text, based on content + // Count syllables in text, assume speaking rate of 4 syll/sec + const syllCountArr = clue.question + // Remove parenthetical starts and blanks + .replace(/^\(.*\)/, '') + .replace(/_+/g, ' blank ') + .split(' ') + .map((word: string) => syllableCount(word)); + const totalSyll = syllCountArr.reduce((a: number, b: number) => a + b, 0); + // Minimum 1 second speaking time + speakingTime = Math.max((totalSyll / 4) * 1000, 1000); + console.log('[TRIGGERPLAYCLUE]', clue.question, totalSyll, speakingTime); + this.jpd.public.playClueEndTS = Date.now() + speakingTime; + } + this.setPlayClueTimeout(speakingTime); + } + + setPlayClueTimeout(durationMs: number) { + this.playClueTimeout = setTimeout(() => { + this.playClueDone(); + }, durationMs); + } + + playClueDone() { + console.log('[PLAYCLUEDONE]'); + clearTimeout(this.playClueTimeout); + this.jpd.public.playClueEndTS = 0; + this.jpd.public.buzzUnlockTS = Date.now(); + if (this.jpd.public.round === 'final') { + this.unlockAnswer(this.settings.finalTimeout); + // Play final jeopardy music + this.io.of(this.roomId).emit('JPD:playFinalJeopardy'); + } else { + if (!this.jpd.public.currentDailyDouble) { + // DD already handles buzzing automatically + this.jpd.public.canBuzz = true; + } + this.unlockAnswer(this.settings.answerTimeout); + } + this.emitState(); + } + + async pregenAIVoices(rvcHost: string) { + // Indicate we should use AI voices for this game + this.settings.enableAIVoices = rvcHost; + this.emitState(); + // For the current game, get all category names and clues (61 clues + 12 category names) + // Final category doesn't get read right now + const strings = new Set( + [ + ...(this.jpd.jeopardy?.map((item) => item.q) ?? []), + ...(this.jpd.double?.map((item) => item.q) ?? []), + ...(this.jpd.final?.map((item) => item.q) ?? []), + ...(this.jpd.jeopardy?.map((item) => item.cat) ?? []), + ...(this.jpd.double?.map((item) => item.cat) ?? []), + ].filter(Boolean), + ); + console.log('%s strings to generate', strings.size); + const arr = Array.from(strings); + // console.log(arr); + const results = await Promise.allSettled( + arr.map(async (str, i) => { + try { + // Call the API to pregenerate the voice clips + const url = await genAITextToSpeech(rvcHost, str ?? ''); + // Report progress back in chat messages + if (url) { + this.addChatMessage(undefined, { + id: '', + name: 'System', + msg: 'generated ai voice ' + i + ': ' + url, + }); + redisCount('aiVoice'); + } + } catch (e) { + console.log(e); + } + }), + ); + this.addChatMessage(undefined, { + id: '', + name: 'System', + msg: + results.filter(Boolean).length + + '/' + + results.length + + ' voices generated!', + }); + } +} + +function constructBoard(questions: RawQuestion[]) { + // Map of x_y coordinates to questions + let output: { [key: string]: RawQuestion } = {}; + questions.forEach((q) => { + output[`${q.x}_${q.y}`] = q; + }); + return output; +} + +function constructPublicBoard(questions: RawQuestion[]) { + // Map of x_y coordinates to questions + let output: { [key: string]: Question } = {}; + questions.forEach((q) => { + output[`${q.x}_${q.y}`] = { + value: q.val, + category: q.cat, + }; + }); + return output; +} + +function syllableCount(word: string) { + word = word.toLowerCase(); //word.downcase! + if (word.length <= 3) { + return 1; + } + word = word.replace(/(?:[^laeiouy]es|ed|[^laeiouy]e)$/, ''); //word.sub!(/(?:[^laeiouy]es|ed|[^laeiouy]e)$/, '') + word = word.replace(/^y/, ''); + let vowels = word.match(/[aeiouy]{1,2}/g); + // Use 3 as the default if no letters, it's probably a year + return vowels ? vowels.length : 3; } diff --git a/server/server.ts b/server/server.ts index 227b2da6..6a1c46e9 100644 --- a/server/server.ts +++ b/server/server.ts @@ -37,7 +37,7 @@ async function saveRoomsToRedis() { // console.time('roomSave'); const roomArr = Array.from(rooms.values()); for (let i = 0; i < roomArr.length; i++) { - if (roomArr[i].getConnectedRoster().length) { + if (roomArr[i].getConnectedPlayers().length) { const roomData = roomArr[i].serialize(); const key = roomArr[i].roomId; await redis?.setex(key, 24 * 60 * 60, roomData); @@ -90,7 +90,7 @@ app.get('/stats', async (req, res) => { const obj = { creationTime: room.creationTime, roomId: room.roomId, - rosterLength: room.getConnectedRoster().length, + rosterLength: room.getConnectedPlayers().length, }; currentUsers += obj.rosterLength; roomData.push(obj); @@ -103,7 +103,7 @@ app.get('/stats', async (req, res) => { .find((line) => line.startsWith('used_memory:')) ?.split(':')[1] .trim(); - const chatMessages = await getRedisCountDay('chatMessages'); + // const chatMessages = await getRedisCountDay('chatMessages'); const newGames = await getRedisCountDay('newGames'); const customGames = await getRedisCountDay('customGames'); const aiJudgeLastDay = await getRedisCountDay('aiJudge'); @@ -119,7 +119,7 @@ app.get('/stats', async (req, res) => { roomCount: rooms.size, cpuUsage, redisUsage, - chatMessages, + // chatMessages, currentUsers, newGames, customGames, diff --git a/src/components/Chat/Chat.tsx b/src/components/Chat/Chat.tsx index 30e20090..fcbccce5 100644 --- a/src/components/Chat/Chat.tsx +++ b/src/components/Chat/Chat.tsx @@ -69,7 +69,9 @@ export class Chat extends React.Component { style={{ color: correct ? '#21ba45' : '#db2828' }} >{`ruled ${name} ${correct ? 'correct' : 'incorrect'}: ${answer} (${ delta >= 0 ? '+' : '' - }${delta}) ${confidence != null ? `(${(confidence * 100).toFixed(0)}% conf.)` : ''}`} + }${delta}) ${ + confidence != null ? `(${(confidence * 100).toFixed(0)}% conf.)` : '' + }`} ); } else if (cmd === 'answer') { return `Correct answer: ${msg}`; @@ -143,14 +145,7 @@ export class Chat extends React.Component { } } -const ChatMessage = ({ - id, - name, - timestamp, - cmd, - msg, - formatMessage, -}: any) => { +const ChatMessage = ({ id, name, timestamp, cmd, msg, formatMessage }: any) => { return ( @@ -159,7 +154,9 @@ const ChatMessage = ({ {name || id} -
{new Date(timestamp).toLocaleTimeString()}
+
+ {new Date(timestamp).toLocaleTimeString()} +
{cmd && formatMessage(cmd, msg)} diff --git a/src/components/Jeopardy/Jeopardy.tsx b/src/components/Jeopardy/Jeopardy.tsx index e38484c1..ef7524b0 100644 --- a/src/components/Jeopardy/Jeopardy.tsx +++ b/src/components/Jeopardy/Jeopardy.tsx @@ -388,7 +388,13 @@ export class Jeopardy extends React.Component<{ try { await new Promise(async (resolve, reject) => { const hash = MD5.hash(text); - const aiVoice = new Audio(this.state.game?.enableAIVoices + '/gradio_api/file=audio/output/{hash}.mp3'.replace('{hash}', hash)); + const aiVoice = new Audio( + this.state.game?.enableAIVoices + + '/gradio_api/file=audio/output/{hash}.mp3'.replace( + '{hash}', + hash, + ), + ); aiVoice.onended = resolve; aiVoice.onerror = reject; try { @@ -586,7 +592,12 @@ export class Jeopardy extends React.Component<{ )} {this.state.overlayMsg && }
{ @@ -802,7 +813,10 @@ export class Jeopardy extends React.Component<{ /> )} {Boolean(game.wagerEndTS) && ( - + )} {game.canNextQ && (
{new Date(game.airDate + 'T00:00').toLocaleDateString([], { @@ -1111,7 +1129,7 @@ export class Jeopardy extends React.Component<{ month: 'long', day: 'numeric', })} - {(game && game.info) ? ' - ' + game.info : ''} + {game && game.info ? ' - ' + game.info : ''} )}