├── .env.example
├── .github
└── workflows
│ ├── master_dokku.yml
│ └── master_jeopardy.yml
├── .gitignore
├── .prettierignore
├── .prettierrc
├── .vscode
└── settings.json
├── LICENSE
├── Procfile
├── README.md
├── dev
├── dokku.sh
├── hash.mts
└── run_hash.sh
├── global.d.ts
├── index.html
├── jeopardy.json.gz
├── package-lock.json
├── package.json
├── public
├── clearly.mp3
├── example.csv
├── favicon.ico
├── jeopardy
│ ├── jeopardy-board-fill.mp3
│ ├── jeopardy-daily-double.mp3
│ ├── jeopardy-intro-full.ogg
│ ├── jeopardy-intro-short.mp3
│ ├── jeopardy-intro-video.mp4
│ ├── jeopardy-rightanswer.mp3
│ ├── jeopardy-think.mp3
│ └── jeopardy-times-up.mp3
└── screenshot3.png
├── server
├── aivoice.ts
├── config.ts
├── gamestate.ts
├── hash.ts
├── jData.ts
├── moniker.ts
├── openai.ts
├── redis.ts
├── room.ts
├── server.ts
└── tsconfig.json
├── src
├── components
│ ├── App
│ │ ├── App.css
│ │ ├── App.test.js
│ │ ├── App.tsx
│ │ └── index.tsx
│ ├── Chat
│ │ └── Chat.tsx
│ ├── Home
│ │ ├── Home.module.css
│ │ └── Home.tsx
│ ├── Jeopardy
│ │ ├── Jeopardy.css
│ │ ├── Jeopardy.tsx
│ │ ├── gyparody.ttf
│ │ ├── jeopardy-daily-double.png
│ │ ├── jeopardy-game-board-daily-double.png
│ │ ├── korinna-regular.otf
│ │ ├── swiss911-xcm-bt.ttf
│ │ └── univers-75-black.ttf
│ └── TopBar
│ │ └── TopBar.tsx
├── index.jsx
├── tsconfig.json
└── utils.ts
├── vite.config.js
└── words
├── adjectives.txt
├── nouns.txt
└── verbs.txt
/.env.example:
--------------------------------------------------------------------------------
1 | #REDIS_URL=REPLACE_ME
2 | #OPENAI_SECRET_KEY=REPLACE_ME
--------------------------------------------------------------------------------
/.github/workflows/master_dokku.yml:
--------------------------------------------------------------------------------
1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node
2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions
3 |
4 | name: Node.js CI
5 |
6 | on:
7 | push:
8 | branches: [master]
9 | pull_request:
10 | branches: [master]
11 |
12 | jobs:
13 | build:
14 | runs-on: ubuntu-latest
15 |
16 | steps:
17 | - name: Cloning repo
18 | uses: actions/checkout@v2
19 | with:
20 | fetch-depth: 0
21 | - name: Push to dokku
22 | uses: dokku/github-action@master
23 | with:
24 | git_remote_url: 'ssh://dokku@jeopardy.centralus.cloudapp.azure.com:22/jeopardy.app'
25 | ssh_private_key: ${{ secrets.SSH_PRIVATE_KEY }}
26 |
--------------------------------------------------------------------------------
/.github/workflows/master_jeopardy.yml:
--------------------------------------------------------------------------------
1 | # Docs for the Azure Web Apps Deploy action: https://github.com/Azure/webapps-deploy
2 | # More GitHub Actions for Azure: https://github.com/Azure/actions
3 |
4 | # name: Build and deploy Node.js app to Azure Web App - jeopardy
5 |
6 | # on:
7 | # push:
8 | # branches:
9 | # - master
10 | # workflow_dispatch:
11 |
12 | # jobs:
13 | # build:
14 | # runs-on: ubuntu-latest
15 |
16 | # steps:
17 | # - uses: actions/checkout@v2
18 |
19 | # - name: Set up Node.js version
20 | # uses: actions/setup-node@v1
21 | # with:
22 | # node-version: '16.x'
23 |
24 | # - name: npm install, build, and test
25 | # run: |
26 | # npm install
27 | # npm run build --if-present
28 |
29 | # - name: Zip artifact for deployment
30 | # run: zip release.zip ./* -r
31 |
32 | # - name: Upload artifact for deployment job
33 | # uses: actions/upload-artifact@v2
34 | # with:
35 | # name: node-app
36 | # path: release.zip
37 |
38 | # deploy:
39 | # runs-on: ubuntu-latest
40 | # needs: build
41 | # environment:
42 | # name: 'Production'
43 | # url: ${{ steps.deploy-to-webapp.outputs.webapp-url }}
44 |
45 | # steps:
46 | # - name: Download artifact from build job
47 | # uses: actions/download-artifact@v2
48 | # with:
49 | # name: node-app
50 |
51 | # - name: unzip artifact for deployment
52 | # run: unzip release.zip
53 |
54 | # - name: 'Deploy to Azure Web App'
55 | # id: deploy-to-webapp
56 | # uses: azure/webapps-deploy@v2
57 | # with:
58 | # app-name: 'jeopardy'
59 | # slot-name: 'Production'
60 | # publish-profile: ${{ secrets.AZUREAPPSERVICE_PUBLISHPROFILE_05B932BE2D10470B995285EFD9AD1EED }}
61 | # package: .
62 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # production
12 | /build
13 | /buildServer
14 |
15 | # misc
16 | .DS_Store
17 | .env
18 | .env.local
19 | .env.development.local
20 | .env.test.local
21 | .env.production.local
22 |
23 | npm-debug.log*
24 | yarn-debug.log*
25 | yarn-error.log*
26 |
27 | j-archive-parser/*
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | j-archive-parser
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "singleQuote": true
3 | }
4 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "typescript.tsdk": "node_modules/typescript/lib"
3 | }
4 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 Howard Chung
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Procfile:
--------------------------------------------------------------------------------
1 | web: npm run server
2 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Jeopardy
2 |
3 | A website for playing Jeopardy! together with friends over the Internet. Designed for computer browsers, although it's playable on mobile. If the UI/code looks familiar, much of it is copied from my other project, WatchParty: https://github.com/howardchung/watchparty
4 |
5 | ## Description
6 |
7 | - Implements the game show Jeopardy!, including the Jeopardy, Double Jeopardy, and Final Jeopardy rounds. Daily Doubles are also included.
8 | - Any archived episode of Jeopardy! can be loaded, with options for loading specific event games (e.g. College Championship)
9 | - Load games by episode number
10 | - Create your own custom game with a CSV file
11 | - Supports creating multiple rooms for private/simultaneous games.
12 | - Text chat included
13 |
14 | ### Reading:
15 |
16 | - Uses text-to-speech to read clues
17 |
18 | ### Buzzing:
19 |
20 | - After a set time (based on number of syllables in the clue text), buzzing is unlocked
21 | - Buzzing in enables a user to submit an answer
22 | - Answers will be judged in buzz order
23 |
24 | ### Judging:
25 |
26 | - Players can judge answer correctness themselves.
27 | - An experimental AI judge powered by ChatGPT is in testing.
28 |
29 | ### Data:
30 |
31 | - Game data is from http://j-archive.com/
32 | - Games might be incomplete if some clues weren't revealed on the show.
33 |
34 | ## Updating Clues:
35 |
36 | - Game data is collected using a separate j-archive-parser project and collected into a single gzipped JSON file, which this project can retrieve.
37 |
38 | ## Environment Variables
39 |
40 | - `REDIS_URL`: Provide to allow persisting rooms to Redis so they survive server reboots
41 | - `OPENAI_SECRET_KEY`: Provide to allow using OpenAI's ChatGPT to judge answers
42 |
43 | ## Tech
44 |
45 | - React
46 | - TypeScript
47 | - Node.js
48 | - Redis
49 |
--------------------------------------------------------------------------------
/dev/dokku.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # for debian systems, installs Dokku via apt-get
4 | wget https://dokku.com/install/v0.30.1/bootstrap.sh
5 | sudo DOKKU_TAG=v0.30.1 bash bootstrap.sh
6 |
7 | # usually your key is already available under the current user's `~/.ssh/authorized_keys` file
8 | cat ~/.ssh/authorized_keys | sudo dokku ssh-keys:add admin
9 |
10 | # you can use any domain you already have access to
11 | # this domain should have an A record or CNAME pointing at your server's IP
12 | dokku domains:set-global jeopardy.app
13 |
14 | dokku apps:create jeopardy.app
15 | # Set up env vars
16 | dokku config:set jeopardy.app STATS_KEY=test
17 |
18 | # Set up Redis (dokku native)
19 | sudo dokku plugin:install https://github.com/dokku/dokku-redis.git
20 | dokku redis:create jeopardy-redis
21 | dokku redis:link jeopardy-redis jeopardy.app
22 |
23 | # Set up redis (custom)
24 | # sudo docker run --name redis -d redis:7
25 | # Use the IP of the docker bridge network
26 | # dokku config:set jeopardy.app REDIS_URL=redis://172.17.0.4:6379
--------------------------------------------------------------------------------
/dev/hash.mts:
--------------------------------------------------------------------------------
1 | // Benchmark hashing algorithms
2 | // import { xxHash32 } from 'js-xxhash';
3 | import nodeCrypto from 'node:crypto';
4 | import { cyrb53 } from '../server/hash.ts';
5 |
6 | const test = 'test'.repeat(100000);
7 |
8 | // Note: due to VM optimization the later functions run faster
9 | // Need to execute in separate processes for accurate testing
10 | const jobs = {
11 | cyrb: () => cyrb53(test).toString(16),
12 | // xxhash: () => xxHash32(test).toString(16),
13 | // md5js: () => MD5.hash(test),
14 | // Works only in node
15 | md5node: () => nodeCrypto.createHash('md5').update(test).digest('hex'),
16 | // requires https in browser, also Buffer API to convert not available
17 | sha1: async () => Buffer.from(await crypto.subtle.digest('sha-1', Buffer.from(test))).toString('hex'),
18 | }
19 |
20 | console.time(process.argv[2]);
21 | console.log(await jobs[process.argv[2]]());
22 | console.timeEnd(process.argv[2]);
23 |
--------------------------------------------------------------------------------
/dev/run_hash.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | tsx hash.mts cyrb
4 | tsx hash.mts xxhash
5 | tsx hash.mts md5js
6 | tsx hash.mts md5node
7 | tsx hash.mts sha1
--------------------------------------------------------------------------------
/global.d.ts:
--------------------------------------------------------------------------------
1 | interface User {
2 | id: string;
3 | name?: string;
4 | connected: boolean;
5 | disconnectTime: number;
6 | }
7 |
8 | interface ChatMessage {
9 | timestamp: string;
10 | videoTS?: number;
11 | id: string;
12 | cmd: string;
13 | msg: string;
14 | }
15 |
16 | interface RawQuestion {
17 | val: number;
18 | cat: string;
19 | x?: number;
20 | y?: number;
21 | q?: string;
22 | a?: string;
23 | dd?: boolean;
24 | }
25 |
26 | interface Question {
27 | value: number;
28 | category: string;
29 | question?: string;
30 | answer?: string;
31 | daily_double?: boolean;
32 | }
33 |
34 | type GameOptions = {
35 | number?: string;
36 | filter?: string;
37 | makeMeHost?: boolean;
38 | allowMultipleCorrect?: boolean;
39 | // Turns on AI judge by default (otherwise needs to be enabled per game)
40 | enableAIJudge?: boolean;
41 | // timeout to use for DD wagers and question answers
42 | answerTimeout?: number;
43 | // timeout to use for final wagers and answers (all players participate)
44 | finalTimeout?: number;
45 | }
46 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 |
13 |
14 | Jeopardy!
15 |
16 |
17 |
18 |
37 | You need to enable JavaScript to run this app.
38 |
39 |
49 |
50 |
51 |
55 |
64 |
65 |
--------------------------------------------------------------------------------
/jeopardy.json.gz:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/howardchung/jeopardy/ce64b280bc5adafad56b2c2f0abede988cc0d524/jeopardy.json.gz
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "jeopardy",
3 | "version": "0.1.2",
4 | "private": true,
5 | "dependencies": {
6 | "compression": "^1.7.5",
7 | "cors": "^2.8.5",
8 | "dotenv": "^16.4.7",
9 | "express": "^5.0.1",
10 | "ioredis": "^5.4.2",
11 | "openai": "^5.1.1",
12 | "papaparse": "^5.5.1",
13 | "react": "^18.3.1",
14 | "react-countup": "^6.5.3",
15 | "react-dom": "^18.3.1",
16 | "react-markdown": "^9.0.3",
17 | "semantic-ui-css": "^2.5.0",
18 | "semantic-ui-react": "^2.1.5",
19 | "socket.io": "^4.8.1",
20 | "socket.io-client": "^4.8.1"
21 | },
22 | "scripts": {
23 | "server": "node buildServer/server.js",
24 | "ui": "vite --host",
25 | "build": "npm run buildReact && npm run buildServer",
26 | "buildReact": "vite build && npm run typecheck",
27 | "buildServer": "tsc --project server/tsconfig.json --outDir buildServer",
28 | "typecheckServer": "tsc --project server/tsconfig.json --noEmit",
29 | "typecheck": "tsc --project src/tsconfig.json --noEmit",
30 | "dev": "ts-node-dev --respawn --transpile-only --project server/tsconfig.json server/server.ts",
31 | "prettier": "prettier --write .",
32 | "updateEps": "curl -sSL -O https://github.com/howardchung/j-archive-parser/raw/release/jeopardy.json.gz"
33 | },
34 | "eslintConfig": {
35 | "extends": "react-app"
36 | },
37 | "browserslist": {
38 | "production": [
39 | ">0.2%",
40 | "not dead",
41 | "not op_mini all"
42 | ],
43 | "development": [
44 | "last 1 chrome version",
45 | "last 1 firefox version",
46 | "last 1 safari version"
47 | ]
48 | },
49 | "devDependencies": {
50 | "@types/compression": "^1.7.5",
51 | "@types/cors": "^2.8.17",
52 | "@types/express": "^5.0.0",
53 | "@types/node": "^22.10.5",
54 | "@types/papaparse": "^5.3.15",
55 | "@types/react": "^18.3.18",
56 | "@types/react-dom": "^18.3.5",
57 | "prettier": "^3.4.2",
58 | "ts-node-dev": "^2.0.0",
59 | "typescript": "^5.7.3",
60 | "vite": "^6.0.7"
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/public/clearly.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/howardchung/jeopardy/ce64b280bc5adafad56b2c2f0abede988cc0d524/public/clearly.mp3
--------------------------------------------------------------------------------
/public/example.csv:
--------------------------------------------------------------------------------
1 | round,cat,q,a,dd
2 | jeopardy,"A CATEGORY","a question","a answer",true
3 | jeopardy,"A CATEGORY","a question","a answer",false
4 | jeopardy,"A CATEGORY","a question","a answer",false
5 | jeopardy,"A CATEGORY","a question","a answer",false
6 | jeopardy,"A CATEGORY","a question","a answer",false
7 | jeopardy,"ANOTHER CATEGORY","a question","a answer",false
8 | jeopardy,"ANOTHER CATEGORY","a question","a answer",false
9 | jeopardy,"ANOTHER CATEGORY","a question","a answer",false
10 | jeopardy,"ANOTHER CATEGORY","a question","a answer",false
11 | jeopardy,"ANOTHER CATEGORY","a question","a answer",false
12 | jeopardy,"CATEGORY 3","a question","a answer",false
13 | jeopardy,"CATEGORY 3","a question","a answer",false
14 | jeopardy,"CATEGORY 3","a question","a answer",false
15 | jeopardy,"CATEGORY 3","a question","a answer",false
16 | jeopardy,"CATEGORY 3","a question","a answer",false
17 | jeopardy,"CATEGORY 4","a question","a answer",false
18 | jeopardy,"CATEGORY 4","a question","a answer",false
19 | jeopardy,"CATEGORY 4","a question","a answer",false
20 | jeopardy,"CATEGORY 4","a question","a answer",false
21 | jeopardy,"CATEGORY 4","a question","a answer",false
22 | jeopardy,"CATEGORY 5","a question","a answer",false
23 | jeopardy,"CATEGORY 5","a question","a answer",false
24 | jeopardy,"CATEGORY 5","a question","a answer",false
25 | jeopardy,"CATEGORY 5","a question","a answer",false
26 | jeopardy,"CATEGORY 5","a question","a answer",false
27 | jeopardy,"CATEGORY 6","a question","a answer",false
28 | jeopardy,"CATEGORY 6","a question","a answer",false
29 | jeopardy,"CATEGORY 6","a question","a answer",false
30 | jeopardy,"CATEGORY 6","a question","a answer",false
31 | jeopardy,"CATEGORY 6","a question","a answer",false
32 | double,"A CATEGORY","a question","a answer",true
33 | double,"A CATEGORY","a question","a answer",true
34 | double,"A CATEGORY","a question","a answer",false
35 | double,"A CATEGORY","a question","a answer",false
36 | double,"A CATEGORY","a question","a answer",false
37 | double,"ANOTHER CATEGORY","a question","a answer",false
38 | double,"ANOTHER CATEGORY","a question","a answer",false
39 | double,"ANOTHER CATEGORY","a question","a answer",false
40 | double,"ANOTHER CATEGORY","a question","a answer",false
41 | double,"ANOTHER CATEGORY","a question","a answer",false
42 | double,"CATEGORY 3","a question","a answer",false
43 | double,"CATEGORY 3","a question","a answer",false
44 | double,"CATEGORY 3","a question","a answer",false
45 | double,"CATEGORY 3","a question","a answer",false
46 | double,"CATEGORY 3","a question","a answer",false
47 | double,"CATEGORY 4","a question","a answer",false
48 | double,"CATEGORY 4","a question","a answer",false
49 | double,"CATEGORY 4","a question","a answer",false
50 | double,"CATEGORY 4","a question","a answer",false
51 | double,"CATEGORY 4","a question","a answer",false
52 | double,"CATEGORY 5","a question","a answer",false
53 | double,"CATEGORY 5","a question","a answer",false
54 | double,"CATEGORY 5","a question","a answer",false
55 | double,"CATEGORY 5","a question","a answer",false
56 | double,"CATEGORY 5","a question","a answer",false
57 | double,"CATEGORY 6","a question","a answer",false
58 | double,"CATEGORY 6","a question","a answer",false
59 | double,"CATEGORY 6","a question","a answer",false
60 | double,"CATEGORY 6","a question","a answer",false
61 | double,"CATEGORY 6","a question","a answer",false
62 | final,"FINAL CATEGORY","a question","a answer",false
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/howardchung/jeopardy/ce64b280bc5adafad56b2c2f0abede988cc0d524/public/favicon.ico
--------------------------------------------------------------------------------
/public/jeopardy/jeopardy-board-fill.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/howardchung/jeopardy/ce64b280bc5adafad56b2c2f0abede988cc0d524/public/jeopardy/jeopardy-board-fill.mp3
--------------------------------------------------------------------------------
/public/jeopardy/jeopardy-daily-double.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/howardchung/jeopardy/ce64b280bc5adafad56b2c2f0abede988cc0d524/public/jeopardy/jeopardy-daily-double.mp3
--------------------------------------------------------------------------------
/public/jeopardy/jeopardy-intro-full.ogg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/howardchung/jeopardy/ce64b280bc5adafad56b2c2f0abede988cc0d524/public/jeopardy/jeopardy-intro-full.ogg
--------------------------------------------------------------------------------
/public/jeopardy/jeopardy-intro-short.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/howardchung/jeopardy/ce64b280bc5adafad56b2c2f0abede988cc0d524/public/jeopardy/jeopardy-intro-short.mp3
--------------------------------------------------------------------------------
/public/jeopardy/jeopardy-intro-video.mp4:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/howardchung/jeopardy/ce64b280bc5adafad56b2c2f0abede988cc0d524/public/jeopardy/jeopardy-intro-video.mp4
--------------------------------------------------------------------------------
/public/jeopardy/jeopardy-rightanswer.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/howardchung/jeopardy/ce64b280bc5adafad56b2c2f0abede988cc0d524/public/jeopardy/jeopardy-rightanswer.mp3
--------------------------------------------------------------------------------
/public/jeopardy/jeopardy-think.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/howardchung/jeopardy/ce64b280bc5adafad56b2c2f0abede988cc0d524/public/jeopardy/jeopardy-think.mp3
--------------------------------------------------------------------------------
/public/jeopardy/jeopardy-times-up.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/howardchung/jeopardy/ce64b280bc5adafad56b2c2f0abede988cc0d524/public/jeopardy/jeopardy-times-up.mp3
--------------------------------------------------------------------------------
/public/screenshot3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/howardchung/jeopardy/ce64b280bc5adafad56b2c2f0abede988cc0d524/public/screenshot3.png
--------------------------------------------------------------------------------
/server/aivoice.ts:
--------------------------------------------------------------------------------
1 | import { cyrb53 } from './hash';
2 |
3 | // Given input text, gets back an mp3 file URL
4 | // We can send this to each client and have it be read
5 | // The RVC server caches for repeated inputs, so duplicate requests are fast
6 | // 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
7 | export async function genAITextToSpeech(
8 | rvcHost: string,
9 | text: string,
10 | ): Promise {
11 | if (text.length > 10000 || !text.length) {
12 | return;
13 | }
14 | const resp = await fetch(rvcHost + '/gradio_api/call/partial_36', {
15 | method: 'POST',
16 | headers: {
17 | 'Content-Type': 'application/json',
18 | },
19 | body: JSON.stringify({
20 | data: [
21 | text,
22 | 'Trebek',
23 | 'en-US-ChristopherNeural',
24 | 0,
25 | 0,
26 | 0,
27 | 0,
28 | 0,
29 | ['rmvpe'],
30 | 0.5,
31 | 3,
32 | 0.25,
33 | 0.33,
34 | 128,
35 | true,
36 | false,
37 | 1,
38 | true,
39 | 0.7,
40 | 'contentvec',
41 | '',
42 | 0,
43 | 0,
44 | 44100,
45 | 'mp3',
46 | cyrb53(text).toString(),
47 | ],
48 | }),
49 | });
50 | const info = await resp.json();
51 | // console.log(info);
52 | // Fetch the result
53 | const fetchUrl = rvcHost + '/gradio_api/call/partial_36/' + info.event_id;
54 | // console.log(fetchUrl);
55 | const resp2 = await fetch(fetchUrl);
56 | const info2 = await resp2.text();
57 | // console.log(info2);
58 | const lines = info2.split('\n');
59 | // Find the line after complete
60 | const completeIndex = lines.indexOf('event: complete');
61 | const target = lines[completeIndex + 1];
62 | if (target.startsWith('data: ')) {
63 | // Take off the prefix, parse the array as json and get the first element
64 | const arr = JSON.parse(target.slice(6));
65 | // Fix the path /grad/gradio_api/file to /gradio_api/file
66 | const url = arr[0].url.replace('/grad/gradio_api/file', '/gradio_api/file');
67 | // console.log(url);
68 | return url;
69 | }
70 | return;
71 | }
72 |
--------------------------------------------------------------------------------
/server/config.ts:
--------------------------------------------------------------------------------
1 | import 'dotenv/config';
2 |
3 | const defaults = {
4 | SSL_KEY_FILE: '', // Optional, Filename of SSL key (to use https)
5 | SSL_CRT_FILE: '', // Optional, Filename of SSL cert (to use https)
6 | PORT: '8083', // Port to use for server
7 | NODE_ENV: '',
8 | OPENAI_SECRET_KEY: '',
9 | STATS_KEY: 'test',
10 | REDIS_URL: '',
11 | };
12 |
13 | export default {
14 | ...defaults,
15 | ...process.env,
16 | permaRooms: ['/default', '/smokestack', '/howard-and-katie', '/liz'],
17 | };
--------------------------------------------------------------------------------
/server/gamestate.ts:
--------------------------------------------------------------------------------
1 | export const getPerQuestionState = () => {
2 | return {
3 | currentQ: '',
4 | currentAnswer: undefined as string | undefined,
5 | currentValue: 0,
6 | playClueEndTS: 0,
7 | questionEndTS: 0,
8 | wagerEndTS: 0,
9 | buzzUnlockTS: 0,
10 | currentDailyDouble: false,
11 | canBuzz: false,
12 | canNextQ: false,
13 | currentJudgeAnswerIndex: undefined as number | undefined,
14 | currentJudgeAnswer: undefined as string | undefined, //socket.id
15 | dailyDoublePlayer: undefined as string | undefined, //socket.id
16 | answers: {} as Record,
17 | submitted: {} as Record,
18 | judges: {} as Record,
19 | buzzes: {} as Record,
20 | wagers: {} as Record,
21 | // We track this separately from wagers because the list of people to wait for is different depending on context
22 | // e.g. for Double we only need to wait for 1 player, for final we have to wait for everyone
23 | waitingForWager: undefined as Record | undefined,
24 | };
25 | };
26 |
27 | export const getGameState = (
28 | options: {
29 | epNum?: string;
30 | airDate?: string;
31 | info?: string;
32 | answerTimeout?: number;
33 | finalTimeout?: number;
34 | allowMultipleCorrect?: boolean;
35 | host?: string;
36 | enableAIJudge?: boolean;
37 | },
38 | jeopardy?: RawQuestion[],
39 | double?: RawQuestion[],
40 | final?: RawQuestion[],
41 | ) => {
42 | return {
43 | jeopardy,
44 | double,
45 | final,
46 | answers: {} as Record,
47 | wagers: {} as Record,
48 | board: {} as { [key: string]: RawQuestion },
49 | public: {
50 | serverTime: Date.now(),
51 | epNum: options.epNum,
52 | airDate: options.airDate,
53 | info: options.info,
54 | board: {} as { [key: string]: Question },
55 | scores: {} as Record, // player scores
56 | round: '', // jeopardy or double or final
57 | picker: undefined as string | undefined, // If null let anyone pick, otherwise last correct answer
58 | // below is populated in emitstate from settings
59 | host: undefined as string | undefined,
60 | enableAIJudge: false,
61 | enableAIVoices: undefined as string | undefined,
62 | ...getPerQuestionState(),
63 | },
64 | };
65 | };
66 | export type PublicGameState = ReturnType['public'];
--------------------------------------------------------------------------------
/server/hash.ts:
--------------------------------------------------------------------------------
1 | // Extremely simple function to convert a string to a number for bucketing
2 | // Will return the same value for the same letters, regardless of order (collisions very likely)
3 | export function hashString(input: string) {
4 | var hash = 0;
5 | for (var i = 0; i < input.length; i++) {
6 | var charCode = input.charCodeAt(i);
7 | hash += charCode;
8 | }
9 | return hash;
10 | }
11 |
12 | // An improved alternative:
13 | // cyrb53 (c) 2018 bryc (github.com/bryc). License: Public domain. Attribution appreciated.
14 | // A fast and simple 64-bit (or 53-bit) string hash function with decent collision resistance.
15 | // Largely inspired by MurmurHash2/3, but with a focus on speed/simplicity.
16 | // See https://stackoverflow.com/questions/7616461/generate-a-hash-from-string-in-javascript/52171480#52171480
17 | // https://github.com/bryc/code/blob/master/jshash/experimental/cyrb53.js
18 | export const cyrb53 = (str: string, seed = 0) => {
19 | let h1 = 0xdeadbeef ^ seed,
20 | h2 = 0x41c6ce57 ^ seed;
21 | for (let i = 0, ch; i < str.length; i++) {
22 | ch = str.charCodeAt(i);
23 | h1 = Math.imul(h1 ^ ch, 2654435761);
24 | h2 = Math.imul(h2 ^ ch, 1597334677);
25 | }
26 | h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507);
27 | h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909);
28 | h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507);
29 | h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909);
30 |
31 | return 4294967296 * (2097151 & h2) + (h1 >>> 0);
32 | };
33 | // If we want all 64 bits of the output:
34 | /*
35 | return [h2>>>0, h1>>>0];
36 | // or
37 | return (h2>>>0).toString(16).padStart(8,0)+(h1>>>0).toString(16).padStart(8,0);
38 | // or
39 | return 4294967296n * BigInt(h2) + BigInt(h1);
40 | */
41 |
--------------------------------------------------------------------------------
/server/jData.ts:
--------------------------------------------------------------------------------
1 | import { gunzipSync } from 'zlib';
2 | import fs from 'fs';
3 | import config from './config';
4 |
5 | let qs = 0;
6 | let eps = 0;
7 | // On boot, start with the initial data included in repo
8 | console.time('load');
9 | let jData = JSON.parse(gunzipSync(fs.readFileSync('./jeopardy.json.gz')).toString());
10 | updateJDataStats();
11 | console.timeEnd('load');
12 | console.log('loaded %d episodes', Object.keys(jData).length);
13 | let etag: string | null = null;
14 |
15 | // Periodically refetch the latest episode data and replace it in memory
16 | setInterval(refreshEpisodes, 24 * 60 * 60 * 1000);
17 | refreshEpisodes();
18 |
19 | async function refreshEpisodes() {
20 | if (config.NODE_ENV === 'development') {
21 | return;
22 | }
23 | console.time('reload');
24 | try {
25 | const response = await fetch(
26 | 'https://github.com/howardchung/j-archive-parser/raw/release/jeopardy.json.gz',
27 | );
28 | const newEtag = response.headers.get('etag');
29 | console.log(newEtag, etag);
30 | if (newEtag !== etag) {
31 | const arrayBuf = await response.arrayBuffer();
32 | const buf = Buffer.from(arrayBuf);
33 | jData = JSON.parse(gunzipSync(buf).toString());
34 | updateJDataStats();
35 | etag = newEtag;
36 | }
37 | } catch (e) {
38 | console.log(e);
39 | }
40 | console.timeEnd('reload');
41 | }
42 |
43 | function updateJDataStats() {
44 | console.time('count');
45 | qs = 0;
46 | eps = 0;
47 | Object.keys(jData).forEach(key => {
48 | eps += 1;
49 | qs += jData[key].jeopardy.length;
50 | qs += jData[key].double.length;
51 | qs + jData[key].final.length;
52 | });
53 | console.timeEnd('count');
54 | }
55 |
56 | export function getJData() {
57 | return jData;
58 | }
59 |
60 | export function getJDataStats() {
61 | return { qs, eps };
62 | }
--------------------------------------------------------------------------------
/server/moniker.ts:
--------------------------------------------------------------------------------
1 | import fs from 'fs';
2 |
3 | let adjectives = fs
4 | .readFileSync(process.cwd() + '/words/adjectives.txt')
5 | .toString()
6 | .split('\n');
7 | const nouns = fs
8 | .readFileSync(process.cwd() + '/words/nouns.txt')
9 | .toString()
10 | .split('\n');
11 | const verbs = fs
12 | .readFileSync(process.cwd() + '/words/verbs.txt')
13 | .toString()
14 | .split('\n');
15 | const randomElement = (array: string[]) =>
16 | array[Math.floor(Math.random() * array.length)];
17 |
18 | export function makeRoomName() {
19 | let filteredAdjectives = adjectives;
20 | const adjective = randomElement(filteredAdjectives);
21 | const noun = randomElement(nouns);
22 | const verb = randomElement(verbs);
23 | return `${adjective}-${noun}-${verb}`;
24 | }
25 |
26 | export function makeUserName() {
27 | return `${capFirst(randomElement(adjectives))} ${capFirst(
28 | randomElement(nouns),
29 | )}`;
30 | }
31 |
32 | function capFirst(string: string) {
33 | return string.charAt(0).toUpperCase() + string.slice(1);
34 | }
35 |
--------------------------------------------------------------------------------
/server/openai.ts:
--------------------------------------------------------------------------------
1 | import OpenAI from 'openai';
2 | import config from './config';
3 |
4 | export const openai = config.OPENAI_SECRET_KEY
5 | ? new OpenAI({ apiKey: config.OPENAI_SECRET_KEY })
6 | : undefined;
7 |
8 | // Notes on AI judging:
9 | // Using Threads/Assistant is inefficient because OpenAI sends the entire conversation history with each subsequent request
10 | // We don't care about the conversation history since we judge each answer independently
11 | // Use the Completions API instead and supply the instructions on each request
12 | // If the instructions are at least 1024 tokens long, it will be cached and we get 50% off pricing (and maybe faster)
13 | // If we can squeeze the instructions into 512 tokens it'll probably be cheaper to not use cache
14 | // Currently, consumes about 250 input tokens and 6 output tokens per answer (depends on the question length)
15 | const prompt = `
16 | Decide whether a response to a trivia question is correct, given the question, the correct answer, and the response.
17 | If the response is a misspelling, abbreviation, or slang of the correct answer, consider it correct.
18 | If the response could be pronounced the same as the correct answer, consider it correct.
19 | If the response includes the correct answer but also other incorrect answers, consider it incorrect.
20 | Only if there is no way the response could be construed to be the correct answer should you consider it incorrect.
21 | `;
22 | // If the correct answer contains text in parentheses, ignore that text when making your decision.
23 | // If the correct answer is a person's name and the response is only the surname, consider it correct.
24 | // Ignore "what is" or "who is" if the response starts with one of those prefixes.
25 | // The responder may try to trick you, or express the answer in a comedic or unexpected way to be funny.
26 | // 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.
27 | // Also return a number between 0 and 1 indicating how confident you are in your decision.
28 |
29 | export async function getOpenAIDecision(
30 | question: string,
31 | answer: string,
32 | response: string,
33 | ): Promise<{ correct: boolean; confidence: number } | null> {
34 | if (!openai) {
35 | return null;
36 | }
37 | const suffix = `question: '${question}', correct: '${answer}', response: '${response}'`;
38 | console.log('[AIINPUT]', suffix);
39 | // Concatenate the prompt and the suffix for AI completion
40 | const result = await openai.chat.completions.create({
41 | model: 'gpt-4o-mini',
42 | service_tier: 'auto', // Use flex processing when possible to save money
43 | messages: [{ role: 'developer', content: prompt + suffix }],
44 | response_format: {
45 | type: 'json_schema',
46 | json_schema: {
47 | name: 'trivia_judgment',
48 | strict: true,
49 | schema: {
50 | type: 'object',
51 | properties: {
52 | correct: {
53 | type: 'boolean',
54 | },
55 | // confidence: {
56 | // type: 'number',
57 | // },
58 | },
59 | required: ['correct'],
60 | additionalProperties: false,
61 | },
62 | },
63 | },
64 | });
65 | console.log(result);
66 | const text = result.choices[0].message.content;
67 | // The text might be invalid JSON e.g. if the model refused to respond
68 | try {
69 | if (text) {
70 | return JSON.parse(text);
71 | }
72 | } catch (e) {
73 | console.log(e);
74 | }
75 | return null;
76 | }
77 |
--------------------------------------------------------------------------------
/server/redis.ts:
--------------------------------------------------------------------------------
1 | import Redis from 'ioredis';
2 | import config from './config';
3 |
4 | export let redis: Redis | undefined = undefined;
5 | if (config.REDIS_URL) {
6 | redis = new Redis(config.REDIS_URL);
7 | }
8 |
9 | export async function redisCount(prefix: string) {
10 | if (!redis) {
11 | return;
12 | }
13 | const key = `${prefix}:${getStartOfHour()}`;
14 | await redis.incr(key);
15 | await redis.expireat(key, getStartOfHour() + 86400 * 1000);
16 | }
17 |
18 | export async function getRedisCountDay(prefix: string) {
19 | if (!redis) {
20 | return;
21 | }
22 | // Get counts for last 24 hour keys (including current partial hour)
23 | const keyArr = [];
24 | for (let i = 0; i < 24; i += 1) {
25 | keyArr.push(`${prefix}:${getStartOfHour() - i * 3600 * 1000}`);
26 | }
27 | const values = await redis.mget(...keyArr);
28 | return values.reduce((a, b) => (Number(a) || 0) + (Number(b) || 0), 0);
29 | }
30 |
31 | export async function getRedisCountHour(prefix: string) {
32 | if (!redis) {
33 | return;
34 | }
35 | // Get counts for previous full hour
36 | const value = await redis.get(`${prefix}:${getStartOfHour() - 3600 * 1000}`);
37 | return Number(value);
38 | }
39 |
40 | export async function redisCountDistinct(prefix: string, item: string) {
41 | if (!redis) {
42 | return;
43 | }
44 | const key = `${prefix}:${getStartOfHour()}`;
45 | await redis.pfadd(key, item);
46 | await redis.expireat(key, getStartOfHour() + 86400 * 1000);
47 | }
48 |
49 | export async function getRedisCountDayDistinct(prefix: string) {
50 | if (!redis) {
51 | return;
52 | }
53 | // Get counts for last 24 hour keys (including current partial hour)
54 | const keyArr = [];
55 | for (let i = 0; i < 24; i += 1) {
56 | keyArr.push(`${prefix}:${getStartOfHour() - i * 3600 * 1000}`);
57 | }
58 | return await redis.pfcount(...keyArr);
59 | }
60 |
61 | export async function getRedisCountHourDistinct(prefix: string) {
62 | if (!redis) {
63 | return;
64 | }
65 | // Get counts for previous full hour
66 | return await redis.pfcount(`${prefix}:${getStartOfHour() - 3600 * 1000}`);
67 | }
68 |
69 | function getStartOfDay() {
70 | const now = Date.now();
71 | return now - (now % 86400000);
72 | }
73 |
74 | function getStartOfHour() {
75 | const now = Date.now();
76 | return now - (now % 3600000);
77 | }
78 |
79 | function getStartOfMinute() {
80 | const now = Date.now();
81 | return now - (now % 60000);
82 | }
83 |
84 |
--------------------------------------------------------------------------------
/server/room.ts:
--------------------------------------------------------------------------------
1 | import { Socket, Server } from 'socket.io';
2 | import Papa from 'papaparse';
3 | import { redis, redisCount } from './redis';
4 | import { genAITextToSpeech } from './aivoice';
5 | import { getOpenAIDecision, openai } from './openai';
6 | import config from './config';
7 | import { getGameState, getPerQuestionState } from './gamestate';
8 | import { getJData } from './jData';
9 |
10 | export class Room {
11 | // Serialized state
12 | public roster: User[] = [];
13 | public clientIds: Record = {};
14 | private chat: ChatMessage[] = [];
15 | public creationTime: Date = new Date();
16 | public jpd: ReturnType = getGameState({}, [], [], []);
17 | public settings = {
18 | answerTimeout: 20000,
19 | finalTimeout: 30000,
20 | host: undefined as string | undefined,
21 | allowMultipleCorrect: false,
22 | enableAIJudge: false,
23 | enableAIVoices: undefined as string | undefined,
24 | };
25 |
26 | // Unserialized state
27 | private io: Server;
28 | public roomId: string;
29 | // Note: snapshot is not persisted so undo is not possible if server restarts
30 | private jpdSnapshot: ReturnType | undefined;
31 | private undoActivated: boolean | undefined = undefined;
32 | private aiJudged: boolean | undefined = undefined;
33 | private playClueTimeout: NodeJS.Timeout =
34 | undefined as unknown as NodeJS.Timeout;
35 | private questionAnswerTimeout: NodeJS.Timeout =
36 | undefined as unknown as NodeJS.Timeout;
37 | private wagerTimeout: NodeJS.Timeout = undefined as unknown as NodeJS.Timeout;
38 | public cleanupInterval: NodeJS.Timeout = undefined as unknown as NodeJS.Timeout;
39 | public lastUpdateTime: Date = new Date();
40 |
41 | constructor(
42 | io: Server,
43 | roomId: string,
44 | roomData?: string | null | undefined,
45 | ) {
46 | this.io = io;
47 | this.roomId = roomId;
48 |
49 | if (roomData) {
50 | this.deserialize(roomData);
51 | }
52 |
53 | this.cleanupInterval = setInterval(() => {
54 | // Remove players that have been disconnected for a long time
55 | const beforeLength = this.getAllPlayers();
56 | const now = Date.now();
57 | this.roster = this.roster.filter(
58 | (p) => p.connected || now - p.disconnectTime < 60 * 60 * 1000,
59 | );
60 | const afterLength = this.getAllPlayers();
61 | if (beforeLength !== afterLength && this.getConnectedPlayers().length > 0) {
62 | this.sendRoster();
63 | }
64 | }, 30 * 60 * 1000);
65 |
66 | io.of(roomId).on('connection', (socket: Socket) => {
67 | this.jpd.public.scores[socket.id] = 0;
68 |
69 | const clientId = socket.handshake.query?.clientId as string;
70 | // clientid map keeps track of the unique clients we've seen
71 | // if we saw this ID already, do the reconnect logic (transfer state)
72 | // The list is persisted, so if the server reboots, all clients reconnect and should have state restored
73 | if (this.clientIds[clientId]) {
74 | const newId = socket.id;
75 | const oldId = this.clientIds[clientId];
76 | this.handleReconnect(newId, oldId);
77 | }
78 | if (!this.getAllPlayers().find((p) => p.id === socket.id)) {
79 | // New client joining, add to roster
80 | this.roster.push({
81 | id: socket.id,
82 | name: undefined,
83 | connected: true,
84 | disconnectTime: 0,
85 | });
86 | }
87 | this.clientIds[clientId] = socket.id;
88 |
89 | this.sendState();
90 | this.sendRoster();
91 | socket.emit('chatinit', this.chat);
92 |
93 | socket.on('CMD:name', (data: string) => {
94 | if (!data) {
95 | return;
96 | }
97 | if (data && data.length > 100) {
98 | return;
99 | }
100 | const target = this.getAllPlayers().find((p) => p.id === socket.id);
101 | if (target) {
102 | target.name = data;
103 | this.sendRoster();
104 | }
105 | });
106 | // socket.on('JPD:cmdIntro', () => {
107 | // this.io.of(this.roomId).emit('JPD:playIntro');
108 | // });
109 | socket.on('JPD:start', (options, data) => {
110 | if (data && data.length > 1000000) {
111 | return;
112 | }
113 | if (typeof options !== 'object') {
114 | return;
115 | }
116 | this.loadEpisode(socket, options, data);
117 | });
118 | socket.on('JPD:pickQ', (id: string) => {
119 | if (this.settings.host && socket.id !== this.settings.host) {
120 | return;
121 | }
122 | if (
123 | this.jpd.public.picker &&
124 | // If the picker is disconnected, allow anyone to pick to avoid blocking game
125 | this.getConnectedPlayers().find(
126 | (p) => p.id === this.jpd.public.picker,
127 | ) &&
128 | this.jpd.public.picker !== socket.id
129 | ) {
130 | return;
131 | }
132 | if (this.jpd.public.currentQ) {
133 | return;
134 | }
135 | if (!this.jpd.public.board[id]) {
136 | return;
137 | }
138 | this.jpd.public.currentQ = id;
139 | this.jpd.public.currentValue = this.jpd.public.board[id].value;
140 | // check if it's a daily double
141 | if (this.jpd.board[id].dd && !this.settings.allowMultipleCorrect) {
142 | // if it is, don't show it yet, we need to collect wager info based only on category
143 | this.jpd.public.currentDailyDouble = true;
144 | this.jpd.public.dailyDoublePlayer = socket.id;
145 | this.jpd.public.waitingForWager = { [socket.id]: true };
146 | this.setWagerTimeout(this.settings.answerTimeout);
147 | // Autobuzz the player who picked the DD, all others pass
148 | // Note: if a player joins during wagering, they might not be marked as passed (submitted)
149 | // Currently client doesn't show the answer box because it checks for buzzed in players
150 | // But there's probably no server block on them submitting answers
151 | this.getActivePlayers().forEach((p) => {
152 | if (p.id === socket.id) {
153 | this.jpd.public.buzzes[p.id] = Date.now();
154 | } else {
155 | this.jpd.public.submitted[p.id] = true;
156 | }
157 | });
158 | this.io.of(this.roomId).emit('JPD:playDailyDouble');
159 | } else {
160 | // Put Q in public state
161 | this.jpd.public.board[this.jpd.public.currentQ].question =
162 | this.jpd.board[this.jpd.public.currentQ].q;
163 | this.triggerPlayClue();
164 | }
165 | // Undo no longer possible after next question is picked
166 | this.jpdSnapshot = undefined;
167 | this.undoActivated = undefined;
168 | this.aiJudged = undefined;
169 | this.sendState();
170 | });
171 | socket.on('JPD:buzz', () => {
172 | if (!this.jpd.public.canBuzz) {
173 | return;
174 | }
175 | if (this.jpd.public.buzzes[socket.id]) {
176 | return;
177 | }
178 | this.jpd.public.buzzes[socket.id] = Date.now();
179 | this.sendState();
180 | });
181 | socket.on('JPD:answer', (question, answer) => {
182 | if (question !== this.jpd.public.currentQ) {
183 | // Not submitting for right question
184 | return;
185 | }
186 | if (!this.jpd.public.questionEndTS) {
187 | // Time was already up
188 | return;
189 | }
190 | if (answer && answer.length > 10000) {
191 | // Answer too long
192 | return;
193 | }
194 | console.log('[ANSWER]', socket.id, question, answer);
195 | if (answer) {
196 | this.jpd.answers[socket.id] = answer;
197 | }
198 | this.jpd.public.submitted[socket.id] = true;
199 | this.sendState();
200 | if (
201 | this.jpd.public.round !== 'final' &&
202 | // If a player disconnects, don't wait for their answer
203 | this.getConnectedPlayers().every(
204 | (p) => p.id in this.jpd.public.submitted,
205 | )
206 | ) {
207 | this.revealAnswer();
208 | }
209 | });
210 |
211 | socket.on('JPD:wager', (wager) => this.submitWager(socket.id, wager));
212 | socket.on('JPD:judge', (data) => this.doHumanJudge(socket, data));
213 | socket.on('JPD:bulkJudge', (data) => {
214 | if (!data) {
215 | return;
216 | }
217 | // Check if the next player to be judged is in the input data
218 | // If so, doJudge for that player
219 | // Check if we advanced to the next question, otherwise keep doing
220 | let count = 0;
221 | while (this.jpd.public.currentJudgeAnswer !== undefined && count <= data.length) {
222 | // The bulkjudge may not contain all decisions. Stop if we did as many decisions as the input data
223 | count += 1;
224 | console.log('[BULKJUDGE]', count, data.length);
225 | const id = this.jpd.public.currentJudgeAnswer;
226 | const match = data.find((d: any) => d.id === id);
227 | if (match) {
228 | this.doHumanJudge(socket, match);
229 | } else {
230 | // Player to be judged isn't in the input
231 | // Stop judging and revert to manual (or let the user resubmit, we should prevent duplicates)
232 | break;
233 | }
234 | }
235 | });
236 | socket.on('JPD:undo', () => {
237 | if (this.settings.host && socket.id !== this.settings.host) {
238 | // Not the host
239 | return;
240 | }
241 | // Reset the game state to the last snapshot
242 | // Snapshot updates at each revealAnswer
243 | if (this.jpdSnapshot) {
244 | redisCount('undo');
245 | if (this.aiJudged) {
246 | redisCount('aiUndo');
247 | this.aiJudged = undefined;
248 | }
249 | this.undoActivated = true;
250 | this.jpd = JSON.parse(JSON.stringify(this.jpdSnapshot));
251 | this.advanceJudging(false);
252 | this.sendState();
253 | }
254 | });
255 | socket.on('JPD:skipQ', () => {
256 | if (this.jpd.public.canNextQ) {
257 | // We are in the post-judging phase and can move on
258 | this.nextQuestion();
259 | }
260 | });
261 | socket.on('JPD:enableAiJudge', (enable: boolean) => {
262 | this.settings.enableAIJudge = Boolean(enable);
263 | this.sendState();
264 | // optional: If we're in the judging phase, trigger the AI judge here
265 | // That way we can decide to use AI judge after the first answer has already been revealed
266 | });
267 | socket.on('CMD:chat', (data: string) => {
268 | if (data && data.length > 10000) {
269 | // TODO add some validation on client side too so we don't just drop long messages
270 | return;
271 | }
272 | if (data === '/clear') {
273 | this.chat.length = 0;
274 | io.of(roomId).emit('chatinit', this.chat);
275 | return;
276 | }
277 | if (data.startsWith('/aivoices')) {
278 | const rvcServer =
279 | data.split(' ')[1] ?? 'https://azure.howardchung.net/rvc';
280 | this.pregenAIVoices(rvcServer);
281 | }
282 | const sender = this.getAllPlayers().find((p) => p.id === socket.id);
283 | const chatMsg = { id: socket.id, name: sender?.name, msg: data };
284 | this.addChatMessage(socket, chatMsg);
285 | });
286 | socket.on('disconnect', () => {
287 | if (this.jpd && this.jpd.public) {
288 | // If player who needs to submit wager leaves, submit 0
289 | if (
290 | this.jpd.public.waitingForWager &&
291 | this.jpd.public.waitingForWager[socket.id]
292 | ) {
293 | this.submitWager(socket.id, 0);
294 | }
295 | }
296 | // Mark the user disconnected
297 | let target = this.getAllPlayers().find((p) => p.id === socket.id);
298 | if (target) {
299 | target.connected = false;
300 | target.disconnectTime = Date.now();
301 | }
302 | this.sendRoster();
303 | });
304 | });
305 | }
306 |
307 | serialize = () => {
308 | return JSON.stringify({
309 | chat: this.chat,
310 | clientIds: this.clientIds,
311 | roster: this.roster,
312 | creationTime: this.creationTime,
313 | jpd: this.jpd,
314 | settings: this.settings,
315 | });
316 | };
317 |
318 | deserialize = (roomData: string) => {
319 | const roomObj = JSON.parse(roomData);
320 | if (roomObj.chat) {
321 | this.chat = roomObj.chat;
322 | }
323 | if (roomObj.clientIds) {
324 | this.clientIds = roomObj.clientIds;
325 | }
326 | if (roomObj.creationTime) {
327 | this.creationTime = new Date(roomObj.creationTime);
328 | }
329 | if (roomObj.roster) {
330 | // Reset connected state to false, reconnects will update it again
331 | this.roster = roomObj.roster.map((p: User) => ({...p, connected: false}));
332 | }
333 | if (roomObj.jpd && roomObj.jpd.public) {
334 | const gameData = roomObj.jpd;
335 | this.jpd = gameData;
336 | // Reconstruct the timeouts from the saved state
337 | if (this.jpd.public.questionEndTS) {
338 | const remaining = this.jpd.public.questionEndTS - Date.now();
339 | this.setQuestionAnswerTimeout(remaining);
340 | }
341 | if (this.jpd.public.playClueEndTS) {
342 | const remaining = this.jpd.public.playClueEndTS - Date.now();
343 | this.setPlayClueTimeout(remaining);
344 | }
345 | if (this.jpd.public.wagerEndTS) {
346 | const remaining = this.jpd.public.wagerEndTS - Date.now();
347 | this.setWagerTimeout(remaining, this.jpd.public.wagerEndTS);
348 | }
349 | }
350 | if (roomObj.settings) {
351 | this.settings = roomObj.settings;
352 | }
353 | };
354 |
355 | saveRoom = async () => {
356 | const roomData = this.serialize();
357 | const key = this.roomId;
358 | await redis?.setex(key, 24 * 60 * 60, roomData);
359 | if (config.permaRooms.includes(key)) {
360 | await redis?.persist(key);
361 | }
362 | this.lastUpdateTime = new Date();
363 | redisCount('saves');
364 | }
365 |
366 | addChatMessage = (socket: Socket | undefined, chatMsg: any) => {
367 | const chatWithTime: ChatMessage = {
368 | ...chatMsg,
369 | timestamp: new Date().toISOString(),
370 | };
371 | this.chat.push(chatWithTime);
372 | this.chat = this.chat.splice(-100);
373 | this.io.of(this.roomId).emit('REC:chat', chatWithTime);
374 | this.saveRoom();
375 | };
376 |
377 | sendState = () => {
378 | this.jpd.public.serverTime = Date.now();
379 | // Copy values over from settings before each send
380 | this.jpd.public.host = this.settings.host;
381 | this.jpd.public.enableAIJudge = this.settings.enableAIJudge;
382 | this.jpd.public.enableAIVoices = this.settings.enableAIVoices;
383 | this.io.of(this.roomId).emit('JPD:state', this.jpd.public);
384 | this.saveRoom();
385 | }
386 |
387 | sendRoster = () => {
388 | // Sort by score and resend the list of players to everyone
389 | this.roster.sort(
390 | (a, b) =>
391 | (this.jpd.public?.scores[b.id] || 0) -
392 | (this.jpd.public?.scores[a.id] || 0),
393 | );
394 | this.io.of(this.roomId).emit('roster', this.roster);
395 | this.saveRoom();
396 | }
397 |
398 | getConnectedPlayers = () => {
399 | // Returns players that are currently connected and not spectators
400 | return this.roster.filter((p) => p.connected);
401 | };
402 |
403 | getActivePlayers = () => {
404 | // Returns all players not marked as spectator (includes disconnected)
405 | // Currently just returns all players
406 | // In the future we might want to ignore spectators
407 | return this.roster;
408 | };
409 |
410 | getAllPlayers = () => {
411 | // Return all players regardless of connection state or spectator
412 | return this.roster;
413 | };
414 |
415 | handleReconnect = (newId: string, oldId: string) => {
416 | console.log('[RECONNECT] transfer %s to %s', oldId, newId);
417 | // Update the roster with the new ID and connected state
418 | const target = this.getAllPlayers().find((p) => p.id === oldId);
419 | if (target) {
420 | target.id = newId;
421 | target.connected = true;
422 | target.disconnectTime = 0;
423 | }
424 | if (this.jpd.public.scores?.[oldId]) {
425 | this.jpd.public.scores[newId] = this.jpd.public.scores[oldId];
426 | delete this.jpd.public.scores[oldId];
427 | }
428 | if (this.jpd.public.buzzes?.[oldId]) {
429 | this.jpd.public.buzzes[newId] = this.jpd.public.buzzes[oldId];
430 | delete this.jpd.public.buzzes[oldId];
431 | }
432 | if (this.jpd.public.judges?.[oldId]) {
433 | this.jpd.public.judges[newId] = this.jpd.public.judges[oldId];
434 | delete this.jpd.public.judges[oldId];
435 | }
436 | if (this.jpd.public.submitted?.[oldId]) {
437 | this.jpd.public.submitted[newId] = this.jpd.public.submitted[oldId];
438 | delete this.jpd.public.submitted[oldId];
439 | }
440 | if (this.jpd.public.answers?.[oldId]) {
441 | this.jpd.public.answers[newId] = this.jpd.public.answers[oldId];
442 | delete this.jpd.public.answers[oldId];
443 | }
444 | if (this.jpd.public.wagers?.[oldId]) {
445 | this.jpd.public.wagers[newId] = this.jpd.public.wagers[oldId];
446 | delete this.jpd.public.wagers[oldId];
447 | }
448 | // Note: two copies of answers and wagers exist, a public and non-public version, so we need to copy both
449 | // Alternatively, we can just have some state to tracks whether to emit the answers and wagers and keep both in public only
450 | if (this.jpd.answers?.[oldId]) {
451 | this.jpd.answers[newId] = this.jpd.answers[oldId];
452 | delete this.jpd.answers[oldId];
453 | }
454 | if (this.jpd.wagers?.[oldId]) {
455 | this.jpd.wagers[newId] = this.jpd.wagers[oldId];
456 | delete this.jpd.wagers[oldId];
457 | }
458 | if (this.jpd.public.waitingForWager?.[oldId]) {
459 | // Current behavior is to submit wager 0 if disconnecting
460 | // So there should be no state to transfer
461 | this.jpd.public.waitingForWager[newId] = true;
462 | delete this.jpd.public.waitingForWager[oldId];
463 | }
464 | if (this.jpd.public.currentJudgeAnswer === oldId) {
465 | this.jpd.public.currentJudgeAnswer = newId;
466 | }
467 | if (this.jpd.public.dailyDoublePlayer === oldId) {
468 | this.jpd.public.dailyDoublePlayer = newId;
469 | }
470 | if (this.jpd.public.picker === oldId) {
471 | this.jpd.public.picker = newId;
472 | }
473 | if (this.settings.host === oldId) {
474 | this.settings.host = newId;
475 | }
476 | }
477 |
478 | loadEpisode = (socket: Socket, options: GameOptions, custom: string) => {
479 | let {
480 | number,
481 | filter,
482 | answerTimeout,
483 | finalTimeout,
484 | makeMeHost,
485 | allowMultipleCorrect,
486 | enableAIJudge,
487 | } = options;
488 | console.log('[LOADEPISODE]', number, filter, Boolean(custom));
489 | let loadedData = null;
490 | if (custom) {
491 | try {
492 | const parse = Papa.parse(custom, { header: true });
493 | const typed = [];
494 | let round = '';
495 | let cat = '';
496 | let curX = 0;
497 | let curY = 0;
498 | for (let i = 0; i < parse.data.length; i++) {
499 | const d = parse.data[i];
500 | if (round !== d.round) {
501 | // Reset x and y to 1
502 | curX = 1;
503 | curY = 1;
504 | } else if (cat !== d.cat) {
505 | // Increment x, reset y to 1, new category
506 | curX += 1;
507 | curY = 1;
508 | } else {
509 | curY += 1;
510 | }
511 | round = d.round;
512 | cat = d.cat;
513 | let multiplier = 1;
514 | if (round === 'double') {
515 | multiplier = 2;
516 | } else if (round === 'final') {
517 | multiplier = 0;
518 | }
519 | if (d.q && d.a) {
520 | typed.push({
521 | round: d.round,
522 | cat: d.cat,
523 | q: d.q,
524 | a: d.a,
525 | dd: d.dd?.toLowerCase() === 'true',
526 | val: curY * 200 * multiplier,
527 | x: curX,
528 | y: curY,
529 | });
530 | }
531 | }
532 | loadedData = {
533 | airDate: new Date().toISOString().split('T')[0],
534 | epNum: 'Custom',
535 | jeopardy: typed.filter((d: any) => d.round === 'jeopardy'),
536 | double: typed.filter((d: any) => d.round === 'double'),
537 | final: typed.filter((d: any) => d.round === 'final'),
538 | };
539 | redisCount('customGames');
540 | } catch (e) {
541 | console.warn(e);
542 | }
543 | } else {
544 | const jData = getJData();
545 | // Load question data into game
546 | let nums = Object.keys(jData);
547 | if (filter) {
548 | // Only load episodes with info matching the filter: kids, teen, college etc.
549 | nums = nums.filter(
550 | (num) =>
551 | (jData as any)[num].info && (jData as any)[num].info === filter,
552 | );
553 | }
554 | if (number === 'ddtest') {
555 | loadedData = jData['8000'];
556 | loadedData['jeopardy'] = loadedData['jeopardy'].filter(
557 | (q: any) => q.dd,
558 | );
559 | } else if (number === 'finaltest') {
560 | loadedData = jData['8000'];
561 | } else {
562 | if (!number) {
563 | // Random an episode
564 | number = nums[Math.floor(Math.random() * nums.length)];
565 | }
566 | loadedData = (jData as any)[number];
567 | }
568 | }
569 | if (loadedData) {
570 | redisCount('newGames');
571 | const { epNum, airDate, info, jeopardy, double, final } = loadedData;
572 | this.jpd = getGameState(
573 | {
574 | epNum,
575 | airDate,
576 | info,
577 | },
578 | jeopardy,
579 | double,
580 | final,
581 | );
582 | this.jpdSnapshot = undefined;
583 | this.settings.host = makeMeHost ? socket.id : undefined;
584 | if (allowMultipleCorrect) {
585 | this.settings.allowMultipleCorrect = allowMultipleCorrect;
586 | }
587 | if (enableAIJudge) {
588 | this.settings.enableAIJudge = enableAIJudge;
589 | }
590 | if (Number(finalTimeout)) {
591 | this.settings.finalTimeout = Number(finalTimeout) * 1000;
592 | }
593 | if (Number(answerTimeout)) {
594 | this.settings.answerTimeout = Number(answerTimeout) * 1000;
595 | }
596 | if (number === 'finaltest') {
597 | this.jpd.public.round = 'double';
598 | }
599 | this.nextRound();
600 | }
601 | }
602 |
603 | playCategories = () => {
604 | this.io.of(this.roomId).emit('JPD:playCategories');
605 | }
606 |
607 | resetAfterQuestion = () => {
608 | this.jpd.answers = {};
609 | this.jpd.wagers = {};
610 | clearTimeout(this.playClueTimeout);
611 | clearTimeout(this.questionAnswerTimeout);
612 | clearTimeout(this.wagerTimeout);
613 | this.jpd.public = { ...this.jpd.public, ...getPerQuestionState() };
614 | // Overwrite any other picker settings if there's a host
615 | if (this.settings.host) {
616 | this.jpd.public.picker = this.settings.host;
617 | }
618 | }
619 |
620 | nextQuestion = () => {
621 | // Show the correct answer in the game log
622 | this.addChatMessage(undefined, {
623 | id: '',
624 | name: 'System',
625 | cmd: 'answer',
626 | msg: this.jpd.public.currentAnswer,
627 | });
628 | // Scores have updated so resend sorted player list
629 | this.sendRoster();
630 | // Reset question state
631 | delete this.jpd.public.board[this.jpd.public.currentQ];
632 | this.resetAfterQuestion();
633 | if (Object.keys(this.jpd.public.board).length === 0) {
634 | this.nextRound();
635 | } else {
636 | this.sendState();
637 | // TODO may want to introduce some delay here to make sure our state is updated before reading selection
638 | this.io.of(this.roomId).emit('JPD:playMakeSelection');
639 | }
640 | }
641 |
642 | nextRound = () => {
643 | this.resetAfterQuestion();
644 | // host is made picker in resetAfterQuestion, so any picker changes here should be behind host check
645 | // advance round counter
646 | if (this.jpd.public.round === 'jeopardy') {
647 | this.jpd.public.round = 'double';
648 | // If double, person with lowest score is picker
649 | // Unless we are allowing multiple corrects or there's a host
650 | if (!this.settings.allowMultipleCorrect && !this.settings.host) {
651 | // Pick the lowest score out of the currently connected players
652 | // This is nlogn rather than n, but prob ok for small numbers of players
653 | const playersWithScores = this.getConnectedPlayers().map((p) => ({
654 | id: p.id,
655 | score: this.jpd.public.scores[p.id] || 0,
656 | }));
657 | playersWithScores.sort((a, b) => a.score - b.score);
658 | this.jpd.public.picker = playersWithScores[0]?.id;
659 | }
660 | } else if (this.jpd.public.round === 'double') {
661 | this.jpd.public.round = 'final';
662 | const now = Date.now();
663 | this.jpd.public.waitingForWager = {};
664 | // There's no picker for final. In host mode we set one above
665 | this.jpd.public.picker = undefined;
666 | // Ask all players for wager (including disconnected since they might come back)
667 | this.getActivePlayers().forEach((p) => {
668 | this.jpd.public.waitingForWager![p.id] = true;
669 | });
670 | this.setWagerTimeout(this.settings.finalTimeout);
671 | // autopick the question
672 | this.jpd.public.currentQ = '1_1';
673 | // autobuzz the players in ascending score order
674 | let playerIds = this.getActivePlayers().map((p) => p.id);
675 | playerIds.sort(
676 | (a, b) =>
677 | Number(this.jpd.public.scores[a] || 0) -
678 | Number(this.jpd.public.scores[b] || 0),
679 | );
680 | playerIds.forEach((pid) => {
681 | this.jpd.public.buzzes[pid] = now;
682 | });
683 | // Play the category sound
684 | this.io.of(this.roomId).emit('JPD:playRightanswer');
685 | } else if (this.jpd.public.round === 'final') {
686 | this.jpd.public.round = 'end';
687 | // Log the results
688 | const scores = Object.entries(this.jpd.public.scores);
689 | scores.sort((a, b) => b[1] - a[1]);
690 | const scoresNames = scores.map((score) => [
691 | this.getAllPlayers().find((p) => p.id === score[0])?.name,
692 | score[1],
693 | ]);
694 | redis?.lpush('jpd:results', JSON.stringify(scoresNames));
695 | } else {
696 | this.jpd.public.round = 'jeopardy';
697 | }
698 | if (
699 | this.jpd.public.round === 'jeopardy' ||
700 | this.jpd.public.round === 'double' ||
701 | this.jpd.public.round === 'final'
702 | ) {
703 | this.jpd.board = constructBoard((this.jpd as any)[this.jpd.public.round]);
704 | this.jpd.public.board = constructPublicBoard(
705 | (this.jpd as any)[this.jpd.public.round],
706 | );
707 | if (Object.keys(this.jpd.public.board).length === 0) {
708 | this.nextRound();
709 | }
710 | }
711 | this.sendState();
712 | if (
713 | this.jpd.public.round === 'jeopardy' ||
714 | this.jpd.public.round === 'double'
715 | ) {
716 | this.playCategories();
717 | }
718 | }
719 |
720 | unlockAnswer = (durationMs: number) => {
721 | this.jpd.public.questionEndTS = Date.now() + durationMs;
722 | this.setQuestionAnswerTimeout(durationMs);
723 | }
724 |
725 | setQuestionAnswerTimeout = (durationMs: number) => {
726 | this.questionAnswerTimeout = setTimeout(() => {
727 | if (this.jpd.public.round !== 'final') {
728 | this.io.of(this.roomId).emit('JPD:playTimesUp');
729 | }
730 | this.revealAnswer();
731 | }, durationMs);
732 | }
733 |
734 | revealAnswer = () => {
735 | clearTimeout(this.questionAnswerTimeout);
736 | this.jpd.public.questionEndTS = 0;
737 |
738 | // Add empty answers for anyone who buzzed but didn't submit anything
739 | Object.keys(this.jpd.public.buzzes).forEach((key) => {
740 | if (!this.jpd.answers[key]) {
741 | this.jpd.answers[key] = '';
742 | }
743 | });
744 | this.jpd.public.canBuzz = false;
745 | // Show everyone's answers
746 | this.jpd.public.answers = { ...this.jpd.answers };
747 | this.jpd.public.currentAnswer = this.jpd.board[this.jpd.public.currentQ]?.a;
748 | this.jpdSnapshot = JSON.parse(JSON.stringify(this.jpd));
749 | this.advanceJudging(false);
750 | this.sendState();
751 | }
752 |
753 | advanceJudging = (skipRemaining: boolean) => {
754 | if (this.jpd.public.currentJudgeAnswerIndex === undefined) {
755 | this.jpd.public.currentJudgeAnswerIndex = 0;
756 | } else {
757 | this.jpd.public.currentJudgeAnswerIndex += 1;
758 | }
759 | this.jpd.public.currentJudgeAnswer = Object.keys(this.jpd.public.buzzes)[
760 | this.jpd.public.currentJudgeAnswerIndex
761 | ];
762 | // Either we picked a correct answer (in standard mode) or ran out of players to judge
763 | if (skipRemaining || this.jpd.public.currentJudgeAnswer === undefined) {
764 | this.jpd.public.canNextQ = true;
765 | }
766 | if (this.jpd.public.currentJudgeAnswer) {
767 | // In Final, reveal one at a time rather than all at once (for dramatic purposes)
768 | // Note: Looks like we just bulk reveal answers elsewhere, so this is just wagers
769 | this.jpd.public.wagers[this.jpd.public.currentJudgeAnswer] =
770 | this.jpd.wagers[this.jpd.public.currentJudgeAnswer];
771 | this.jpd.public.answers[this.jpd.public.currentJudgeAnswer] =
772 | this.jpd.answers[this.jpd.public.currentJudgeAnswer];
773 | }
774 | // Undo snapshots the current state of jpd
775 | // So if a player has reconnected since with a new ID the ID from buzzes might not be there anymore
776 | // If so, we skip that answer (not optimal but easiest)
777 | // TODO To fix this we probably have to use clientId instead of socket id to index the submitted answers
778 | if (
779 | this.jpd.public.currentJudgeAnswer &&
780 | !this.getActivePlayers().find(
781 | (p) => p.id === this.jpd.public.currentJudgeAnswer,
782 | )
783 | ) {
784 | console.log(
785 | '[ADVANCEJUDGING] player not found, moving on:',
786 | this.jpd.public.currentJudgeAnswer,
787 | );
788 | this.advanceJudging(skipRemaining);
789 | return;
790 | }
791 | if (
792 | openai &&
793 | !this.jpd.public.canNextQ &&
794 | this.settings.enableAIJudge &&
795 | // Don't use AI if the user undid
796 | !this.undoActivated &&
797 | this.jpd.public.currentJudgeAnswer
798 | ) {
799 | // We don't await here since AI judging shouldn't block UI
800 | // But we want to trigger it whenever we move on to the next answer
801 | // The result might come back after we already manually judged, in that case we just log it and ignore
802 | this.doAiJudge({
803 | currentQ: this.jpd.public.currentQ,
804 | id: this.jpd.public.currentJudgeAnswer,
805 | });
806 | }
807 | }
808 |
809 | doAiJudge = async (data: { currentQ: string; id: string }) => {
810 | // count the number of automatic judges
811 | redisCount('aiJudge');
812 | // currentQ: The board coordinates of the current question, e.g. 1_3
813 | // id: socket id of the person being judged
814 | const { currentQ, id } = data;
815 | // The question text
816 | const q = this.jpd.board[currentQ]?.q ?? '';
817 | const a = this.jpd.public.currentAnswer ?? '';
818 | const response = this.jpd.public.answers[id];
819 | let correct: boolean | null = null;
820 | if (response === '') {
821 | // empty response is always wrong
822 | correct = false;
823 | redisCount('aiShortcut');
824 | } else if (response.toLowerCase().trim() === a.toLowerCase().trim()) {
825 | // exact match is always right
826 | correct = true;
827 | redisCount('aiShortcut');
828 | } else {
829 | // count the number of calls to chatgpt
830 | redisCount('aiChatGpt');
831 | try {
832 | const decision = await getOpenAIDecision(q, a, response);
833 | console.log('[AIDECISION]', id, q, a, response, decision);
834 | if (decision && decision.correct != null) {
835 | correct = decision.correct;
836 | } else {
837 | redisCount('aiRefuse');
838 | }
839 | // Log the AI decision to measure accuracy
840 | // If the user undoes and then chooses differently than AI, then that's a failed decision
841 | // 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
842 | // If undefined, AI refused to answer
843 | redis?.lpush(
844 | 'jpd:aiJudges',
845 | JSON.stringify({ q, a, response, correct: decision?.correct }),
846 | );
847 | redis?.ltrim('jpd:aiJudges', 0, 1000);
848 | } catch (e) {
849 | console.log(e);
850 | }
851 | }
852 | if (correct != null) {
853 | this.judgeAnswer(undefined, { currentQ, id, correct });
854 | }
855 | }
856 |
857 | doHumanJudge = (
858 | socket: Socket,
859 | data: { currentQ: string; id: string; correct: boolean | null },
860 | ) => {
861 | const success = this.judgeAnswer(socket, data);
862 | }
863 |
864 | judgeAnswer = (
865 | socket: Socket | undefined,
866 | {
867 | currentQ,
868 | id,
869 | correct,
870 | confidence,
871 | }: {
872 | currentQ: string;
873 | id: string;
874 | correct: boolean | null;
875 | confidence?: number;
876 | },
877 | ) => {
878 | if (id in this.jpd.public.judges) {
879 | // Already judged this player
880 | return false;
881 | }
882 | if (currentQ !== this.jpd.public.currentQ) {
883 | // Not judging the right question
884 | return false;
885 | }
886 | if (this.jpd.public.currentJudgeAnswer === undefined) {
887 | // Not in judging step
888 | return false;
889 | }
890 | if (this.settings.host && socket && socket?.id !== this.settings.host) {
891 | // Not the host
892 | return;
893 | }
894 | this.jpd.public.judges[id] = correct;
895 | console.log('[JUDGE]', id, correct);
896 | if (!this.jpd.public.scores[id]) {
897 | this.jpd.public.scores[id] = 0;
898 | }
899 | const delta = this.jpd.public.wagers[id] || this.jpd.public.currentValue;
900 | if (correct === true) {
901 | this.jpd.public.scores[id] += delta;
902 | if (!this.settings.allowMultipleCorrect) {
903 | // Correct answer is next picker
904 | this.jpd.public.picker = id;
905 | }
906 | }
907 | if (correct === false) {
908 | this.jpd.public.scores[id] -= delta;
909 | }
910 | // If null/undefined, don't change scores
911 | if (correct != null) {
912 | const msg = {
913 | id: socket?.id ?? '',
914 | // name of judge
915 | name:
916 | this.getAllPlayers().find((p) => p.id === socket?.id)?.name ??
917 | 'System',
918 | cmd: 'judge',
919 | msg: JSON.stringify({
920 | id: id,
921 | // name of person being judged
922 | name: this.getAllPlayers().find((p) => p.id === id)?.name,
923 | answer: this.jpd.public.answers[id],
924 | correct,
925 | delta: correct ? delta : -delta,
926 | confidence,
927 | }),
928 | };
929 | this.addChatMessage(socket, msg);
930 | if (!socket) {
931 | this.aiJudged = true;
932 | }
933 | }
934 | const allowMultipleCorrect =
935 | this.jpd.public.round === 'final' || this.settings.allowMultipleCorrect;
936 | const skipRemaining = !allowMultipleCorrect && correct === true;
937 | this.advanceJudging(skipRemaining);
938 |
939 | if (this.jpd.public.canNextQ) {
940 | this.nextQuestion();
941 | } else {
942 | this.sendState();
943 | }
944 | return correct != null;
945 | }
946 |
947 | submitWager = (id: string, wager: number) => {
948 | if (id in this.jpd.wagers) {
949 | return;
950 | }
951 | // User setting a wager for DD or final
952 | // Can bet up to current score, minimum of 1000 in single or 2000 in double, 0 in final
953 | let maxWager = 0;
954 | let minWager = 5;
955 | if (this.jpd.public.round === 'jeopardy') {
956 | maxWager = Math.max(this.jpd.public.scores[id] || 0, 1000);
957 | } else if (this.jpd.public.round === 'double') {
958 | maxWager = Math.max(this.jpd.public.scores[id] || 0, 2000);
959 | } else if (this.jpd.public.round === 'final') {
960 | minWager = 0;
961 | maxWager = Math.max(this.jpd.public.scores[id] || 0, 0);
962 | }
963 | let numWager = Number(wager);
964 | if (Number.isNaN(Number(wager))) {
965 | numWager = minWager;
966 | } else {
967 | numWager = Math.min(Math.max(numWager, minWager), maxWager);
968 | }
969 | console.log('[WAGER]', id, wager, numWager);
970 | if (id === this.jpd.public.dailyDoublePlayer && this.jpd.public.currentQ) {
971 | this.jpd.wagers[id] = numWager;
972 | this.jpd.public.wagers[id] = numWager;
973 | this.jpd.public.waitingForWager = undefined;
974 | if (this.jpd.public.board[this.jpd.public.currentQ]) {
975 | this.jpd.public.board[this.jpd.public.currentQ].question =
976 | this.jpd.board[this.jpd.public.currentQ]?.q;
977 | }
978 | this.triggerPlayClue();
979 | this.sendState();
980 | }
981 | if (this.jpd.public.round === 'final' && this.jpd.public.currentQ) {
982 | // store the wagers privately until everyone's made one
983 | this.jpd.wagers[id] = numWager;
984 | if (this.jpd.public.waitingForWager) {
985 | delete this.jpd.public.waitingForWager[id];
986 | }
987 | if (Object.keys(this.jpd.public.waitingForWager ?? {}).length === 0) {
988 | // if final, reveal clue if all players made wager
989 | this.jpd.public.waitingForWager = undefined;
990 | if (this.jpd.public.board[this.jpd.public.currentQ]) {
991 | this.jpd.public.board[this.jpd.public.currentQ].question =
992 | this.jpd.board[this.jpd.public.currentQ]?.q;
993 | }
994 | this.triggerPlayClue();
995 | }
996 | this.sendState();
997 | }
998 | }
999 |
1000 | setWagerTimeout = (durationMs: number, endTS?: number) => {
1001 | this.jpd.public.wagerEndTS = endTS ?? Date.now() + durationMs;
1002 | this.wagerTimeout = setTimeout(() => {
1003 | Object.keys(this.jpd.public.waitingForWager ?? {}).forEach((id) => {
1004 | this.submitWager(id, 0);
1005 | });
1006 | }, durationMs);
1007 | }
1008 |
1009 | triggerPlayClue = () => {
1010 | clearTimeout(this.wagerTimeout);
1011 | this.jpd.public.wagerEndTS = 0;
1012 | const clue = this.jpd.public.board[this.jpd.public.currentQ];
1013 | this.io
1014 | .of(this.roomId)
1015 | .emit('JPD:playClue', this.jpd.public.currentQ, clue && clue.question);
1016 | let speakingTime = 0;
1017 | if (clue && clue.question) {
1018 | // Allow some time for reading the text, based on content
1019 | // Count syllables in text, assume speaking rate of 4 syll/sec
1020 | const syllCountArr = clue.question
1021 | // Remove parenthetical starts and blanks
1022 | .replace(/^\(.*\)/, '')
1023 | .replace(/_+/g, ' blank ')
1024 | .split(' ')
1025 | .map((word: string) => syllableCount(word));
1026 | const totalSyll = syllCountArr.reduce((a: number, b: number) => a + b, 0);
1027 | // Minimum 1 second speaking time
1028 | speakingTime = Math.max((totalSyll / 4) * 1000, 1000);
1029 | console.log('[TRIGGERPLAYCLUE]', clue.question, totalSyll, speakingTime);
1030 | this.jpd.public.playClueEndTS = Date.now() + speakingTime;
1031 | }
1032 | this.setPlayClueTimeout(speakingTime);
1033 | }
1034 |
1035 | setPlayClueTimeout = (durationMs: number) => {
1036 | this.playClueTimeout = setTimeout(() => {
1037 | this.playClueDone();
1038 | }, durationMs);
1039 | }
1040 |
1041 | playClueDone = () => {
1042 | clearTimeout(this.playClueTimeout);
1043 | this.jpd.public.playClueEndTS = 0;
1044 | this.jpd.public.buzzUnlockTS = Date.now();
1045 | if (this.jpd.public.round === 'final') {
1046 | this.unlockAnswer(this.settings.finalTimeout);
1047 | // Play final jeopardy music
1048 | this.io.of(this.roomId).emit('JPD:playFinalJeopardy');
1049 | } else {
1050 | if (!this.jpd.public.currentDailyDouble) {
1051 | // DD already handles buzzing automatically
1052 | this.jpd.public.canBuzz = true;
1053 | }
1054 | this.unlockAnswer(this.settings.answerTimeout);
1055 | }
1056 | this.sendState();
1057 | }
1058 |
1059 | pregenAIVoices = async (rvcHost: string) => {
1060 | // Indicate we should use AI voices for this game
1061 | this.settings.enableAIVoices = rvcHost;
1062 | this.sendState();
1063 | // For the current game, get all category names and clues (61 clues + 12 category names)
1064 | // Final category doesn't get read right now
1065 | const strings = new Set(
1066 | [
1067 | ...(this.jpd.jeopardy?.map((item) => item.q) ?? []),
1068 | ...(this.jpd.double?.map((item) => item.q) ?? []),
1069 | ...(this.jpd.final?.map((item) => item.q) ?? []),
1070 | ...(this.jpd.jeopardy?.map((item) => item.cat) ?? []),
1071 | ...(this.jpd.double?.map((item) => item.cat) ?? []),
1072 | ].filter(Boolean),
1073 | );
1074 | console.log('%s strings to generate', strings.size);
1075 | const items = Array.from(strings);
1076 | const start = Date.now();
1077 | let cursor = items.entries();
1078 | // create for loops that each run off the same cursor which keeps track of location
1079 | let numWorkers = 10;
1080 | // The parallelism should ideally depend on the server configuration
1081 | // But we just need a value that won't take more than 5 minutes between start and stop because fetch will timeout
1082 | // No good way of configuring it right now without switching to undici
1083 | let success = 0;
1084 | let count = 0;
1085 | Array(numWorkers).fill('').forEach(async (_, workerIndex) => {
1086 | for (let [i, text] of cursor) {
1087 | try {
1088 | const url = await genAITextToSpeech(rvcHost, text ?? '');
1089 | // Report progress back in chat messages
1090 | if (url) {
1091 | this.addChatMessage(undefined, {
1092 | id: '',
1093 | name: 'System',
1094 | msg: 'generated ai voice ' + i + ': ' + url,
1095 | });
1096 | redisCount('aiVoice');
1097 | success += 1;
1098 | }
1099 | } catch (e) {
1100 | // Log errors, but continue iterating
1101 | console.log(e);
1102 | }
1103 | count += 1;
1104 | }
1105 | if (count === items.length) {
1106 | const end = Date.now();
1107 | this.addChatMessage(undefined, {
1108 | id: '',
1109 | name: 'System',
1110 | msg:
1111 | success +
1112 | '/' +
1113 | count +
1114 | ' voices generated in ' + (end - start) + 'ms',
1115 | });
1116 | }
1117 | });
1118 | }
1119 | }
1120 |
1121 | function constructBoard(questions: RawQuestion[]) {
1122 | // Map of x_y coordinates to questions
1123 | let output: { [key: string]: RawQuestion } = {};
1124 | questions.forEach((q) => {
1125 | output[`${q.x}_${q.y}`] = q;
1126 | });
1127 | return output;
1128 | }
1129 |
1130 | function constructPublicBoard(questions: RawQuestion[]) {
1131 | // Map of x_y coordinates to questions
1132 | let output: { [key: string]: Question } = {};
1133 | questions.forEach((q) => {
1134 | output[`${q.x}_${q.y}`] = {
1135 | value: q.val,
1136 | category: q.cat,
1137 | };
1138 | });
1139 | return output;
1140 | }
1141 |
1142 | function syllableCount(word: string) {
1143 | word = word.toLowerCase(); //word.downcase!
1144 | if (word.length <= 3) {
1145 | return 1;
1146 | }
1147 | word = word.replace(/(?:[^laeiouy]es|ed|[^laeiouy]e)$/, ''); //word.sub!(/(?:[^laeiouy]es|ed|[^laeiouy]e)$/, '')
1148 | word = word.replace(/^y/, '');
1149 | let vowels = word.match(/[aeiouy]{1,2}/g);
1150 | // Use 3 as the default if no letters, it's probably a year
1151 | return vowels ? vowels.length : 3;
1152 | }
1153 |
--------------------------------------------------------------------------------
/server/server.ts:
--------------------------------------------------------------------------------
1 | import fs from 'fs';
2 | import util from 'util';
3 | import express from 'express';
4 | import compression from 'compression';
5 | import os from 'os';
6 | import cors from 'cors';
7 | import https from 'https';
8 | import http from 'http';
9 | import { Server } from 'socket.io';
10 | import { Room } from './room';
11 | import { redis, getRedisCountDay } from './redis';
12 | import { makeRoomName, makeUserName } from './moniker';
13 | import config from './config';
14 | import { getJDataStats } from './jData';
15 |
16 | const app = express();
17 | let server = null as https.Server | http.Server | null;
18 | if (config.SSL_KEY_FILE && config.SSL_CRT_FILE) {
19 | const key = fs.readFileSync(config.SSL_KEY_FILE as string);
20 | const cert = fs.readFileSync(config.SSL_CRT_FILE as string);
21 | server = https.createServer({ key: key, cert: cert }, app);
22 | } else {
23 | server = new http.Server(app);
24 | }
25 | const io = new Server(server, { cors: { origin: '*' }, transports: ['websocket'] });
26 | const rooms = new Map();
27 | init();
28 | setInterval(freeUnusedRooms, 5 * 60 * 1000);
29 | async function freeUnusedRooms() {
30 | // Only run if redis persistence is turned on
31 | // Clean up rooms that are no longer in redis and empty
32 | // Frees up some JS memory space when process is long-running
33 | // If running without redis, keep rooms in memory
34 | // We don't currently attempt to reload rooms from redis on demand
35 | if (redis) {
36 | rooms.forEach(async (room, key) => {
37 | if (room.roster.length === 0 && !(await redis?.get(key))) {
38 | clearInterval(room.cleanupInterval);
39 | rooms.delete(key);
40 | // Unregister the namespace to avoid dupes
41 | io._nsps.delete(key);
42 | }
43 | });
44 | }
45 | }
46 |
47 | async function init() {
48 | if (redis) {
49 | // Load rooms from Redis
50 | console.log('loading rooms from redis');
51 | const keys = await redis.keys('/*');
52 | console.log(util.format('found %s rooms in redis', keys.length));
53 | for (let i = 0; i < keys.length; i++) {
54 | const key = keys[i];
55 | const roomData = await redis.get(key);
56 | console.log(key, roomData?.length);
57 | rooms.set(key, new Room(io, key, roomData));
58 | }
59 | }
60 | config.permaRooms.forEach((roomId) => {
61 | // Create the room if it doesn't exist
62 | if (!rooms.has(roomId)) {
63 | rooms.set(roomId, new Room(io, roomId));
64 | }
65 | });
66 | server?.listen(Number(config.PORT));
67 | }
68 |
69 | app.use(cors());
70 | app.use(compression());
71 | app.use(express.static('build'));
72 |
73 | app.get('/ping', (req, res) => {
74 | res.json('pong');
75 | });
76 |
77 | app.get('/metadata', (req, res) => {
78 | res.json(getJDataStats());
79 | });
80 |
81 | app.get('/stats', async (req, res) => {
82 | if (req.query.key && req.query.key === config.STATS_KEY) {
83 | const roomData: any[] = [];
84 | let currentUsers = 0;
85 | rooms.forEach((room) => {
86 | const obj = {
87 | creationTime: room.creationTime,
88 | roomId: room.roomId,
89 | rosterLength: room.getConnectedPlayers().length,
90 | };
91 | currentUsers += obj.rosterLength;
92 | roomData.push(obj);
93 | });
94 | // Sort newest first
95 | roomData.sort((a, b) => b.creationTime - a.creationTime);
96 | const cpuUsage = os.loadavg();
97 | const redisUsage = (await redis?.info())
98 | ?.split('\n')
99 | .find((line) => line.startsWith('used_memory:'))
100 | ?.split(':')[1]
101 | .trim();
102 | // const chatMessages = await getRedisCountDay('chatMessages');
103 | const newGamesLastDay = await getRedisCountDay('newGames');
104 | const customGamesLastDay = await getRedisCountDay('customGames');
105 | const aiJudgeLastDay = await getRedisCountDay('aiJudge');
106 | const aiShortcutLastDay = await getRedisCountDay('aiShortcut');
107 | const aiChatGptLastDay = await getRedisCountDay('aiChatGpt');
108 | const aiRefuseLastDay = await getRedisCountDay('aiRefuse');
109 | const undoLastDay = await getRedisCountDay('undo');
110 | const aiUndoLastDay = await getRedisCountDay('aiUndo');
111 | const aiVoiceLastDay = await getRedisCountDay('aiVoice');
112 | const savesLastDay = await getRedisCountDay('saves');
113 | const nonTrivialJudges = await redis?.llen('jpd:nonTrivialJudges');
114 | const jeopardyResults = await redis?.llen('jpd:results');
115 | const aiJudges = await redis?.llen('jpd:aiJudges');
116 |
117 | res.json({
118 | uptime: process.uptime(),
119 | roomCount: rooms.size,
120 | cpuUsage,
121 | redisUsage,
122 | memUsage: process.memoryUsage().rss,
123 | // chatMessages,
124 | currentUsers,
125 | newGamesLastDay,
126 | customGamesLastDay,
127 | aiJudgeLastDay,
128 | aiShortcutLastDay,
129 | aiChatGptLastDay,
130 | aiRefuseLastDay,
131 | undoLastDay,
132 | aiUndoLastDay,
133 | aiVoiceLastDay,
134 | savesLastDay,
135 | nonTrivialJudges,
136 | jeopardyResults,
137 | aiJudges,
138 | rooms: roomData,
139 | });
140 | } else {
141 | res.status(403).json({ error: 'Access Denied' });
142 | }
143 | });
144 |
145 | app.get('/jeopardyResults', async (req, res) => {
146 | if (req.query.key && req.query.key === config.STATS_KEY) {
147 | const data = await redis?.lrange('jpd:results', 0, -1);
148 | res.json(data);
149 | } else {
150 | res.status(403).json({ error: 'Access Denied' });
151 | }
152 | });
153 |
154 | app.get('/nonTrivialJudges', async (req, res) => {
155 | if (req.query.key && req.query.key === config.STATS_KEY) {
156 | const data = await redis?.lrange('jpd:nonTrivialJudges', 0, -1);
157 | res.json(data);
158 | } else {
159 | res.status(403).json({ error: 'Access Denied' });
160 | }
161 | });
162 |
163 | app.get('/aiJudges', async (req, res) => {
164 | if (req.query.key && req.query.key === config.STATS_KEY) {
165 | const data = await redis?.lrange('jpd:aiJudges', 0, -1);
166 | res.json(data);
167 | } else {
168 | res.status(403).json({ error: 'Access Denied' });
169 | }
170 | });
171 |
172 | app.post('/createRoom', async (req, res) => {
173 | const genName = () => '/' + makeRoomName();
174 | let name = genName();
175 | // Keep retrying until no collision
176 | while (rooms.has(name)) {
177 | name = genName();
178 | }
179 | console.log('createRoom: ', name);
180 | const newRoom = new Room(io, name);
181 | newRoom.saveRoom();
182 | rooms.set(name, newRoom);
183 | res.json({ name: name.slice(1) });
184 | });
185 |
186 | app.get('/generateName', (req, res) => {
187 | res.send(makeUserName());
188 | });
189 |
--------------------------------------------------------------------------------
/server/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es6",
4 | "lib": ["dom", "es2020"],
5 | "allowJs": true,
6 | "strict": true,
7 | "esModuleInterop": true,
8 | "module": "commonjs",
9 | "outDir": "../buildServer"
10 | },
11 | "include": [".", "../global.d.ts"]
12 | }
13 |
--------------------------------------------------------------------------------
/src/components/App/App.css:
--------------------------------------------------------------------------------
1 | body {
2 | margin: 0;
3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
5 | sans-serif;
6 | -webkit-font-smoothing: antialiased;
7 | -moz-osx-font-smoothing: grayscale;
8 | }
9 |
10 | ::-webkit-scrollbar-corner {
11 | background: rgba(0, 0, 0, 0);
12 | }
13 |
14 | body {
15 | background-color: #222222;
16 | }
17 |
18 | .comment {
19 | word-break: break-word;
20 | }
21 |
22 | .comment .light.light.light {
23 | color: gainsboro;
24 | }
25 |
26 | .comment .dark.dark.dark {
27 | color: gray;
28 | }
29 |
30 | .chatContainer {
31 | flex-grow: 1;
32 | overflow: auto;
33 | }
34 |
35 | .system.system.system.system {
36 | font-size: 10px;
37 | text-transform: uppercase;
38 | letter-spacing: 1px;
39 | }
40 |
41 | .controlsContainer {
42 | position: absolute;
43 | width: 100%;
44 | bottom: 0px;
45 | }
46 |
47 | .controls {
48 | color: white;
49 | display: flex;
50 | flex-wrap: wrap;
51 | align-items: center;
52 | line-height: 14px;
53 | padding-left: 10px;
54 | padding-top: 10px;
55 | padding-bottom: 10px;
56 | }
57 |
58 | .control.control {
59 | margin-right: 10px;
60 | text-transform: uppercase;
61 | }
62 |
63 | .control.action {
64 | cursor: pointer;
65 | }
66 |
67 | .progress .bar.bar.bar {
68 | pointer-events: none;
69 | min-width: initial;
70 | }
71 |
72 | .videoContent {
73 | position: absolute;
74 | top: 0;
75 | left: 0;
76 | width: 100%;
77 | height: 100%;
78 | }
79 |
80 | .fullScreenContainer {
81 | display: flex;
82 | flex-direction: row;
83 | }
84 |
85 | #playerContainer {
86 | height: 100%;
87 | position: relative;
88 | display: flex;
89 | align-items: center;
90 | justify-content: center;
91 | min-height: 62vh;
92 | }
93 |
94 | .fullScreenContainer:fullscreen > #playerContainer {
95 | width: 80vw;
96 | }
97 |
98 | .fullScreenChat.fullScreenChat.fullScreenChat {
99 | width: 20vw;
100 | }
101 |
102 | #leftVideo::-webkit-media-controls {
103 | display: none !important;
104 | }
105 |
106 | @media only screen and (max-width: 600px) {
107 | .mobileStack {
108 | flex-direction: column;
109 | }
110 | }
111 |
112 | .fullHeightColumn.fullHeightColumn.fullHeightColumn.fullHeightColumn {
113 | height: calc(100vh - 62px);
114 | }
115 |
116 | .toolButton {
117 | min-width: 135px;
118 | height: 36px;
119 | }
120 |
121 | .videoChatContent {
122 | height: 110px;
123 | border-radius: 4px;
124 | object-fit: contain;
125 | }
126 |
127 | .ui.labeled.icon.button > .icon.loading,
128 | .ui.labeled.icon.buttons > .button > .icon.loading {
129 | background-color: initial;
130 | }
131 |
132 | .ui.dropdown .menu[role='listbox'] {
133 | z-index: 1001;
134 | }
135 |
136 | .ui.selection.dropdown.ui.selection.dropdown {
137 | min-height: 1em;
138 | border: 0px;
139 | }
140 |
141 | .ui.search.dropdown > .text {
142 | white-space: nowrap;
143 | text-overflow: ellipsis;
144 | width: 100%;
145 | overflow: hidden;
146 | }
147 |
148 | .footerIcon {
149 | color: white;
150 | margin-right: 10px;
151 | }
152 |
--------------------------------------------------------------------------------
/src/components/App/App.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { render } from '@testing-library/react';
4 |
5 | import App from './App';
6 |
7 | test('renders learn react link', () => {
8 | const { getByText } = render( );
9 | const linkElement = getByText(/learn react/i);
10 | expect(linkElement).toBeInTheDocument();
11 | });
12 |
--------------------------------------------------------------------------------
/src/components/App/App.tsx:
--------------------------------------------------------------------------------
1 | import './App.css';
2 | import React, { useCallback, useEffect, useState } from 'react';
3 | import { Divider, Grid, Icon, Input } from 'semantic-ui-react';
4 | import { serverPath, generateName } from '../../utils';
5 | import { Chat } from '../Chat/Chat';
6 | import { JeopardyTopBar } from '../TopBar/TopBar';
7 | import { Jeopardy } from '../Jeopardy/Jeopardy';
8 | import { type Socket } from 'socket.io-client';
9 |
10 | export default function App() {
11 | const [participants, setParticipants] = useState([]);
12 | const [chat, setChat] = useState([]);
13 | const [myName, setMyName] = useState('');
14 | const [scrollTimestamp, setScrollTimestamp] = useState(0);
15 | const [socket, setSocket] = useState(undefined);
16 |
17 | useEffect(() => {
18 | const heartbeat = window.setInterval(
19 | () => {
20 | window.fetch(serverPath + '/ping');
21 | },
22 | 10 * 60 * 1000,
23 | );
24 | return () => {
25 | window.clearInterval(heartbeat);
26 | }
27 | });
28 |
29 | const updateName = useCallback((name: string) => {
30 | if (socket) {
31 | setMyName(name);
32 | socket.emit('CMD:name', name);
33 | window.localStorage.setItem('watchparty-username', name);
34 | }
35 | }, [socket]);
36 |
37 | return (
38 |
39 |
40 | {
41 |
42 |
43 |
44 |
53 |
54 |
59 | updateName(data.value)}
65 | icon={
66 |
68 | updateName(await generateName())
69 | }
70 | name="random"
71 | inverted
72 | circular
73 | link
74 | />
75 | }
76 | />
77 |
78 |
83 |
84 |
85 |
86 | }
87 |
88 | );
89 | }
90 |
--------------------------------------------------------------------------------
/src/components/App/index.tsx:
--------------------------------------------------------------------------------
1 | export { default } from './App';
2 |
--------------------------------------------------------------------------------
/src/components/Chat/Chat.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useRef, useState } from 'react';
2 | import { Button, Comment, Icon, Input, Segment } from 'semantic-ui-react';
3 | import { getColorHex, getDefaultPicture } from '../../utils';
4 | import { Socket } from 'socket.io-client';
5 |
6 | interface ChatProps {
7 | chat: ChatMessage[];
8 | scrollTimestamp: number;
9 | className?: string;
10 | hide?: boolean;
11 | socket: Socket | undefined;
12 | }
13 |
14 | export function Chat (props: ChatProps) {
15 | const [chatMsg, setChatMsg] = useState('');
16 | const [isNearBottom, setIsNearBottom] = useState(true);
17 | const messagesRef = useRef(null);
18 |
19 | const updateChatMsg = (e: any, data: { value: string }) => {
20 | setChatMsg(data.value);
21 | };
22 |
23 | const sendChatMsg = () => {
24 | if (!chatMsg || !props.socket) {
25 | return;
26 | }
27 | setChatMsg('');
28 | props.socket.emit('CMD:chat', chatMsg);
29 | };
30 |
31 | const isChatNearBottom = () => {
32 | return Boolean(
33 | messagesRef.current &&
34 | messagesRef.current.scrollHeight -
35 | messagesRef.current.scrollTop -
36 | messagesRef.current.offsetHeight <
37 | 100
38 | );
39 | };
40 |
41 | const onScroll = () => {
42 | setIsNearBottom(isChatNearBottom());
43 | };
44 |
45 | const scrollToBottom = () => {
46 | if (messagesRef.current) {
47 | messagesRef.current.scrollTop =
48 | messagesRef.current.scrollHeight;
49 | }
50 | };
51 |
52 | useEffect(() => {
53 | scrollToBottom();
54 | messagesRef.current?.addEventListener('scroll', onScroll);
55 | return () => {
56 | messagesRef.current?.removeEventListener('scroll', onScroll);
57 | };
58 | }, []);
59 |
60 | useEffect(() => {
61 | // if scrolltimestamp updated, we received a new message
62 | // We don't really need to diff it with the previous props
63 | // If 0, we haven't scrolled yet and want to always go to bottom
64 | if (isChatNearBottom() || props.scrollTimestamp != null) {
65 | scrollToBottom();
66 | }
67 | }, [props.scrollTimestamp, props.chat]);
68 | return (
69 |
81 |
86 |
87 | {props.chat.map((msg) => (
88 |
92 | ))}
93 | {/*
*/}
94 |
95 | {!isNearBottom && (
96 |
106 | Jump to bottom
107 |
108 | )}
109 |
110 | e.key === 'Enter' && sendChatMsg()}
114 | onChange={updateChatMsg}
115 | value={chatMsg}
116 | icon={
117 |
124 | }
125 | placeholder="Enter a message..."
126 | />
127 |
128 | );
129 | }
130 |
131 | const ChatMessage = ({ id, name, timestamp, cmd, msg }: {id: string, name?: string, timestamp: string, cmd: string, msg: string}) => {
132 | return (
133 |
134 |
135 |
136 |
137 | {name || id}
138 |
139 |
140 |
141 | {new Date(timestamp).toLocaleTimeString()}
142 |
143 |
144 |
145 | {cmd && formatMessage(cmd, msg)}
146 |
147 | {!cmd && msg}
148 |
149 |
150 | );
151 | };
152 |
153 | const formatMessage = (cmd: string, msg: string): React.ReactNode | string => {
154 | if (cmd === 'judge') {
155 | const { id, correct, answer, delta, name, confidence } = JSON.parse(msg);
156 | return (
157 | {`ruled ${name} ${correct ? 'correct' : 'incorrect'}: ${answer} (${
160 | delta >= 0 ? '+' : ''
161 | }${delta}) ${
162 | confidence != null ? `(${(confidence * 100).toFixed(0)}% conf.)` : ''
163 | }`}
164 | );
165 | } else if (cmd === 'answer') {
166 | return `Correct answer: ${msg}`;
167 | }
168 | return cmd;
169 | };
--------------------------------------------------------------------------------
/src/components/Home/Home.module.css:
--------------------------------------------------------------------------------
1 | .container {
2 | display: flex;
3 | flex-direction: column;
4 | color: white;
5 | /* background-image: linear-gradient(to bottom, #222222, #008cdd); */
6 | }
7 |
8 | .hero {
9 | color: white;
10 | width: 100%;
11 | background-image: linear-gradient(to bottom, #222222, #006199);
12 | border-bottom-left-radius: 0%;
13 | border-bottom-right-radius: 50%;
14 | display: flex;
15 | justify-content: center;
16 | }
17 |
18 | .hero.green {
19 | background-image: linear-gradient(to bottom, #222222, rgb(21, 114, 43));
20 | border-bottom-left-radius: 50%;
21 | border-bottom-right-radius: 0%;
22 | }
23 |
24 | .heroInner {
25 | padding: 30px;
26 | max-width: 1200px;
27 | display: flex;
28 | align-items: center;
29 | }
30 |
31 | @media only screen and (max-width: 600px) {
32 | .heroInner {
33 | flex-direction: column;
34 | }
35 | }
36 |
37 | .heroText {
38 | font-size: 32px;
39 | line-height: 1.2em;
40 | font-weight: 600;
41 | }
42 |
43 | .subText {
44 | color: lightgray;
45 | font-size: 20px;
46 | line-height: 1.5em;
47 | margin-top: 8px;
48 | }
49 |
50 | .featureSection {
51 | display: flex;
52 | max-width: 1200px;
53 | justify-content: center;
54 | margin: 0 auto;
55 | flex-wrap: wrap;
56 | }
57 |
58 | .featureTitle {
59 | text-transform: uppercase;
60 | letter-spacing: 1px;
61 | }
62 |
63 | .featureText {
64 | color: lightgray;
65 | font-size: 14px;
66 | }
67 |
--------------------------------------------------------------------------------
/src/components/Home/Home.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from 'react';
2 | import { Divider, Header, Icon } from 'semantic-ui-react';
3 | import CountUp from 'react-countup';
4 |
5 | import { NewRoomButton, JeopardyTopBar } from '../TopBar/TopBar';
6 | import styles from './Home.module.css';
7 | import { serverPath } from '../../utils';
8 |
9 | const Feature = ({
10 | icon,
11 | text,
12 | title,
13 | }: {
14 | icon: string;
15 | text: string;
16 | title: string;
17 | }) => {
18 | return (
19 |
29 |
30 |
{title}
31 |
{text}
32 |
33 | );
34 | };
35 |
36 | const Hero = ({
37 | heroText,
38 | action,
39 | image,
40 | color,
41 | }: {
42 | heroText?: string;
43 | action?: React.ReactNode;
44 | image?: string;
45 | color?: string;
46 | }) => {
47 | const [epCount, setEpCount] = useState(8000);
48 | const [qCount, setQCount] = useState(500000);
49 | useEffect(() => {
50 | const update = async () => {
51 | const response = await fetch(serverPath + '/metadata');
52 | const json = await response.json();
53 | setQCount(json.qs);
54 | setEpCount(json.eps);
55 | }
56 | update();
57 | }, []);
58 | return (
59 |
60 |
61 |
62 |
{heroText}
63 |
64 |
65 | {' '}
66 | episodes featuring
67 | {' '}
68 |
69 | {' '}
70 | clues
71 |
72 | {action}
73 |
74 |
79 |
84 |
85 |
86 |
87 | );
88 | };
89 |
90 | export const JeopardyHome = () => {
91 | return (
92 |
93 |
94 |
95 |
}
98 | image={'/screenshot3.png'}
99 | />
100 |
101 |
102 |
103 | Features
104 |
105 |
106 |
107 |
112 |
117 |
122 |
127 |
132 |
133 |
134 |
135 | );
136 | };
137 |
--------------------------------------------------------------------------------
/src/components/Jeopardy/Jeopardy.css:
--------------------------------------------------------------------------------
1 | @font-face {
2 | font-family: 'Korinna';
3 | font-style: normal;
4 | font-weight: 400;
5 | src: url('./korinna-regular.otf');
6 | }
7 |
8 | @font-face {
9 | font-family: 'Swiss 911';
10 | font-style: normal;
11 | font-weight: 400;
12 | src: url('./swiss911-xcm-bt.ttf');
13 | }
14 |
15 | @font-face {
16 | font-family: 'Univers';
17 | font-style: normal;
18 | font-weight: 400;
19 | src: url('./univers-75-black.ttf');
20 | }
21 |
22 | @font-face {
23 | font-family: 'Gyparody';
24 | font-style: normal;
25 | font-weight: 400;
26 | src: url('./gyparody.ttf');
27 | }
28 |
29 | .logo {
30 | font-family: 'Gyparody';
31 | font-size: 48px;
32 | line-height: 48px;
33 | text-transform: uppercase;
34 | color: white;
35 | text-shadow:
36 | 1px 1px 4px #fff,
37 | 1px 1px 4px #ccc;
38 | }
39 |
40 | .logo.small {
41 | font-size: 30px;
42 | line-height: 30px;
43 | }
44 |
45 | .board {
46 | background-color: black;
47 | display: grid;
48 | grid-template-columns: repeat(6, 1fr);
49 | grid-template-rows: repeat(6, 1fr);
50 | grid-gap: 6px;
51 | padding: 6px;
52 | align-items: center;
53 | justify-content: center;
54 | text-align: center;
55 | margin: 0 auto;
56 | position: relative;
57 | width: 100%;
58 | }
59 |
60 | .board.currentQ {
61 | overflow-x: hidden;
62 | }
63 |
64 | @media only screen and (max-width: 600px) {
65 | .board {
66 | overflow-x: scroll;
67 | justify-content: start;
68 | }
69 | }
70 |
71 | .box {
72 | align-items: center;
73 | justify-content: center;
74 | display: flex;
75 | background-color: #0000b5;
76 | padding: 4px;
77 | height: 100%;
78 | width: 100%;
79 | min-width: 100px;
80 | min-height: 60px;
81 | }
82 |
83 | .category {
84 | font-family: 'Swiss 911';
85 | font-size: 2.5vh;
86 | line-height: 2.5vh;
87 | letter-spacing: 1px;
88 | color: #e1f3ff;
89 | text-transform: uppercase;
90 | }
91 |
92 | .value {
93 | font-family: 'Swiss 911';
94 | font-size: 6vh;
95 | color: #d7a04b;
96 | text-shadow: black 4px 4px;
97 | }
98 |
99 | .value:hover {
100 | cursor: pointer;
101 | border: 1px solid white;
102 | }
103 |
104 | .clueContainerContainer {
105 | transform: scale(0.17);
106 | transform-origin: 0 0;
107 | transition: 0.7s ease-out;
108 | width: 100%;
109 | height: 100%;
110 | }
111 |
112 | .clueContainer {
113 | display: flex;
114 | flex-direction: column;
115 | align-items: center;
116 | padding: 20px 20px;
117 | background-color: #0000b5;
118 | position: relative;
119 | margin: 0 auto;
120 | height: 100%;
121 | }
122 |
123 | .clueContainer.dailyDouble {
124 | background-position-x: center;
125 | background-size: contain;
126 | background-repeat: no-repeat;
127 | background-image: url('./jeopardy-daily-double.png');
128 | background-color: rgba(0, 0, 0, 1);
129 | }
130 |
131 | .clue {
132 | font-family: 'Korinna';
133 | text-transform: uppercase;
134 | color: white;
135 | font-size: 2em;
136 | line-height: 1;
137 | text-align: center;
138 | flex-grow: 1;
139 | display: flex;
140 | align-items: center;
141 | }
142 |
143 | @media only screen and (max-width: 600px) {
144 | .clue {
145 | overflow-y: scroll;
146 | align-items: start;
147 | }
148 | }
149 |
150 | .answer {
151 | font-family: 'Korinna';
152 | text-transform: uppercase;
153 | color: white;
154 | font-size: 22px;
155 | line-height: 22px;
156 | text-align: center;
157 | }
158 |
159 | .scoreboard {
160 | display: flex;
161 | flex-direction: column;
162 | width: 110px;
163 | margin-right: 8px;
164 | flex-shrink: 0;
165 | }
166 |
167 | .scoreboard .icons {
168 | position: absolute;
169 | top: 0;
170 | right: 0;
171 | color: white;
172 | }
173 |
174 | .scoreboard .picture {
175 | width: 100%;
176 | margin-bottom: -4px;
177 | }
178 |
179 | .scoreboard .picture img {
180 | width: 100%;
181 | }
182 |
183 | .scoreboard .points {
184 | font-family: 'Univers';
185 | color: white;
186 | background-color: #0000b5;
187 | display: flex;
188 | align-items: center;
189 | justify-content: center;
190 | font-size: 22px;
191 | line-height: 22px;
192 | padding: 4px 10px;
193 | border: 4px solid black;
194 | }
195 |
196 | .scoreboard .points.negative {
197 | color: red;
198 | }
199 |
200 | .scoreboard .answerBox.negative {
201 | color: red;
202 | }
203 |
204 | .judgeButtons {
205 | position: absolute;
206 | top: 0px;
207 | display: flex;
208 | justify-content: center;
209 | }
210 |
211 | .scoreboard .answerBox {
212 | font-family: 'Korinna';
213 | color: white;
214 | background-color: #0000b5;
215 | display: flex;
216 | align-items: center;
217 | justify-content: center;
218 | min-height: 80px;
219 | font-size: 12px;
220 | line-height: 12px;
221 | padding: 4px;
222 | word-break: break-word;
223 | border: 4px solid black;
224 | position: relative;
225 | }
226 |
227 | .scoreboard .answerBox.buzz {
228 | border: 4px solid white;
229 | }
230 |
231 | .scoreboard .answerBox .timeOffset {
232 | position: absolute;
233 | bottom: 0;
234 | right: 0;
235 | font-size: 11px;
236 | font-family: initial;
237 | padding: 0px 3px;
238 | border-radius: 3px;
239 | }
240 |
241 | .gameSelector input {
242 | width: 100px;
243 | }
244 |
245 | #intro {
246 | position: absolute;
247 | width: 100%;
248 | height: 100%;
249 | top: 0;
250 | left: 0;
251 | z-index: 1;
252 | display: flex;
253 | align-items: center;
254 | justify-content: center;
255 | }
256 |
257 | #intro video {
258 | width: 100%;
259 | }
260 |
261 | #endgame {
262 | display: flex;
263 | justify-content: center;
264 | align-items: center;
265 | flex-direction: column;
266 | position: absolute;
267 | width: 100%;
268 | padding: 20px;
269 | }
270 |
--------------------------------------------------------------------------------
/src/components/Jeopardy/Jeopardy.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from 'react';
2 | import {
3 | Button,
4 | Label,
5 | Input,
6 | Icon,
7 | Dropdown,
8 | Popup,
9 | Modal,
10 | Table,
11 | TableRow,
12 | TableCell,
13 | TableHeader,
14 | TableHeaderCell,
15 | Checkbox,
16 | Header,
17 | SemanticCOLORS,
18 | } from 'semantic-ui-react';
19 | import './Jeopardy.css';
20 | import {
21 | getDefaultPicture,
22 | getColorHex,
23 | shuffle,
24 | getColor,
25 | generateName,
26 | serverPath,
27 | getOrCreateClientId,
28 | } from '../../utils';
29 | import { io, type Socket } from 'socket.io-client';
30 | import ReactMarkdown from 'react-markdown';
31 | import { type PublicGameState } from '../../../server/gamestate';
32 | import { cyrb53 } from '../../../server/hash';
33 |
34 | const dailyDouble = new Audio('/jeopardy/jeopardy-daily-double.mp3');
35 | const boardFill = new Audio('/jeopardy/jeopardy-board-fill.mp3');
36 | const think = new Audio('/jeopardy/jeopardy-think.mp3');
37 | const timesUp = new Audio('/jeopardy/jeopardy-times-up.mp3');
38 | const rightAnswer = new Audio('/jeopardy/jeopardy-rightanswer.mp3');
39 |
40 | type GameSettings = {
41 | answerTimeout?: number;
42 | finalTimeout?: number;
43 | makeMeHost?: boolean;
44 | allowMultipleCorrect?: boolean;
45 | enableAIJudge?: boolean;
46 | };
47 |
48 | const loadSavedSettings = (): GameSettings => {
49 | try {
50 | const saved = window.localStorage.getItem('jeopardy-gameSettings');
51 | if (saved) {
52 | return JSON.parse(saved);
53 | }
54 | } catch (e) {
55 | console.log(e);
56 | }
57 | return {};
58 | };
59 |
60 | // room ID from url
61 | let roomId = '/default';
62 | const urlParams = new URLSearchParams(window.location.search);
63 | const query = urlParams.get('game');
64 | if (query) {
65 | roomId = '/' + query;
66 | }
67 |
68 | export class Jeopardy extends React.Component<{
69 | participants: User[];
70 | chat: ChatMessage[];
71 | updateName: (name: string) => void;
72 | setSocket: (socket: Socket | undefined) => void;
73 | setParticipants: (users: User[]) => void;
74 | setScrollTimestamp: (ts: number) => void;
75 | setChat: (chat: ChatMessage[]) => void;
76 | }> {
77 | public state = {
78 | game: undefined as PublicGameState | undefined,
79 | isIntroPlaying: false,
80 | localAnswer: '',
81 | localWager: '',
82 | localAnswerSubmitted: false,
83 | localWagerSubmitted: false,
84 | localEpNum: '',
85 | categoryMask: Array(6).fill(true),
86 | categoryReadTime: 0,
87 | clueMask: {} as any,
88 | readingDisabled: false,
89 | buzzFrozen: false,
90 | showCustomModal: false,
91 | showJudgingModal: false,
92 | showSettingsModal: false,
93 | settings: loadSavedSettings(),
94 | overlayMsg: '',
95 | };
96 | buzzLock = 0;
97 | socket: Socket | undefined = undefined;
98 |
99 | async componentDidMount() {
100 | window.speechSynthesis.getVoices();
101 |
102 | document.onkeydown = this.onKeydown;
103 |
104 | this.setState({
105 | readingDisabled: Boolean(
106 | window.localStorage.getItem('jeopardy-readingDisabled'),
107 | ),
108 | });
109 | const socket = io(serverPath + roomId, {
110 | transports: ['websocket'],
111 | query: {
112 | clientId: getOrCreateClientId(),
113 | },
114 | });
115 | this.socket = socket;
116 | this.props.setSocket(socket);
117 | socket.on('connect', async () => {
118 | // Load username from localstorage
119 | let userName = window.localStorage.getItem('watchparty-username');
120 | this.props.updateName(userName || (await generateName()));
121 | });
122 | socket.on('connect_error', (err: any) => {
123 | console.error(err);
124 | if (err.message === 'Invalid namespace') {
125 | this.setState({ overlayMsg: "Couldn't load this room." });
126 | }
127 | });
128 | socket.on('REC:chat', (data: ChatMessage) => {
129 | if (document.visibilityState && document.visibilityState !== 'visible') {
130 | new Audio('/clearly.mp3').play();
131 | }
132 | // There may be race condition issues if we append using [...this.state.chat, data]
133 | this.props.chat.push(data);
134 | this.props.setChat(this.props.chat);
135 | this.props.setScrollTimestamp(Number(new Date()));
136 | });
137 | socket.on('roster', (data: User[]) => {
138 | this.props.setParticipants(data);
139 | });
140 | socket.on('chatinit', (data: any) => {
141 | this.props.setChat(data);
142 | this.props.setScrollTimestamp(0);
143 | });
144 | socket.on('JPD:state', (game: PublicGameState) => {
145 | this.setState({ game, localEpNum: game.epNum });
146 | });
147 | // socket.on('JPD:playIntro', () => {
148 | // this.playIntro();
149 | // });
150 | socket.on('JPD:playTimesUp', () => {
151 | timesUp.play();
152 | });
153 | socket.on('JPD:playDailyDouble', () => {
154 | dailyDouble.volume = 0.5;
155 | dailyDouble.play();
156 | });
157 | socket.on('JPD:playFinalJeopardy', async () => {
158 | think.volume = 0.5;
159 | think.play();
160 | });
161 | socket.on('JPD:playRightanswer', () => {
162 | rightAnswer.play();
163 | });
164 | // socket.on('JPD:playMakeSelection', () => {
165 | // if (this.state.game.picker) {
166 | // const selectionText = [
167 | // 'Make a selection, {name}',
168 | // 'You have command of the board, {name}',
169 | // 'Pick a clue, {name}',
170 | // 'Select a category, {name}',
171 | // 'Go again, {name}',
172 | // ];
173 | // const random =p
174 | // selectionText[Math.floor(Math.random() * selectionText.length)];
175 | // this.sayText(
176 | // random.replace('{name}', this.state.game.picker)
177 | // );
178 | // }
179 | // });
180 | socket.on('JPD:playClue', async (qid: string, text: string) => {
181 | this.setState({
182 | localAnswer: '',
183 | localWager: '',
184 | localWagerSubmitted: false,
185 | localAnswerSubmitted: false,
186 | });
187 | // Read the question
188 | // console.log('JPD:playClue', text);
189 | // Remove parenthetical starts and blanks
190 | await this.sayText(text.replace(/^\(.*\)/, '').replace(/_+/g, ' blank '));
191 | });
192 | socket.on('JPD:playCategories', async () => {
193 | const now = Date.now();
194 | const clueMask: any = {};
195 | const clueMaskOrder = [];
196 | for (let i = 1; i <= 6; i++) {
197 | for (let j = 1; j <= 5; j++) {
198 | clueMask[`${i}_${j}`] = true;
199 | clueMaskOrder.push(`${i}_${j}`);
200 | }
201 | }
202 | this.setState({
203 | categoryMask: Array(6).fill(false),
204 | categoryReadTime: now,
205 | clueMask,
206 | });
207 | // Run board intro sequence
208 | // Play the fill sound
209 | boardFill.play();
210 | // Randomly choose ordering of the 30 clues
211 | // Split into 6 sets of 5
212 | // Each half second show another set of 5
213 | shuffle(clueMaskOrder);
214 | const clueSets = [];
215 | for (let i = 0; i < clueMaskOrder.length; i += 5) {
216 | clueSets.push(clueMaskOrder.slice(i, i + 5));
217 | }
218 | for (let i = 0; i < clueSets.length; i++) {
219 | await new Promise((resolve) => setTimeout(resolve, 400));
220 | clueSets[i].forEach((clue) => delete this.state.clueMask[clue]);
221 | this.setState({ clueMask: this.state.clueMask });
222 | }
223 | // Reveal and read categories
224 | const categories = this.getCategories();
225 | for (let i = 0; i < this.state.categoryMask.length; i++) {
226 | if (this.state.categoryReadTime !== now) {
227 | continue;
228 | }
229 | let newMask: Boolean[] = [...this.state.categoryMask];
230 | newMask[i] = true;
231 | this.setState({ categoryMask: newMask });
232 | await Promise.any([
233 | this.sayText(categories[i]),
234 | new Promise((resolve) => setTimeout(resolve, 3000)),
235 | ]);
236 | }
237 | });
238 | }
239 |
240 | componentWillUnmount() {
241 | document.removeEventListener('keydown', this.onKeydown);
242 | this.socket?.close();
243 | this.socket = undefined;
244 | this.props.setSocket(undefined);
245 | }
246 |
247 | async componentDidUpdate(prevProps: any, prevState: any) {
248 | if (!prevState.game?.currentQ && this.state.game?.currentQ) {
249 | // Run growing clue animation
250 | const clue = document.getElementById(
251 | 'clueContainerContainer',
252 | ) as HTMLElement;
253 | const board = document.getElementById('board') as HTMLElement;
254 | const box = document.getElementById(
255 | this.state.game?.currentQ,
256 | ) as HTMLElement;
257 | clue.style.position = 'absolute';
258 | clue.style.left = box.offsetLeft + 'px';
259 | clue.style.top = box.offsetTop + 'px';
260 | setTimeout(() => {
261 | clue.style.left = board.scrollLeft + 'px';
262 | clue.style.top = '0px';
263 | clue.style.transform = 'scale(1)';
264 | }, 1);
265 | }
266 | }
267 |
268 | onKeydown = (e: any) => {
269 | if (!document.activeElement || document.activeElement.tagName === 'BODY') {
270 | if (e.key === ' ') {
271 | e.preventDefault();
272 | this.onBuzz();
273 | }
274 | if (e.key === 'p') {
275 | e.preventDefault();
276 | if (this.state.game?.canBuzz) {
277 | this.submitAnswer(null);
278 | }
279 | }
280 | }
281 | };
282 |
283 | newGame = async (
284 | options: {
285 | number?: string;
286 | filter?: string;
287 | },
288 | customGame?: string,
289 | ) => {
290 | this.setState({ game: null });
291 | // optionally send an episode number or game type filter
292 | // combine with other custom settings configured by user
293 | const combined: GameOptions = {
294 | number: options.number,
295 | filter: options.filter,
296 | ...this.state.settings,
297 | };
298 | this.socket?.emit('JPD:start', combined, customGame);
299 | };
300 |
301 | customGame = () => {
302 | // Create an input element
303 | const inputElement = document.createElement('input');
304 |
305 | // Set its type to file
306 | inputElement.type = 'file';
307 |
308 | // Set accept to the file types you want the user to select.
309 | // Include both the file extension and the mime type
310 | // inputElement.accept = accept;
311 |
312 | // set onchange event to call callback when user has selected file
313 | inputElement.addEventListener('change', () => {
314 | const file = inputElement.files![0];
315 | // Read the file
316 | const reader = new FileReader();
317 | reader.readAsText(file);
318 | reader.onload = (e) => {
319 | let content = e.target?.result;
320 | this.newGame({}, content as string);
321 | this.setState({ showCustomModal: false });
322 | };
323 | });
324 |
325 | // dispatch a click event to open the file dialog
326 | inputElement.dispatchEvent(new MouseEvent('click'));
327 | };
328 |
329 | // playIntro = async () => {
330 | // this.setState({ isIntroPlaying: true });
331 | // document.getElementById('intro')!.innerHTML = '';
332 | // let introVideo = document.createElement('video');
333 | // let introMusic = new Audio('/jeopardy/jeopardy-intro-full.ogg');
334 | // document.getElementById('intro')?.appendChild(introVideo);
335 | // introVideo.muted = true;
336 | // introVideo.src = '/jeopardy/jeopardy-intro-video.mp4';
337 | // introVideo.play();
338 | // introVideo.style.width = '100%';
339 | // introVideo.style.height = '100%';
340 | // introVideo.style.backgroundColor = '#000000';
341 | // introMusic.volume = 0.5;
342 | // introMusic.play();
343 | // setTimeout(async () => {
344 | // await this.sayText('This is Jeopardy!');
345 | // await new Promise((resolve) => setTimeout(resolve, 1000));
346 | // await this.sayText("Here are today's contestants.");
347 | // await new Promise((resolve) => setTimeout(resolve, 1000));
348 | // for (let i = 0; i < this.props.participants.length; i++) {
349 | // const p = this.props.participants[i];
350 | // const name = p.name;
351 | // const player = document.createElement('img');
352 | // player.src =
353 | // getDefaultPicture(p.name, getColorHex(p.id));
354 | // player.style.width = '200px';
355 | // player.style.height = '200px';
356 | // player.style.position = 'absolute';
357 | // player.style.margin = 'auto';
358 | // player.style.top = '0px';
359 | // player.style.bottom = '0px';
360 | // player.style.left = '0px';
361 | // player.style.right = '0px';
362 | // document.getElementById('intro')!.appendChild(player);
363 | // // maybe we can look up the location by IP?
364 | // await this.sayText('A person from somewhere, ' + name);
365 | // await new Promise((resolve) => setTimeout(resolve, 1000));
366 | // document.getElementById('intro')!.removeChild(player);
367 | // }
368 | // await this.sayText(
369 | // 'And now, here is the host of Jeopardy, your computer!'
370 | // );
371 | // await new Promise((resolve) => setTimeout(resolve, 1000));
372 | // introMusic.pause();
373 | // introVideo.pause();
374 | // introVideo = null as any;
375 | // introMusic = null as any;
376 | // document.getElementById('intro')!.innerHTML = '';
377 | // this.setState({ isIntroPlaying: false });
378 | // }, 10000);
379 | // };
380 |
381 | sayText = async (text: string) => {
382 | if (this.state.readingDisabled) {
383 | await new Promise((resolve) => setTimeout(resolve, 2000));
384 | return;
385 | }
386 | if (text.startsWith('!')) {
387 | // This is probably markdown
388 | return;
389 | }
390 | // if enabled, attempt using pregen AI voice
391 | // Always checking first is kind of slow due to http request needed
392 | if (this.state.game?.enableAIVoices) {
393 | try {
394 | await new Promise(async (resolve, reject) => {
395 | const hash = cyrb53(text).toString();
396 | const aiVoice = new Audio(
397 | this.state.game?.enableAIVoices +
398 | '/gradio_api/file=audio/output/{hash}.mp3'.replace(
399 | '{hash}',
400 | hash,
401 | ),
402 | );
403 | aiVoice.onended = resolve;
404 | aiVoice.onerror = reject;
405 | try {
406 | await aiVoice.play();
407 | } catch (e) {
408 | reject(e);
409 | }
410 | });
411 | return;
412 | } catch (e) {
413 | console.log(e);
414 | }
415 | }
416 | // If we errored, fallback to browser TTS
417 | let isIOS = /iPad|iPhone|iPod/.test(navigator.platform);
418 | if (isIOS) {
419 | // on iOS speech synthesis just never returns
420 | await new Promise((resolve) => setTimeout(resolve, 2000));
421 | return;
422 | }
423 | await new Promise((resolve) => {
424 | window.speechSynthesis.cancel();
425 | const utterThis = new SpeechSynthesisUtterance(text);
426 | // let retryCount = 0;
427 | // while (speechSynthesis.getVoices().length === 0 && retryCount < 3) {
428 | // retryCount += 1;
429 | // await new Promise((resolve) => setTimeout(resolve, 500));
430 | // }
431 | let voices = window.speechSynthesis.getVoices();
432 | let target = voices.find(
433 | (voice) => voice.name === 'Google UK English Male',
434 | );
435 | if (target) {
436 | utterThis.voice = target;
437 | }
438 | window.speechSynthesis.speak(utterThis);
439 | utterThis.onend = resolve;
440 | utterThis.onerror = resolve;
441 | });
442 | };
443 |
444 | pickQ = (id: string) => {
445 | this.socket?.emit('JPD:pickQ', id);
446 | };
447 |
448 | submitWager = () => {
449 | this.socket?.emit('JPD:wager', this.state.localWager);
450 | this.setState({ localWager: '', localWagerSubmitted: true });
451 | };
452 |
453 | submitAnswer = (answer = null) => {
454 | if (!this.state.localAnswerSubmitted) {
455 | this.socket?.emit(
456 | 'JPD:answer',
457 | this.state.game?.currentQ,
458 | answer || this.state.localAnswer,
459 | );
460 | this.setState({ localAnswer: '', localAnswerSubmitted: true });
461 | }
462 | };
463 |
464 | judgeAnswer = (id: string, correct: boolean | null) => {
465 | this.socket?.emit('JPD:judge', {
466 | currentQ: this.state.game?.currentQ,
467 | id,
468 | correct,
469 | });
470 | };
471 |
472 | bulkJudgeAnswer = (data: { id: string; correct: boolean | null }[]) => {
473 | this.socket?.emit(
474 | 'JPD:bulkJudge',
475 | data.map((d) => ({ ...d, currentQ: this.state.game?.currentQ })),
476 | );
477 | };
478 |
479 | getCategories = () => {
480 | const game = this.state.game;
481 | if (!game || !game.board) {
482 | return [];
483 | }
484 | let categories: string[] = Array(6).fill('');
485 | Object.keys(game.board).forEach((key) => {
486 | const col = Number(key.split('_')[0]) - 1;
487 | categories[col] = game.board[key].category;
488 | });
489 | return categories;
490 | };
491 |
492 | getWinners = () => {
493 | const max =
494 | Math.max(...Object.values(this.state.game?.scores || {})) || 0;
495 | return this.props.participants
496 | .filter((p) => (this.state.game?.scores[p.id] || 0) === max)
497 | .map((p) => p.id);
498 | };
499 |
500 | getBuzzOffset = (id: string) => {
501 | if (!this.state.game?.buzzUnlockTS) {
502 | return 0;
503 | }
504 | return this.state.game?.buzzes[id] - this.state.game?.buzzUnlockTS;
505 | };
506 |
507 | onBuzz = () => {
508 | const game = this.state.game;
509 | if (game?.canBuzz && !this.buzzLock && !this.state.buzzFrozen) {
510 | this.socket?.emit('JPD:buzz');
511 | } else {
512 | // Freeze the buzzer for 0.25 seconds
513 | // setState takes a little bit, so also set a local var to prevent spam
514 | const now = Date.now();
515 | this.buzzLock = now;
516 | this.setState({ buzzFrozen: true });
517 | setTimeout(() => {
518 | if (this.buzzLock === now) {
519 | this.setState({ buzzFrozen: false });
520 | this.buzzLock = 0;
521 | }
522 | }, 250);
523 | }
524 | };
525 |
526 | saveSettings = (settings: GameSettings) => {
527 | // serialize to localStorage so settings persist on reload
528 | window.localStorage.setItem(
529 | 'jeopardy-gameSettings',
530 | JSON.stringify(settings),
531 | );
532 | // update state
533 | this.setState({ settings });
534 | };
535 |
536 | render() {
537 | const game = this.state.game;
538 | const categories = this.getCategories();
539 | const participants = this.props.participants;
540 | const canJudge = !game?.host || this.socket?.id === game?.host;
541 | return (
542 | <>
543 | {this.state.showCustomModal && (
544 | this.setState({ showCustomModal: false })}>
545 | Custom Game
546 |
547 |
548 |
549 | You can create and play a custom game by uploading a data
550 | file. Download the example and customize with your own
551 | questions and answers.
552 |
553 |
560 |
561 | Download Example .csv
562 |
563 |
564 |
565 |
566 | Once you're done, upload your file:
567 |
568 | this.customGame()}
570 | icon
571 | labelPosition="left"
572 | color="purple"
573 | >
574 |
575 | Upload
576 |
577 |
578 |
579 |
580 |
581 | )}
582 | {this.state.showJudgingModal && (
583 | this.setState({ showJudgingModal: false })}
588 | getBuzzOffset={this.getBuzzOffset}
589 | />
590 | )}
591 | {this.state.showSettingsModal && (
592 | this.setState({ showSettingsModal: false })}
594 | onSubmit={this.saveSettings}
595 | settings={this.state.settings}
596 | />
597 | )}
598 | {this.state.overlayMsg && }
599 |
607 | {
608 |
609 | {
610 |
611 |
617 | {this.state.isIntroPlaying &&
}
618 | {categories.map((cat, i) => (
619 |
620 | {this.state.categoryMask[i] ? cat : ''}
621 |
622 | ))}
623 | {Array.from(Array(5)).map((_, i) => {
624 | return (
625 |
626 | {categories.map((cat, j) => {
627 | const id = `${j + 1}_${i + 1}`;
628 | const clue = game?.board[id];
629 | return (
630 | this.pickQ(id) : undefined
635 | }
636 | className={`${clue ? 'value' : ''} box`}
637 | >
638 | {!this.state.clueMask[id] && clue
639 | ? clue.value
640 | : ''}
641 |
642 | );
643 | })}
644 |
645 | );
646 | })}
647 | {game && Boolean(game.currentQ) && (
648 |
652 |
660 |
661 | {game.board[game.currentQ] &&
662 | game.board[game.currentQ].category}
663 |
664 |
665 | {Boolean(game.currentValue) && game.currentValue}
666 |
667 | {
668 |
669 |
(
682 |
691 | ),
692 | }}
693 | >
694 | {game.board[game.currentQ] &&
695 | game.board[game.currentQ].question}
696 |
697 |
698 | }
699 |
700 | {!game.currentAnswer &&
701 | this.socket &&
702 | !game.buzzes[this.socket.id!] &&
703 | !game.submitted[this.socket.id!] &&
704 | !game.currentDailyDouble &&
705 | game.round !== 'final' ? (
706 |
707 |
715 |
718 | Buzz
719 |
720 |
728 | {
732 | if (game.canBuzz) {
733 | this.submitAnswer(null);
734 | }
735 | }}
736 | icon
737 | labelPosition="left"
738 | >
739 |
742 | Pass
743 |
744 |
745 |
746 | ) : null}
747 | {!game.currentAnswer &&
748 | !this.state.localAnswerSubmitted &&
749 | this.socket &&
750 | game.buzzes[this.socket.id!] &&
751 | game.questionEndTS ? (
752 |
757 | this.setState({ localAnswer: e.target.value })
758 | }
759 | onKeyPress={(e: any) =>
760 | e.key === 'Enter' && this.submitAnswer()
761 | }
762 | icon={
763 |
this.submitAnswer()}
765 | name="arrow right"
766 | inverted
767 | circular
768 | link
769 | />
770 | }
771 | />
772 | ) : null}
773 | {game.waitingForWager &&
774 | this.socket &&
775 | game.waitingForWager[this.socket.id!] ? (
776 |
790 | this.setState({ localWager: e.target.value })
791 | }
792 | onKeyPress={(e: any) =>
793 | e.key === 'Enter' && this.submitWager()
794 | }
795 | icon={
796 | this.submitWager()}
798 | name="arrow right"
799 | inverted
800 | circular
801 | link
802 | />
803 | }
804 | />
805 | ) : null}
806 |
807 |
808 | {game.currentAnswer}
809 |
810 | {Boolean(game.playClueEndTS) && (
811 |
812 | )}
813 | {Boolean(game.questionEndTS) && (
814 |
819 | )}
820 | {Boolean(game.wagerEndTS) && (
821 |
825 | )}
826 | {game.canNextQ && (
827 |
834 | this.socket?.emit('JPD:skipQ')}
836 | icon
837 | labelPosition="left"
838 | >
839 |
840 | Next
841 |
842 |
843 | )}
844 | {game.currentAnswer && canJudge && (
845 |
852 |
854 | this.setState({ showJudgingModal: true })
855 | }
856 | icon
857 | labelPosition="left"
858 | >
859 |
860 | Bulk Judge
861 |
862 |
863 | )}
864 |
865 |
866 | )}
867 | {game && game.round === 'end' && (
868 |
869 |
Winner!
870 |
871 | {this.getWinners().map((winnerId: string) => (
872 |
p.id === winnerId)
878 | ?.name ?? '',
879 | getColorHex(winnerId),
880 | )}
881 | />
882 | ))}
883 |
884 |
885 | )}
886 |
887 |
888 | }
889 |
892 | {participants.map((p) => {
893 | return (
894 |
895 |
896 |
903 |
917 |
929 | {p.name || p.id}
930 |
931 |
932 | {game && p.id in game.wagers ? (
933 |
940 |
941 | {game.wagers[p.id] || 0}
942 |
943 |
944 | ) : null}
945 | {game && (
946 |
947 | {!game.picker || game.picker === p.id ? (
948 |
952 | ) : null}
953 | {game.host && game.host === p.id ? (
954 |
955 | ) : null}
956 | {!p.connected ? (
957 |
962 | ) : null}
963 |
964 | )}
965 | {game &&
966 | p.id === game.currentJudgeAnswer &&
967 | canJudge ? (
968 |
969 |
this.judgeAnswer(p.id, true)}
974 | color="green"
975 | size="tiny"
976 | icon
977 | fluid
978 | >
979 |
980 |
981 | }
982 | />
983 | this.judgeAnswer(p.id, false)}
988 | color="red"
989 | size="tiny"
990 | icon
991 | fluid
992 | >
993 |
994 |
995 | }
996 | />
997 | this.judgeAnswer(p.id, null)}
1002 | color="grey"
1003 | size="tiny"
1004 | icon
1005 | fluid
1006 | >
1007 |
1008 |
1009 | }
1010 | />
1011 |
1012 | ) : null}
1013 |
1014 |
1019 | {(game?.scores[p.id] || 0).toLocaleString()}
1020 |
1021 |
1026 | {game && game.answers[p.id]}
1027 |
1028 | {this.getBuzzOffset(p.id) &&
1029 | this.getBuzzOffset(p.id) > 0
1030 | ? `+${(this.getBuzzOffset(p.id) / 1000).toFixed(3)}`
1031 | : ''}
1032 |
1033 |
1034 |
1035 | );
1036 | })}
1037 |
1038 |
1046 |
1053 |
1054 | {[
1055 | { key: 'all', value: null, text: 'Any' },
1056 | { key: 'kids', value: 'kids', text: 'Kids Week' },
1057 | { key: 'teen', value: 'teen', text: 'Teen Tournament' },
1058 | {
1059 | key: 'college',
1060 | value: 'college',
1061 | text: 'College Championship',
1062 | },
1063 | {
1064 | key: 'celebrity',
1065 | value: 'celebrity',
1066 | text: 'Celebrity Jeopardy',
1067 | },
1068 | {
1069 | key: 'teacher',
1070 | value: 'teacher',
1071 | text: 'Teachers Tournament',
1072 | },
1073 | {
1074 | key: 'champions',
1075 | value: 'champions',
1076 | text: 'Tournament of Champions',
1077 | },
1078 | {
1079 | key: 'custom',
1080 | value: 'custom',
1081 | text: 'Custom Game',
1082 | },
1083 | ].map((item) => (
1084 | {
1087 | if (item.value === 'custom') {
1088 | this.setState({ showCustomModal: true });
1089 | } else {
1090 | this.newGame({ filter: item.value ?? undefined });
1091 | }
1092 | }}
1093 | >
1094 | {item.text}
1095 |
1096 | ))}
1097 |
1098 |
1099 |
1105 | this.setState({ localEpNum: data.value })
1106 | }
1107 | onKeyPress={(e: any) =>
1108 | e.key === 'Enter' &&
1109 | this.newGame({ number: this.state.localEpNum })
1110 | }
1111 | icon={
1112 |
1114 | this.newGame({ number: this.state.localEpNum })
1115 | }
1116 | name="arrow right"
1117 | inverted
1118 | circular
1119 | />
1120 | }
1121 | />
1122 | {game && game.airDate && (
1123 |
1132 | {new Date(game.airDate + 'T00:00').toLocaleDateString([], {
1133 | year: 'numeric',
1134 | month: 'long',
1135 | day: 'numeric',
1136 | })}
1137 | {game && game.info ? ' - ' + game.info : ''}
1138 |
1139 | )}
1140 | {
1145 | const checked = !this.state.readingDisabled;
1146 | this.setState({ readingDisabled: checked });
1147 | if (checked) {
1148 | window.localStorage.setItem(
1149 | 'jeopardy-readingDisabled',
1150 | '1',
1151 | );
1152 | } else {
1153 | window.localStorage.removeItem(
1154 | 'jeopardy-readingDisabled',
1155 | );
1156 | }
1157 | }}
1158 | >
1159 |
1160 | {this.state.readingDisabled ? 'Reading off' : 'Reading on'}
1161 |
1162 | {
1167 | this.socket?.emit(
1168 | 'JPD:enableAiJudge',
1169 | !Boolean(game?.enableAIJudge),
1170 | );
1171 | }}
1172 | >
1173 |
1174 | {game?.enableAIJudge ? 'AI Judge on' : 'AI Judge off'}
1175 |
1176 | this.setState({ showSettingsModal: true })}
1178 | icon
1179 | labelPosition="left"
1180 | >
1181 |
1182 | Settings
1183 |
1184 | {canJudge && (
1185 | this.socket?.emit('JPD:undo')}
1187 | icon
1188 | labelPosition="left"
1189 | >
1190 |
1191 | Undo Judge
1192 |
1193 | )}
1194 | {/* this.socket?.emit('JPD:cmdIntro')}
1196 | icon
1197 | labelPosition="left"
1198 | color="blue"
1199 | >
1200 |
1201 | Play Intro
1202 | */}
1203 |
1204 |
1205 | }
1206 | {false && process.env.NODE_ENV === 'development' && (
1207 |
1210 | {JSON.stringify(game, null, 2)}
1211 |
1212 | )}
1213 |
1214 | >
1215 | );
1216 | }
1217 | }
1218 |
1219 | function TimerBar({duration, secondary, submitAnswer}: {
1220 | duration: number;
1221 | secondary?: boolean;
1222 | submitAnswer?: Function;
1223 | }) {
1224 | const [width, setWidth] = useState(0);
1225 | useEffect(() => {
1226 | requestAnimationFrame(() => {
1227 | setWidth(100);
1228 | });
1229 | let submitTimeout: number | undefined;
1230 | if (submitAnswer) {
1231 | // Submit whatever's in the box 0.5s before expected timeout
1232 | // Bit hacky, but to fix we either need to submit updates on each character
1233 | // Or have a separate step where the server instructs all clients to submit whatever is in box and accepts it
1234 | submitTimeout = window.setTimeout(
1235 | submitAnswer,
1236 | duration - 500,
1237 | );
1238 | }
1239 | return () => {
1240 | if (submitTimeout) {
1241 | window.clearTimeout(submitTimeout);
1242 | }
1243 | }
1244 | }, []);
1245 | return (
1246 |
1257 | );
1258 | }
1259 |
1260 | function getWagerBounds(round: string, score: number) {
1261 | // User setting a wager for DD or final
1262 | // Can bet up to current score, minimum of 1000 in single or 2000 in double, 0 in final
1263 | let maxWager = 0;
1264 | let minWager = 5;
1265 | if (round === 'jeopardy') {
1266 | maxWager = Math.max(score || 0, 1000);
1267 | } else if (round === 'double') {
1268 | maxWager = Math.max(score || 0, 2000);
1269 | } else if (round === 'final') {
1270 | minWager = 0;
1271 | maxWager = Math.max(score || 0, 0);
1272 | }
1273 | return { minWager, maxWager };
1274 | }
1275 |
1276 | const BulkJudgeModal = ({
1277 | onClose,
1278 | game,
1279 | participants,
1280 | bulkJudge,
1281 | }: {
1282 | onClose: () => void;
1283 | game: PublicGameState | undefined;
1284 | participants: User[];
1285 | bulkJudge: (judges: { id: string; correct: boolean | null }[]) => void;
1286 | getBuzzOffset: (id: string) => number;
1287 | }) => {
1288 | const [decisions, setDecisions] = useState>({});
1289 | const distinctAnswers: string[] = Array.from(
1290 | new Set(
1291 | Object.values(game?.answers ?? {}).map(
1292 | (answer: string) => answer?.toLowerCase()?.trim(),
1293 | ),
1294 | ),
1295 | );
1296 | return (
1297 |
1298 | {game?.currentAnswer}
1299 |
1300 |
1301 |
1302 | Answer
1303 | Decision
1304 | Responses
1305 |
1306 | {distinctAnswers.map((answer) => {
1307 | return (
1308 |
1309 | {answer}
1310 |
1311 | {
1320 | const newDecisions = {
1321 | ...decisions,
1322 | [answer]: data.value as string,
1323 | };
1324 | setDecisions(newDecisions);
1325 | }}
1326 | >
1327 |
1328 |
1329 | {participants
1330 | .filter(
1331 | (p) =>
1332 | game?.answers[p.id]?.toLowerCase()?.trim() === answer,
1333 | )
1334 | .map((p) => {
1335 | return (
1336 |
1344 | );
1345 | })}
1346 |
1347 |
1348 | );
1349 | })}
1350 |
1351 |
1352 |
1353 | {
1355 | const answers = Object.entries(game?.answers || {});
1356 | // Assemble the bulk judges
1357 | const arr = answers.map((ans) => {
1358 | // Look up the answer and decision
1359 | const answer = ans[1]?.toLowerCase()?.trim();
1360 | const decision = decisions[answer];
1361 | return {
1362 | id: ans[0],
1363 | correct: decision === 'skip' ? null : JSON.parse(decision),
1364 | };
1365 | });
1366 | bulkJudge(arr);
1367 | // Close the modal
1368 | onClose();
1369 | }}
1370 | >
1371 | Bulk Judge
1372 |
1373 |
1374 |
1375 | );
1376 | };
1377 |
1378 | const SettingsModal = ({
1379 | onClose,
1380 | onSubmit,
1381 | settings,
1382 | }: {
1383 | onClose: () => void;
1384 | onSubmit: (settings: GameSettings) => void;
1385 | settings: GameSettings;
1386 | }) => {
1387 | const [answerTimeout, setAnswerTimeout] = useState(
1388 | settings.answerTimeout,
1389 | );
1390 | const [finalTimeout, setFinalTimeout] = useState(
1391 | settings.finalTimeout,
1392 | );
1393 | const [makeMeHost, setMakeMeHost] = useState(
1394 | settings.makeMeHost,
1395 | );
1396 | const [allowMultipleCorrect, setAllowMultipleCorrect] = useState<
1397 | boolean | undefined
1398 | >(settings.allowMultipleCorrect);
1399 | const [enableAIJudge, setEnableAIJudge] = useState(
1400 | settings.enableAIJudge,
1401 | );
1402 | return (
1403 |
1404 | Settings
1405 |
1406 | Settings will be applied to any games you create.
1407 |
1447 |
1448 |
1449 | {
1451 | const settings: GameSettings = {
1452 | makeMeHost: Boolean(makeMeHost),
1453 | allowMultipleCorrect: Boolean(allowMultipleCorrect),
1454 | enableAIJudge: Boolean(enableAIJudge),
1455 | answerTimeout: Number(answerTimeout),
1456 | finalTimeout: Number(finalTimeout),
1457 | };
1458 | onSubmit(settings);
1459 | onClose();
1460 | }}
1461 | >
1462 | Save
1463 |
1464 |
1465 |
1466 | );
1467 | };
1468 |
1469 | export const ErrorModal = ({ error }: { error: string }) => {
1470 | return (
1471 |
1472 |
1475 |
1476 | {
1480 | window.location.href = '/';
1481 | }}
1482 | icon
1483 | labelPosition="left"
1484 | >
1485 |
1486 | Go to home page
1487 |
1488 |
1489 |
1490 | );
1491 | };
1492 |
--------------------------------------------------------------------------------
/src/components/Jeopardy/gyparody.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/howardchung/jeopardy/ce64b280bc5adafad56b2c2f0abede988cc0d524/src/components/Jeopardy/gyparody.ttf
--------------------------------------------------------------------------------
/src/components/Jeopardy/jeopardy-daily-double.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/howardchung/jeopardy/ce64b280bc5adafad56b2c2f0abede988cc0d524/src/components/Jeopardy/jeopardy-daily-double.png
--------------------------------------------------------------------------------
/src/components/Jeopardy/jeopardy-game-board-daily-double.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/howardchung/jeopardy/ce64b280bc5adafad56b2c2f0abede988cc0d524/src/components/Jeopardy/jeopardy-game-board-daily-double.png
--------------------------------------------------------------------------------
/src/components/Jeopardy/korinna-regular.otf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/howardchung/jeopardy/ce64b280bc5adafad56b2c2f0abede988cc0d524/src/components/Jeopardy/korinna-regular.otf
--------------------------------------------------------------------------------
/src/components/Jeopardy/swiss911-xcm-bt.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/howardchung/jeopardy/ce64b280bc5adafad56b2c2f0abede988cc0d524/src/components/Jeopardy/swiss911-xcm-bt.ttf
--------------------------------------------------------------------------------
/src/components/Jeopardy/univers-75-black.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/howardchung/jeopardy/ce64b280bc5adafad56b2c2f0abede988cc0d524/src/components/Jeopardy/univers-75-black.ttf
--------------------------------------------------------------------------------
/src/components/TopBar/TopBar.tsx:
--------------------------------------------------------------------------------
1 | import React, { useCallback } from 'react';
2 | import { serverPath } from '../../utils';
3 | import { Icon, Popup, Button } from 'semantic-ui-react';
4 | import '../Jeopardy/Jeopardy.css';
5 |
6 | export function NewRoomButton({size}: { size?: string }) {
7 | const createRoom = useCallback(async () => {
8 | const response = await window.fetch(serverPath + '/createRoom', {
9 | method: 'POST',
10 | headers: {
11 | 'Content-Type': 'application/json',
12 | },
13 | body: JSON.stringify({}),
14 | });
15 | const data = await response.json();
16 | const { name } = data;
17 | const searchParams = new URLSearchParams(window.location.search);
18 | searchParams.set('game', name);
19 | window.location.search = searchParams.toString();
20 | }, []);
21 | return (
22 |
34 |
35 | New Room
36 |
37 | }
38 | />
39 | );
40 | }
41 |
42 | export function JeopardyTopBar({ hideNewRoom }: { hideNewRoom?: boolean }) {
43 | return (
44 |
45 |
106 |
107 | );
108 | }
109 |
--------------------------------------------------------------------------------
/src/index.jsx:
--------------------------------------------------------------------------------
1 | import 'semantic-ui-css/semantic.min.css';
2 |
3 | import { createRoot } from 'react-dom/client';
4 |
5 | import App from './components/App';
6 | import { JeopardyHome } from './components/Home/Home';
7 | import React from 'react';
8 |
9 | const urlParams = new URLSearchParams(window.location.search);
10 | const gameId = urlParams.get('game');
11 | const isHome = !Boolean(gameId);
12 | const container = document.getElementById('root');
13 | const root = createRoot(container);
14 | root.render(isHome ? : );
15 |
--------------------------------------------------------------------------------
/src/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "esModuleInterop": true,
8 | "allowSyntheticDefaultImports": true,
9 | "strict": true,
10 | "forceConsistentCasingInFileNames": true,
11 | "module": "esnext",
12 | "moduleResolution": "node",
13 | "resolveJsonModule": true,
14 | "isolatedModules": true,
15 | "noEmit": true,
16 | "jsx": "react-jsx",
17 | "noFallthroughCasesInSwitch": true,
18 | "types": ["vite/client", "node"]
19 | },
20 | "include": ["./**/*", "../global.d.ts"]
21 | }
22 |
--------------------------------------------------------------------------------
/src/utils.ts:
--------------------------------------------------------------------------------
1 | import { cyrb53 } from '../server/hash';
2 |
3 | export function formatTimestamp(input: any) {
4 | if (
5 | input === null ||
6 | input === undefined ||
7 | input === false ||
8 | Number.isNaN(input) ||
9 | input === Infinity
10 | ) {
11 | return '';
12 | }
13 | let minutes = Math.floor(Number(input) / 60);
14 | let seconds = Math.floor(Number(input) % 60)
15 | .toString()
16 | .padStart(2, '0');
17 | return `${minutes}:${seconds}`;
18 | }
19 |
20 | let colorCache: Record = {};
21 | export function getColor(id: string) {
22 | let colors = [
23 | 'red',
24 | 'orange',
25 | 'yellow',
26 | 'olive',
27 | 'green',
28 | 'teal',
29 | 'blue',
30 | 'violet',
31 | 'purple',
32 | 'pink',
33 | 'brown',
34 | 'grey',
35 | ];
36 | if (colorCache[id]) {
37 | return colors[colorCache[id]];
38 | }
39 | colorCache[id] = Math.abs(cyrb53(id)) % colors.length;
40 | return colors[colorCache[id]];
41 | }
42 |
43 | export function getColorHex(id: string) {
44 | let mappings: Record = {
45 | red: 'B03060',
46 | orange: 'FE9A76',
47 | yellow: 'FFD700',
48 | olive: '32CD32',
49 | green: '016936',
50 | teal: '008080',
51 | blue: '0E6EB8',
52 | violet: 'EE82EE',
53 | purple: 'B413EC',
54 | pink: 'FF1493',
55 | brown: 'A52A2A',
56 | grey: 'A0A0A0',
57 | black: '000000',
58 | };
59 | return mappings[getColor(id)];
60 | }
61 |
62 | export function decodeEntities(input: string) {
63 | const doc = new DOMParser().parseFromString(input, 'text/html');
64 | return doc.documentElement.textContent;
65 | }
66 |
67 | export const getDefaultPicture = (name: string, background = 'a0a0a0') => {
68 | return `https://ui-avatars.com/api/?name=${name}&background=${background}&size=256&color=ffffff`;
69 | };
70 |
71 | export const isMobile = () => {
72 | return window.screen.width <= 600;
73 | };
74 |
75 | export function shuffle(array: any[]) {
76 | for (let i = array.length - 1; i > 0; i--) {
77 | const j = Math.floor(Math.random() * i);
78 | const temp = array[i];
79 | array[i] = array[j];
80 | array[j] = temp;
81 | }
82 | }
83 |
84 | export const serverPath =
85 | import.meta.env.VITE_SERVER_HOST ||
86 | `${window.location.protocol}//${window.location.hostname}${
87 | process.env.NODE_ENV === 'production' ? '' : ':8083'
88 | }`;
89 |
90 | export async function generateName(): Promise {
91 | const response = await fetch(serverPath + '/generateName');
92 | return response.text();
93 | }
94 |
95 | export function getOrCreateClientId() {
96 | let clientId = window.localStorage.getItem('jeopardy-clientid');
97 | if (!clientId) {
98 | // Generate a new clientID and save it
99 | clientId = crypto.randomUUID ? crypto.randomUUID() : uuidv4();
100 | window.localStorage.setItem('jeopardy-clientid', clientId);
101 | }
102 | return clientId;
103 | }
104 |
105 | function uuidv4() {
106 | return '10000000-1000-4000-8000-100000000000'.replace(/[018]/g, (c) =>
107 | (
108 | +c ^
109 | (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (+c / 4)))
110 | ).toString(16),
111 | );
112 | }
113 |
--------------------------------------------------------------------------------
/vite.config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | build: {
3 | outDir: 'build',
4 | },
5 | server: {
6 | https: process.env.SSL_CRT_FILE
7 | ? {
8 | key: fs.readFileSync(process.env.SSL_KEY_FILE),
9 | cert: fs.readFileSync(process.env.SSL_CRT_FILE),
10 | }
11 | : null,
12 | },
13 | };
14 |
--------------------------------------------------------------------------------
/words/adjectives.txt:
--------------------------------------------------------------------------------
1 | aback
2 | abaft
3 | abandoned
4 | abashed
5 | aberrant
6 | abhorrent
7 | abiding
8 | abject
9 | ablaze
10 | able
11 | abnormal
12 | aboard
13 | aboriginal
14 | abortive
15 | abounding
16 | abrasive
17 | abrupt
18 | absent
19 | absorbed
20 | absorbing
21 | abstracted
22 | absurd
23 | abundant
24 | abusive
25 | acceptable
26 | accessible
27 | accidental
28 | accurate
29 | acid
30 | acidic
31 | acoustic
32 | acrid
33 | actually
34 | ad hoc
35 | adamant
36 | adaptable
37 | addicted
38 | adhesive
39 | adjoining
40 | adorable
41 | adventurous
42 | afraid
43 | aggressive
44 | agonizing
45 | agreeable
46 | ahead
47 | ajar
48 | alcoholic
49 | alert
50 | alike
51 | alive
52 | alleged
53 | alluring
54 | aloof
55 | amazing
56 | ambiguous
57 | ambitious
58 | amuck
59 | amused
60 | amusing
61 | ancient
62 | angry
63 | animated
64 | annoyed
65 | annoying
66 | anxious
67 | apathetic
68 | aquatic
69 | aromatic
70 | arrogant
71 | ashamed
72 | aspiring
73 | assorted
74 | astonishing
75 | attractive
76 | auspicious
77 | automatic
78 | available
79 | average
80 | awake
81 | aware
82 | awesome
83 | awful
84 | axiomatic
85 | bad
86 | barbarous
87 | bashful
88 | bawdy
89 | beautiful
90 | befitting
91 | belligerent
92 | beneficial
93 | bent
94 | berserk
95 | best
96 | better
97 | bewildered
98 | big
99 | billowy
100 | bite-sized
101 | bitter
102 | bizarre
103 | black
104 | black-and-white
105 | bloody
106 | blue
107 | blue-eyed
108 | blushing
109 | boiling
110 | boorish
111 | bored
112 | boring
113 | bouncy
114 | boundless
115 | brainy
116 | brash
117 | brave
118 | brawny
119 | breakable
120 | breezy
121 | brief
122 | bright
123 | bright
124 | broad
125 | broken
126 | brown
127 | bumpy
128 | burly
129 | bustling
130 | busy
131 | cagey
132 | calculating
133 | callous
134 | calm
135 | capable
136 | capricious
137 | careful
138 | careless
139 | caring
140 | cautious
141 | ceaseless
142 | certain
143 | changeable
144 | charming
145 | cheap
146 | cheerful
147 | chemical
148 | chief
149 | childlike
150 | chilly
151 | chivalrous
152 | chubby
153 | chunky
154 | clammy
155 | classy
156 | clean
157 | clear
158 | clever
159 | cloistered
160 | cloudy
161 | closed
162 | clumsy
163 | cluttered
164 | coherent
165 | cold
166 | colorful
167 | colossal
168 | combative
169 | comfortable
170 | common
171 | complete
172 | complex
173 | concerned
174 | condemned
175 | confused
176 | conscious
177 | cooing
178 | cool
179 | cooperative
180 | coordinated
181 | courageous
182 | cowardly
183 | crabby
184 | craven
185 | crazy
186 | creepy
187 | crooked
188 | crowded
189 | cruel
190 | cuddly
191 | cultured
192 | cumbersome
193 | curious
194 | curly
195 | curved
196 | curvy
197 | cut
198 | cute
199 | cute
200 | cynical
201 | daffy
202 | daily
203 | damaged
204 | damaging
205 | damp
206 | dangerous
207 | dapper
208 | dark
209 | dashing
210 | dazzling
211 | dead
212 | deadpan
213 | deafening
214 | dear
215 | debonair
216 | decisive
217 | decorous
218 | deep
219 | deeply
220 | defeated
221 | defective
222 | defiant
223 | delicate
224 | delicious
225 | delightful
226 | demonic
227 | delirious
228 | dependent
229 | depressed
230 | deranged
231 | descriptive
232 | deserted
233 | detailed
234 | determined
235 | devilish
236 | didactic
237 | different
238 | difficult
239 | diligent
240 | direful
241 | dirty
242 | disagreeable
243 | disastrous
244 | discreet
245 | disgusted
246 | disgusting
247 | disillusioned
248 | dispensable
249 | distinct
250 | disturbed
251 | divergent
252 | dizzy
253 | domineering
254 | doubtful
255 | drab
256 | draconian
257 | dramatic
258 | dreary
259 | drunk
260 | dry
261 | dull
262 | dusty
263 | dusty
264 | dynamic
265 | dysfunctional
266 | eager
267 | early
268 | earsplitting
269 | earthy
270 | easy
271 | eatable
272 | economic
273 | educated
274 | efficacious
275 | efficient
276 | eight
277 | elastic
278 | elated
279 | elderly
280 | electric
281 | elegant
282 | elfin
283 | elite
284 | embarrassed
285 | eminent
286 | empty
287 | enchanted
288 | enchanting
289 | encouraging
290 | endurable
291 | energetic
292 | enormous
293 | entertaining
294 | enthusiastic
295 | envious
296 | equable
297 | equal
298 | erect
299 | erratic
300 | ethereal
301 | evanescent
302 | evasive
303 | even
304 | excellent
305 | excited
306 | exciting
307 | exclusive
308 | exotic
309 | expensive
310 | extra-large
311 | extra-small
312 | exuberant
313 | exultant
314 | fabulous
315 | faded
316 | faint
317 | fair
318 | faithful
319 | fallacious
320 | false
321 | familiar
322 | famous
323 | fanatical
324 | fancy
325 | fantastic
326 | far
327 | far-flung
328 | fascinated
329 | fast
330 | fat
331 | faulty
332 | fearful
333 | fearless
334 | feeble
335 | feigned
336 | female
337 | fertile
338 | festive
339 | few
340 | fierce
341 | filthy
342 | fine
343 | finicky
344 | first
345 | five
346 | fixed
347 | flagrant
348 | flaky
349 | flashy
350 | flat
351 | flawless
352 | flimsy
353 | flippant
354 | flowery
355 | fluffy
356 | fluttering
357 | foamy
358 | foolish
359 | foregoing
360 | forgetful
361 | fortunate
362 | four
363 | frail
364 | fragile
365 | frantic
366 | free
367 | freezing
368 | frequent
369 | fresh
370 | fretful
371 | friendly
372 | frightened
373 | frightening
374 | full
375 | fumbling
376 | functional
377 | funny
378 | furry
379 | furtive
380 | future
381 | futuristic
382 | fuzzy
383 | gabby
384 | gainful
385 | gamy
386 | gaping
387 | garrulous
388 | gaudy
389 | general
390 | gentle
391 | giant
392 | giddy
393 | gifted
394 | gigantic
395 | glamorous
396 | gleaming
397 | glib
398 | glistening
399 | glorious
400 | glossy
401 | godly
402 | good
403 | goofy
404 | gorgeous
405 | graceful
406 | grandiose
407 | grateful
408 | gratis
409 | gray
410 | greasy
411 | great
412 | greedy
413 | green
414 | grey
415 | grieving
416 | groovy
417 | grotesque
418 | grouchy
419 | grubby
420 | gruesome
421 | grumpy
422 | guarded
423 | guiltless
424 | gullible
425 | gusty
426 | guttural
427 | habitual
428 | half
429 | hallowed
430 | halting
431 | handsome
432 | handsomely
433 | handy
434 | hanging
435 | hapless
436 | happy
437 | hard
438 | hard-to-find
439 | harmonious
440 | harsh
441 | hateful
442 | heady
443 | healthy
444 | heartbreaking
445 | heavenly
446 | heavy
447 | hellish
448 | helpful
449 | helpless
450 | hesitant
451 | hideous
452 | high
453 | highfalutin
454 | high-pitched
455 | hilarious
456 | hissing
457 | historical
458 | holistic
459 | hollow
460 | homeless
461 | homely
462 | honorable
463 | horrible
464 | hospitable
465 | hot
466 | huge
467 | hulking
468 | humdrum
469 | humorous
470 | hungry
471 | hurried
472 | hurt
473 | hushed
474 | husky
475 | hypnotic
476 | hysterical
477 | icky
478 | icy
479 | idiotic
480 | ignorant
481 | ill
482 | illegal
483 | ill-fated
484 | ill-informed
485 | illustrious
486 | imaginary
487 | immense
488 | imminent
489 | impartial
490 | imperfect
491 | impolite
492 | important
493 | imported
494 | impossible
495 | incandescent
496 | incompetent
497 | inconclusive
498 | industrious
499 | incredible
500 | inexpensive
501 | infamous
502 | innate
503 | innocent
504 | inquisitive
505 | insidious
506 | instinctive
507 | intelligent
508 | interesting
509 | internal
510 | invincible
511 | irate
512 | irritating
513 | itchy
514 | jaded
515 | jagged
516 | jazzy
517 | jealous
518 | jittery
519 | jobless
520 | jolly
521 | joyous
522 | judicious
523 | juicy
524 | jumbled
525 | jumpy
526 | juvenile
527 | kaput
528 | keen
529 | kind
530 | kindhearted
531 | kindly
532 | knotty
533 | knowing
534 | knowledgeable
535 | known
536 | labored
537 | lackadaisical
538 | lacking
539 | lame
540 | lamentable
541 | languid
542 | large
543 | last
544 | late
545 | laughable
546 | lavish
547 | lazy
548 | lean
549 | learned
550 | left
551 | legal
552 | lethal
553 | level
554 | lewd
555 | light
556 | like
557 | likeable
558 | limping
559 | literate
560 | little
561 | lively
562 | lively
563 | living
564 | lonely
565 | long
566 | longing
567 | long-term
568 | loose
569 | lopsided
570 | loud
571 | loutish
572 | lovely
573 | loving
574 | low
575 | lowly
576 | lucky
577 | ludicrous
578 | lumpy
579 | lush
580 | luxuriant
581 | lying
582 | lyrical
583 | macabre
584 | macho
585 | maddening
586 | madly
587 | magenta
588 | magical
589 | magnificent
590 | majestic
591 | makeshift
592 | male
593 | malicious
594 | mammoth
595 | maniacal
596 | many
597 | marked
598 | massive
599 | married
600 | marvelous
601 | material
602 | materialistic
603 | mature
604 | mean
605 | measly
606 | meaty
607 | medical
608 | meek
609 | mellow
610 | melodic
611 | melted
612 | merciful
613 | mere
614 | messy
615 | mighty
616 | military
617 | milky
618 | mindless
619 | miniature
620 | minor
621 | miscreant
622 | misty
623 | mixed
624 | moaning
625 | modern
626 | moldy
627 | momentous
628 | motionless
629 | mountainous
630 | muddled
631 | mundane
632 | murky
633 | mushy
634 | mute
635 | mysterious
636 | naive
637 | nappy
638 | narrow
639 | nasty
640 | natural
641 | naughty
642 | nauseating
643 | near
644 | neat
645 | nebulous
646 | necessary
647 | needless
648 | needy
649 | neighborly
650 | nervous
651 | new
652 | next
653 | nice
654 | nifty
655 | nimble
656 | nine
657 | nippy
658 | noiseless
659 | noisy
660 | nonchalant
661 | nondescript
662 | nonstop
663 | normal
664 | nostalgic
665 | nosy
666 | noxious
667 | null
668 | numberless
669 | numerous
670 | nutritious
671 | nutty
672 | oafish
673 | obedient
674 | obeisant
675 | obese
676 | obnoxious
677 | obscene
678 | obsequious
679 | observant
680 | obsolete
681 | obtainable
682 | oceanic
683 | odd
684 | offbeat
685 | old
686 | old-fashioned
687 | omniscient
688 | one
689 | onerous
690 | open
691 | opposite
692 | optimal
693 | orange
694 | ordinary
695 | organic
696 | ossified
697 | outgoing
698 | outrageous
699 | outstanding
700 | oval
701 | overconfident
702 | overjoyed
703 | overrated
704 | overt
705 | overwrought
706 | painful
707 | painstaking
708 | pale
709 | paltry
710 | panicky
711 | panoramic
712 | parallel
713 | parched
714 | parsimonious
715 | past
716 | pastoral
717 | pathetic
718 | peaceful
719 | penitent
720 | perfect
721 | periodic
722 | permissible
723 | perpetual
724 | petite
725 | petite
726 | phobic
727 | physical
728 | picayune
729 | pink
730 | piquant
731 | placid
732 | plain
733 | plant
734 | plastic
735 | plausible
736 | pleasant
737 | plucky
738 | pointless
739 | poised
740 | polite
741 | political
742 | poor
743 | possessive
744 | possible
745 | powerful
746 | precious
747 | premium
748 | present
749 | pretty
750 | previous
751 | pricey
752 | prickly
753 | private
754 | probable
755 | productive
756 | profuse
757 | protective
758 | proud
759 | psychedelic
760 | psychotic
761 | public
762 | puffy
763 | pumped
764 | puny
765 | purple
766 | purring
767 | pushy
768 | puzzled
769 | puzzling
770 | quack
771 | quaint
772 | quarrelsome
773 | questionable
774 | quick
775 | quickest
776 | quiet
777 | quirky
778 | quixotic
779 | quizzical
780 | rabid
781 | racial
782 | ragged
783 | rainy
784 | rambunctious
785 | rampant
786 | rapid
787 | rare
788 | raspy
789 | ratty
790 | ready
791 | real
792 | rebel
793 | receptive
794 | recondite
795 | red
796 | redundant
797 | reflective
798 | regular
799 | relieved
800 | remarkable
801 | reminiscent
802 | repulsive
803 | resolute
804 | resonant
805 | responsible
806 | rhetorical
807 | rich
808 | right
809 | righteous
810 | rightful
811 | rigid
812 | ripe
813 | ritzy
814 | roasted
815 | robust
816 | romantic
817 | roomy
818 | rotten
819 | rough
820 | round
821 | royal
822 | ruddy
823 | rude
824 | rural
825 | rustic
826 | ruthless
827 | sable
828 | sad
829 | safe
830 | salty
831 | same
832 | sassy
833 | satisfying
834 | savory
835 | scandalous
836 | scarce
837 | scared
838 | scary
839 | scattered
840 | scientific
841 | scintillating
842 | scrawny
843 | screeching
844 | second
845 | second-hand
846 | secret
847 | secretive
848 | sedate
849 | seemly
850 | selective
851 | selfish
852 | separate
853 | serious
854 | shaggy
855 | shaky
856 | shallow
857 | sharp
858 | shiny
859 | shivering
860 | shocking
861 | short
862 | shrill
863 | shut
864 | shy
865 | sick
866 | silent
867 | silent
868 | silky
869 | silly
870 | simple
871 | simplistic
872 | sincere
873 | six
874 | skillful
875 | skinny
876 | sleepy
877 | slim
878 | slimy
879 | slippery
880 | sloppy
881 | slow
882 | small
883 | smart
884 | smelly
885 | smiling
886 | smoggy
887 | smooth
888 | sneaky
889 | snobbish
890 | snotty
891 | soft
892 | soggy
893 | solid
894 | somber
895 | sophisticated
896 | sordid
897 | sore
898 | sore
899 | sour
900 | sparkling
901 | special
902 | spectacular
903 | spicy
904 | spiffy
905 | spiky
906 | spiritual
907 | spiteful
908 | splendid
909 | spooky
910 | spotless
911 | spotted
912 | spotty
913 | spurious
914 | squalid
915 | square
916 | squealing
917 | squeamish
918 | staking
919 | stale
920 | standing
921 | statuesque
922 | steadfast
923 | steady
924 | steep
925 | stereotyped
926 | sticky
927 | stiff
928 | stimulating
929 | stingy
930 | stormy
931 | straight
932 | strange
933 | striped
934 | strong
935 | stupendous
936 | stupid
937 | sturdy
938 | subdued
939 | subsequent
940 | substantial
941 | successful
942 | succinct
943 | sudden
944 | sulky
945 | super
946 | superb
947 | superficial
948 | supreme
949 | swanky
950 | sweet
951 | sweltering
952 | swift
953 | symptomatic
954 | synonymous
955 | taboo
956 | tacit
957 | tacky
958 | talented
959 | tall
960 | tame
961 | tan
962 | tangible
963 | tangy
964 | tart
965 | tasteful
966 | tasteless
967 | tasty
968 | tawdry
969 | tearful
970 | tedious
971 | teeny
972 | teeny-tiny
973 | telling
974 | temporary
975 | ten
976 | tender
977 | tense
978 | tense
979 | tenuous
980 | terrible
981 | terrific
982 | tested
983 | testy
984 | thankful
985 | therapeutic
986 | thick
987 | thin
988 | thinkable
989 | third
990 | thirsty
991 | thirsty
992 | thoughtful
993 | thoughtless
994 | threatening
995 | three
996 | thundering
997 | tidy
998 | tight
999 | tightfisted
1000 | tiny
1001 | tired
1002 | tiresome
1003 | toothsome
1004 | torpid
1005 | tough
1006 | towering
1007 | tranquil
1008 | trashy
1009 | tremendous
1010 | tricky
1011 | trite
1012 | troubled
1013 | truculent
1014 | true
1015 | truthful
1016 | two
1017 | typical
1018 | ubiquitous
1019 | ugliest
1020 | ugly
1021 | ultra
1022 | unable
1023 | unaccountable
1024 | unadvised
1025 | unarmed
1026 | unbecoming
1027 | unbiased
1028 | uncovered
1029 | understood
1030 | undesirable
1031 | unequal
1032 | unequaled
1033 | uneven
1034 | unhealthy
1035 | uninterested
1036 | unique
1037 | unkempt
1038 | unknown
1039 | unnatural
1040 | unruly
1041 | unsightly
1042 | unsuitable
1043 | untidy
1044 | unused
1045 | unusual
1046 | unwieldy
1047 | unwritten
1048 | upbeat
1049 | uppity
1050 | upset
1051 | uptight
1052 | used
1053 | useful
1054 | useless
1055 | utopian
1056 | utter
1057 | uttermost
1058 | vacuous
1059 | vagabond
1060 | vague
1061 | valuable
1062 | various
1063 | vast
1064 | vengeful
1065 | venomous
1066 | verdant
1067 | versed
1068 | victorious
1069 | vigorous
1070 | violent
1071 | violet
1072 | vivacious
1073 | voiceless
1074 | volatile
1075 | voracious
1076 | vulgar
1077 | wacky
1078 | waggish
1079 | waiting
1080 | wakeful
1081 | wandering
1082 | wanting
1083 | warlike
1084 | warm
1085 | wary
1086 | wasteful
1087 | watery
1088 | weak
1089 | wealthy
1090 | weary
1091 | well-groomed
1092 | well-made
1093 | well-off
1094 | well-to-do
1095 | wet
1096 | whimsical
1097 | whispering
1098 | white
1099 | whole
1100 | wholesale
1101 | wicked
1102 | wide
1103 | wide-eyed
1104 | wiggly
1105 | wild
1106 | willing
1107 | windy
1108 | wiry
1109 | wise
1110 | wistful
1111 | witty
1112 | woebegone
1113 | womanly
1114 | wonderful
1115 | wooden
1116 | woozy
1117 | workable
1118 | worried
1119 | worthless
1120 | wrathful
1121 | wretched
1122 | wrong
1123 | wry
1124 | yellow
1125 | yielding
1126 | young
1127 | youthful
1128 | yummy
1129 | zany
1130 | zealous
1131 | zesty
1132 | zippy
1133 | zonked
--------------------------------------------------------------------------------
/words/nouns.txt:
--------------------------------------------------------------------------------
1 | able
2 | able
3 | account
4 | achieve
5 | acoustics
6 | act
7 | action
8 | activity
9 | actor
10 | addition
11 | adjustment
12 | advertisement
13 | advice
14 | aftermath
15 | afternoon
16 | afterthought
17 | agreement
18 | air
19 | airplane
20 | airport
21 | alarm
22 | amount
23 | amusement
24 | anger
25 | angle
26 | animal
27 | answer
28 | ant
29 | ants
30 | apparatus
31 | apparel
32 | apple
33 | apples
34 | appliance
35 | approval
36 | arch
37 | argument
38 | arithmetic
39 | arm
40 | army
41 | art
42 | attack
43 | attempt
44 | attention
45 | attraction
46 | aunt
47 | authority
48 | babies
49 | baby
50 | back
51 | badge
52 | bag
53 | bait
54 | balance
55 | ball
56 | balloon
57 | balls
58 | banana
59 | band
60 | base
61 | baseball
62 | basin
63 | basket
64 | basketball
65 | bat
66 | bath
67 | battle
68 | bead
69 | beam
70 | bean
71 | bear
72 | bears
73 | beast
74 | bed
75 | bedroom
76 | beds
77 | bee
78 | beef
79 | beetle
80 | beggar
81 | beginner
82 | behavior
83 | belief
84 | believe
85 | bell
86 | bells
87 | berry
88 | bike
89 | bikes
90 | bird
91 | birds
92 | birth
93 | birthday
94 | bit
95 | bite
96 | blade
97 | blood
98 | blow
99 | board
100 | boat
101 | boats
102 | body
103 | bomb
104 | bone
105 | book
106 | books
107 | boot
108 | border
109 | bottle
110 | boundary
111 | box
112 | boy
113 | boys
114 | brain
115 | brake
116 | branch
117 | brass
118 | bread
119 | breakfast
120 | breath
121 | brick
122 | bridge
123 | brother
124 | brothers
125 | brush
126 | bubble
127 | bucket
128 | building
129 | bulb
130 | bun
131 | burn
132 | burst
133 | bushes
134 | business
135 | butter
136 | button
137 | cabbage
138 | cable
139 | cactus
140 | cake
141 | cakes
142 | calculator
143 | calendar
144 | camera
145 | camp
146 | can
147 | cannon
148 | canvas
149 | cap
150 | caption
151 | car
152 | card
153 | care
154 | carpenter
155 | carriage
156 | cars
157 | cart
158 | cast
159 | cat
160 | cats
161 | cattle
162 | cause
163 | cave
164 | celery
165 | cellar
166 | cemetery
167 | cent
168 | chain
169 | chair
170 | chairs
171 | chalk
172 | chance
173 | change
174 | channel
175 | cheese
176 | cherries
177 | cherry
178 | chess
179 | chicken
180 | chickens
181 | children
182 | chin
183 | church
184 | circle
185 | clam
186 | class
187 | clock
188 | clocks
189 | cloth
190 | cloud
191 | clouds
192 | clover
193 | club
194 | coach
195 | coal
196 | coast
197 | coat
198 | cobweb
199 | coil
200 | collar
201 | color
202 | comb
203 | comfort
204 | committee
205 | company
206 | comparison
207 | competition
208 | condition
209 | connection
210 | control
211 | cook
212 | copper
213 | copy
214 | cord
215 | cork
216 | corn
217 | cough
218 | country
219 | cover
220 | cow
221 | cows
222 | crack
223 | cracker
224 | crate
225 | crayon
226 | cream
227 | creator
228 | creature
229 | credit
230 | crib
231 | crime
232 | crook
233 | crow
234 | crowd
235 | crown
236 | crush
237 | cry
238 | cub
239 | cup
240 | current
241 | curtain
242 | curve
243 | cushion
244 | dad
245 | daughter
246 | day
247 | death
248 | debt
249 | decision
250 | deer
251 | degree
252 | design
253 | desire
254 | desk
255 | destruction
256 | detail
257 | development
258 | digestion
259 | dime
260 | dinner
261 | dinosaurs
262 | direction
263 | dirt
264 | discovery
265 | discussion
266 | disease
267 | disgust
268 | distance
269 | distribution
270 | division
271 | dock
272 | doctor
273 | dog
274 | dogs
275 | doll
276 | dolls
277 | donkey
278 | door
279 | downtown
280 | drain
281 | drawer
282 | dress
283 | drink
284 | driving
285 | drop
286 | drug
287 | drum
288 | duck
289 | ducks
290 | dust
291 | ear
292 | earth
293 | earthquake
294 | edge
295 | education
296 | effect
297 | egg
298 | eggnog
299 | eggs
300 | elbow
301 | end
302 | engine
303 | error
304 | event
305 | example
306 | exchange
307 | existence
308 | expansion
309 | experience
310 | expert
311 | eye
312 | eyes
313 | face
314 | fact
315 | fairies
316 | fall
317 | family
318 | fan
319 | fang
320 | farm
321 | farmer
322 | father
323 | father
324 | faucet
325 | fear
326 | feast
327 | feather
328 | feeling
329 | feet
330 | fiction
331 | field
332 | fifth
333 | fight
334 | finger
335 | finger
336 | fire
337 | fireman
338 | fish
339 | flag
340 | flame
341 | flavor
342 | flesh
343 | flight
344 | flock
345 | floor
346 | flower
347 | flowers
348 | fly
349 | fog
350 | fold
351 | food
352 | foot
353 | force
354 | fork
355 | form
356 | fowl
357 | frame
358 | friction
359 | friend
360 | friends
361 | frog
362 | frogs
363 | front
364 | fruit
365 | fuel
366 | furniture
367 | alley
368 | game
369 | garden
370 | gate
371 | geese
372 | ghost
373 | giants
374 | giraffe
375 | girl
376 | girls
377 | glass
378 | glove
379 | glue
380 | goat
381 | gold
382 | goldfish
383 | good-bye
384 | goose
385 | government
386 | governor
387 | grade
388 | grain
389 | grandfather
390 | grandmother
391 | grape
392 | grass
393 | grip
394 | ground
395 | group
396 | growth
397 | guide
398 | guitar
399 | gun
400 | hair
401 | haircut
402 | hall
403 | hammer
404 | hand
405 | hands
406 | harbor
407 | harmony
408 | hat
409 | hate
410 | head
411 | health
412 | hearing
413 | heart
414 | heat
415 | help
416 | hen
417 | hill
418 | history
419 | hobbies
420 | hole
421 | holiday
422 | home
423 | honey
424 | hook
425 | hope
426 | horn
427 | horse
428 | horses
429 | hose
430 | hospital
431 | hot
432 | hour
433 | house
434 | houses
435 | humor
436 | hydrant
437 | ice
438 | icicle
439 | idea
440 | impulse
441 | income
442 | increase
443 | industry
444 | ink
445 | insect
446 | instrument
447 | insurance
448 | interest
449 | invention
450 | iron
451 | island
452 | jail
453 | jam
454 | jar
455 | jeans
456 | jelly
457 | jellyfish
458 | jewel
459 | join
460 | joke
461 | journey
462 | judge
463 | juice
464 | jump
465 | kettle
466 | key
467 | kick
468 | kiss
469 | kite
470 | kitten
471 | kittens
472 | kitty
473 | knee
474 | knife
475 | knot
476 | knowledge
477 | laborer
478 | lace
479 | ladybug
480 | lake
481 | lamp
482 | land
483 | language
484 | laugh
485 | lawyer
486 | lead
487 | leaf
488 | learning
489 | leather
490 | leg
491 | legs
492 | letter
493 | letters
494 | lettuce
495 | level
496 | library
497 | lift
498 | light
499 | limit
500 | line
501 | linen
502 | lip
503 | liquid
504 | list
505 | lizards
506 | loaf
507 | lock
508 | locket
509 | look
510 | loss
511 | love
512 | low
513 | lumber
514 | lunch
515 | lunchroom
516 | machine
517 | magic
518 | maid
519 | mailbox
520 | man
521 | manager
522 | map
523 | marble
524 | mark
525 | market
526 | mask
527 | mass
528 | match
529 | meal
530 | measure
531 | meat
532 | meeting
533 | memory
534 | men
535 | metal
536 | mice
537 | middle
538 | milk
539 | mind
540 | mine
541 | minister
542 | mint
543 | minute
544 | mist
545 | mitten
546 | mom
547 | money
548 | monkey
549 | month
550 | moon
551 | morning
552 | mother
553 | motion
554 | mountain
555 | mouth
556 | move
557 | muscle
558 | music
559 | nail
560 | name
561 | nation
562 | neck
563 | need
564 | needle
565 | nerve
566 | nest
567 | net
568 | news
569 | night
570 | noise
571 | north
572 | nose
573 | note
574 | notebook
575 | number
576 | nut
577 | oatmeal
578 | observation
579 | ocean
580 | offer
581 | office
582 | oil
583 | operation
584 | opinion
585 | orange
586 | oranges
587 | order
588 | organization
589 | ornament
590 | oven
591 | owl
592 | owner
593 | page
594 | pail
595 | pain
596 | paint
597 | pan
598 | pancake
599 | paper
600 | parcel
601 | parent
602 | park
603 | part
604 | partner
605 | party
606 | passenger
607 | paste
608 | patch
609 | payment
610 | peace
611 | pear
612 | pen
613 | pencil
614 | person
615 | pest
616 | pet
617 | pets
618 | pickle
619 | picture
620 | pie
621 | pies
622 | pig
623 | pigs
624 | pin
625 | pipe
626 | pizzas
627 | place
628 | plane
629 | planes
630 | plant
631 | plantation
632 | plants
633 | plastic
634 | plate
635 | play
636 | playground
637 | pleasure
638 | plot
639 | plough
640 | pocket
641 | point
642 | poison
643 | police
644 | polish
645 | pollution
646 | popcorn
647 | porter
648 | position
649 | pot
650 | potato
651 | powder
652 | power
653 | price
654 | print
655 | prison
656 | process
657 | produce
658 | profit
659 | property
660 | prose
661 | protest
662 | pull
663 | pump
664 | punishment
665 | purpose
666 | push
667 | quarter
668 | quartz
669 | queen
670 | question
671 | quicksand
672 | quiet
673 | quill
674 | quilt
675 | quince
676 | quiver
677 | rabbit
678 | rabbits
679 | rail
680 | railway
681 | rain
682 | rainstorm
683 | rake
684 | range
685 | rat
686 | rate
687 | ray
688 | reaction
689 | reading
690 | reason
691 | receipt
692 | recess
693 | record
694 | regret
695 | relation
696 | religion
697 | representative
698 | request
699 | respect
700 | rest
701 | reward
702 | rhythm
703 | rice
704 | riddle
705 | rifle
706 | ring
707 | rings
708 | river
709 | road
710 | robin
711 | rock
712 | rod
713 | roll
714 | roof
715 | room
716 | root
717 | rose
718 | route
719 | rub
720 | rule
721 | run
722 | sack
723 | sail
724 | salt
725 | sand
726 | scale
727 | scarecrow
728 | scarf
729 | scene
730 | scent
731 | school
732 | science
733 | scissors
734 | screw
735 | sea
736 | seashore
737 | seat
738 | secretary
739 | seed
740 | selection
741 | self
742 | sense
743 | servant
744 | shade
745 | shake
746 | shame
747 | shape
748 | sheep
749 | sheet
750 | shelf
751 | ship
752 | shirt
753 | shock
754 | shoe
755 | shoes
756 | shop
757 | show
758 | side
759 | sidewalk
760 | sign
761 | silk
762 | silver
763 | sink
764 | sister
765 | sisters
766 | size
767 | skate
768 | skin
769 | skirt
770 | sky
771 | slave
772 | sleep
773 | sleet
774 | slip
775 | slope
776 | smash
777 | smell
778 | smile
779 | smoke
780 | snail
781 | snails
782 | snake
783 | snakes
784 | sneeze
785 | snow
786 | soap
787 | society
788 | sock
789 | soda
790 | sofa
791 | son
792 | song
793 | songs
794 | sort
795 | sound
796 | soup
797 | space
798 | spade
799 | spark
800 | spiders
801 | sponge
802 | spoon
803 | spot
804 | spring
805 | spy
806 | square
807 | squirrel
808 | stage
809 | stamp
810 | star
811 | start
812 | statement
813 | station
814 | steam
815 | steel
816 | stem
817 | step
818 | stew
819 | stick
820 | sticks
821 | stitch
822 | stocking
823 | stomach
824 | stone
825 | stop
826 | store
827 | story
828 | stove
829 | stranger
830 | straw
831 | stream
832 | street
833 | stretch
834 | string
835 | structure
836 | substance
837 | sugar
838 | suggestion
839 | suit
840 | summer
841 | sun
842 | support
843 | surprise
844 | sweater
845 | swim
846 | swing
847 | system
848 | table
849 | tail
850 | talk
851 | tank
852 | taste
853 | tax
854 | teaching
855 | team
856 | teeth
857 | temper
858 | tendency
859 | tent
860 | territory
861 | test
862 | texture
863 | theory
864 | thing
865 | things
866 | thought
867 | thread
868 | thrill
869 | throat
870 | throne
871 | thumb
872 | thunder
873 | ticket
874 | tiger
875 | time
876 | tin
877 | title
878 | toad
879 | toe
880 | toes
881 | tomatoes
882 | tongue
883 | tooth
884 | toothbrush
885 | toothpaste
886 | top
887 | touch
888 | town
889 | toy
890 | toys
891 | trade
892 | trail
893 | train
894 | trains
895 | tramp
896 | transport
897 | tray
898 | treatment
899 | tree
900 | trees
901 | trick
902 | trip
903 | trouble
904 | trousers
905 | truck
906 | trucks
907 | tub
908 | turkey
909 | turn
910 | twig
911 | twist
912 | umbrella
913 | uncle
914 | underwear
915 | unit
916 | use
917 | vacation
918 | value
919 | van
920 | vase
921 | vegetable
922 | veil
923 | vein
924 | verse
925 | vessel
926 | vest
927 | view
928 | visitor
929 | voice
930 | volcano
931 | volleyball
932 | voyage
933 | walk
934 | wall
935 | war
936 | wash
937 | waste
938 | watch
939 | water
940 | wave
941 | waves
942 | wax
943 | way
944 | wealth
945 | weather
946 | week
947 | weight
948 | wheel
949 | whip
950 | whistle
951 | wilderness
952 | wind
953 | window
954 | wine
955 | wing
956 | winter
957 | wire
958 | wish
959 | woman
960 | women
961 | wood
962 | wool
963 | word
964 | work
965 | worm
966 | wound
967 | wren
968 | wrench
969 | wrist
970 | writer
971 | writing
972 | yak
973 | yam
974 | yard
975 | yarn
976 | year
977 | yoke
978 | zebra
979 | zephyr
980 | zinc
981 | zipper
982 | zoo
--------------------------------------------------------------------------------
/words/verbs.txt:
--------------------------------------------------------------------------------
1 | abide
2 | accelerate
3 | accept
4 | accomplish
5 | achieve
6 | acquire
7 | acted
8 | activate
9 | adapt
10 | add
11 | address
12 | administer
13 | admire
14 | admit
15 | adopt
16 | advise
17 | afford
18 | agree
19 | alert
20 | alight
21 | allow
22 | altered
23 | amuse
24 | analyze
25 | announce
26 | annoy
27 | answer
28 | anticipate
29 | apologize
30 | appear
31 | applaud
32 | applied
33 | appoint
34 | appraise
35 | appreciate
36 | approve
37 | arbitrate
38 | argue
39 | arise
40 | arrange
41 | arrest
42 | arrive
43 | ascertain
44 | ask
45 | assemble
46 | assess
47 | assist
48 | assure
49 | attach
50 | attack
51 | attain
52 | attempt
53 | attend
54 | attract
55 | audited
56 | avoid
57 | awake
58 | back
59 | bake
60 | balance
61 | ban
62 | bang
63 | bare
64 | bat
65 | bathe
66 | battle
67 | be
68 | beam
69 | bear
70 | beat
71 | become
72 | beg
73 | begin
74 | behave
75 | behold
76 | belong
77 | bend
78 | beset
79 | bet
80 | bid
81 | bind
82 | bite
83 | bleach
84 | bleed
85 | bless
86 | blind
87 | blink
88 | blot
89 | blow
90 | blush
91 | boast
92 | boil
93 | bolt
94 | bomb
95 | book
96 | bore
97 | borrow
98 | bounce
99 | bow
100 | box
101 | brake
102 | branch
103 | break
104 | breathe
105 | breed
106 | brief
107 | bring
108 | broadcast
109 | bruise
110 | brush
111 | bubble
112 | budget
113 | build
114 | bump
115 | burn
116 | burst
117 | bury
118 | bust
119 | buy
120 | buzz
121 | calculate
122 | call
123 | camp
124 | care
125 | carry
126 | carve
127 | cast
128 | catalog
129 | catch
130 | cause
131 | challenge
132 | change
133 | charge
134 | chart
135 | chase
136 | cheat
137 | check
138 | cheer
139 | chew
140 | choke
141 | choose
142 | chop
143 | claim
144 | clap
145 | clarify
146 | classify
147 | clean
148 | clear
149 | cling
150 | clip
151 | close
152 | clothe
153 | coach
154 | coil
155 | collect
156 | color
157 | comb
158 | come
159 | command
160 | communicate
161 | compare
162 | compete
163 | compile
164 | complain
165 | complete
166 | compose
167 | compute
168 | conceive
169 | concentrate
170 | conceptualize
171 | concern
172 | conclude
173 | conduct
174 | confess
175 | confront
176 | confuse
177 | connect
178 | conserve
179 | consider
180 | consist
181 | consolidate
182 | construct
183 | consult
184 | contain
185 | continue
186 | contract
187 | control
188 | convert
189 | coordinate
190 | copy
191 | correct
192 | correlate
193 | cost
194 | cough
195 | counsel
196 | count
197 | cover
198 | crack
199 | crash
200 | crawl
201 | create
202 | creep
203 | critique
204 | cross
205 | crush
206 | cry
207 | cure
208 | curl
209 | curve
210 | cut
211 | cycle
212 | dam
213 | damage
214 | dance
215 | dare
216 | deal
217 | decay
218 | deceive
219 | decide
220 | decorate
221 | define
222 | delay
223 | delegate
224 | delight
225 | deliver
226 | demonstrate
227 | depend
228 | describe
229 | desert
230 | deserve
231 | design
232 | destroy
233 | detail
234 | detect
235 | determine
236 | develop
237 | devise
238 | diagnose
239 | dig
240 | direct
241 | disagree
242 | disappear
243 | disapprove
244 | disarm
245 | discover
246 | dislike
247 | dispense
248 | display
249 | disprove
250 | dissect
251 | distribute
252 | dive
253 | divert
254 | divide
255 | do
256 | double
257 | doubt
258 | draft
259 | drag
260 | drain
261 | dramatize
262 | draw
263 | dream
264 | dress
265 | drink
266 | drip
267 | drive
268 | drop
269 | drown
270 | drum
271 | dry
272 | dust
273 | dwell
274 | earn
275 | eat
276 | edited
277 | educate
278 | eliminate
279 | embarrass
280 | employ
281 | empty
282 | enacted
283 | encourage
284 | end
285 | endure
286 | enforce
287 | engineer
288 | enhance
289 | enjoy
290 | enlist
291 | ensure
292 | enter
293 | entertain
294 | escape
295 | establish
296 | estimate
297 | evaluate
298 | examine
299 | exceed
300 | excite
301 | excuse
302 | execute
303 | exercise
304 | exhibit
305 | exist
306 | expand
307 | expect
308 | expedite
309 | experiment
310 | explain
311 | explode
312 | express
313 | extend
314 | extract
315 | face
316 | facilitate
317 | fade
318 | fail
319 | fancy
320 | fasten
321 | fax
322 | fear
323 | feed
324 | feel
325 | fence
326 | fetch
327 | fight
328 | file
329 | fill
330 | film
331 | finalize
332 | finance
333 | find
334 | fire
335 | fit
336 | fix
337 | flap
338 | flash
339 | flee
340 | fling
341 | float
342 | flood
343 | flow
344 | flower
345 | fly
346 | fold
347 | follow
348 | fool
349 | forbid
350 | force
351 | forecast
352 | forego
353 | foresee
354 | foretell
355 | forget
356 | forgive
357 | form
358 | formulate
359 | forsake
360 | frame
361 | freeze
362 | frighten
363 | fry
364 | gather
365 | gaze
366 | generate
367 | get
368 | give
369 | glow
370 | glue
371 | go
372 | govern
373 | grab
374 | graduate
375 | grate
376 | grease
377 | greet
378 | grin
379 | grind
380 | grip
381 | groan
382 | grow
383 | guarantee
384 | guard
385 | guess
386 | guide
387 | hammer
388 | hand
389 | handle
390 | handwrite
391 | hang
392 | happen
393 | harass
394 | harm
395 | hate
396 | haunt
397 | head
398 | heal
399 | heap
400 | hear
401 | heat
402 | help
403 | hide
404 | hit
405 | hold
406 | hook
407 | hop
408 | hope
409 | hover
410 | hug
411 | hum
412 | hunt
413 | hurry
414 | hurt
415 | hypothesize
416 | identify
417 | ignore
418 | illustrate
419 | imagine
420 | implement
421 | impress
422 | improve
423 | improvise
424 | include
425 | increase
426 | induce
427 | influence
428 | inform
429 | initiate
430 | inject
431 | injure
432 | inlay
433 | innovate
434 | input
435 | inspect
436 | inspire
437 | install
438 | institute
439 | instruct
440 | insure
441 | integrate
442 | intend
443 | intensify
444 | interest
445 | interfere
446 | interlay
447 | interpret
448 | interrupt
449 | interview
450 | introduce
451 | invent
452 | inventory
453 | investigate
454 | invite
455 | irritate
456 | itch
457 | jail
458 | jam
459 | jog
460 | join
461 | joke
462 | judge
463 | juggle
464 | jump
465 | justify
466 | keep
467 | kept
468 | kick
469 | kill
470 | kiss
471 | kneel
472 | knit
473 | knock
474 | knot
475 | know
476 | label
477 | land
478 | last
479 | laugh
480 | launch
481 | lay
482 | lead
483 | lean
484 | leap
485 | learn
486 | leave
487 | lecture
488 | led
489 | lend
490 | let
491 | level
492 | license
493 | lick
494 | lie
495 | lifted
496 | light
497 | lighten
498 | like
499 | list
500 | listen
501 | live
502 | load
503 | locate
504 | lock
505 | log
506 | long
507 | look
508 | lose
509 | love
510 | maintain
511 | make
512 | man
513 | manage
514 | manipulate
515 | manufacture
516 | map
517 | march
518 | mark
519 | market
520 | marry
521 | match
522 | mate
523 | matter
524 | mean
525 | measure
526 | meddle
527 | mediate
528 | meet
529 | melt
530 | melt
531 | memorize
532 | mend
533 | mentor
534 | milk
535 | mine
536 | mislead
537 | miss
538 | misspell
539 | mistake
540 | misunderstand
541 | mix
542 | moan
543 | model
544 | modify
545 | monitor
546 | moor
547 | motivate
548 | mourn
549 | move
550 | mow
551 | muddle
552 | mug
553 | multiply
554 | murder
555 | nail
556 | name
557 | navigate
558 | need
559 | negotiate
560 | nest
561 | nod
562 | nominate
563 | normalize
564 | note
565 | notice
566 | number
567 | obey
568 | object
569 | observe
570 | obtain
571 | occur
572 | offend
573 | offer
574 | officiate
575 | open
576 | operate
577 | order
578 | organize
579 | oriented
580 | originate
581 | overcome
582 | overdo
583 | overdraw
584 | overflow
585 | overhear
586 | overtake
587 | overthrow
588 | owe
589 | own
590 | pack
591 | paddle
592 | paint
593 | park
594 | part
595 | participate
596 | pass
597 | paste
598 | pat
599 | pause
600 | pay
601 | peck
602 | pedal
603 | peel
604 | peep
605 | perceive
606 | perfect
607 | perform
608 | permit
609 | persuade
610 | phone
611 | photograph
612 | pick
613 | pilot
614 | pinch
615 | pine
616 | pinpoint
617 | pioneer
618 | place
619 | plan
620 | plant
621 | play
622 | plead
623 | please
624 | plug
625 | point
626 | poke
627 | polish
628 | pop
629 | possess
630 | post
631 | pour
632 | practice
633 | praised
634 | pray
635 | preach
636 | precede
637 | predict
638 | prefer
639 | prepare
640 | prescribe
641 | present
642 | preserve
643 | preset
644 | preside
645 | press
646 | pretend
647 | prevent
648 | prick
649 | print
650 | process
651 | procure
652 | produce
653 | profess
654 | program
655 | progress
656 | project
657 | promise
658 | promote
659 | proofread
660 | propose
661 | protect
662 | prove
663 | provide
664 | publicize
665 | pull
666 | pump
667 | punch
668 | puncture
669 | punish
670 | purchase
671 | push
672 | put
673 | qualify
674 | question
675 | queue
676 | quit
677 | race
678 | radiate
679 | rain
680 | raise
681 | rank
682 | rate
683 | reach
684 | read
685 | realign
686 | realize
687 | reason
688 | receive
689 | recognize
690 | recommend
691 | reconcile
692 | record
693 | recruit
694 | reduce
695 | refer
696 | reflect
697 | refuse
698 | regret
699 | regulate
700 | rehabilitate
701 | reign
702 | reinforce
703 | reject
704 | rejoice
705 | relate
706 | relax
707 | release
708 | rely
709 | remain
710 | remember
711 | remind
712 | remove
713 | render
714 | reorganize
715 | repair
716 | repeat
717 | replace
718 | reply
719 | report
720 | represent
721 | reproduce
722 | request
723 | rescue
724 | research
725 | resolve
726 | respond
727 | restored
728 | restructure
729 | retire
730 | retrieve
731 | return
732 | review
733 | revise
734 | rhyme
735 | rid
736 | ride
737 | ring
738 | rinse
739 | rise
740 | risk
741 | rob
742 | rock
743 | roll
744 | rot
745 | rub
746 | ruin
747 | rule
748 | run
749 | rush
750 | sack
751 | sail
752 | satisfy
753 | save
754 | saw
755 | say
756 | scare
757 | scatter
758 | schedule
759 | scold
760 | scorch
761 | scrape
762 | scratch
763 | scream
764 | screw
765 | scribble
766 | scrub
767 | seal
768 | search
769 | secure
770 | see
771 | seek
772 | select
773 | sell
774 | send
775 | sense
776 | separate
777 | serve
778 | service
779 | set
780 | settle
781 | sew
782 | shade
783 | shake
784 | shape
785 | share
786 | shave
787 | shear
788 | shed
789 | shelter
790 | shine
791 | shiver
792 | shock
793 | shoe
794 | shoot
795 | shop
796 | show
797 | shrink
798 | shrug
799 | shut
800 | sigh
801 | sign
802 | signal
803 | simplify
804 | sin
805 | sing
806 | sink
807 | sip
808 | sit
809 | sketch
810 | ski
811 | skip
812 | slap
813 | slay
814 | sleep
815 | slide
816 | sling
817 | slink
818 | slip
819 | slit
820 | slow
821 | smash
822 | smell
823 | smile
824 | smite
825 | smoke
826 | snatch
827 | sneak
828 | sneeze
829 | sniff
830 | snore
831 | snow
832 | soak
833 | solve
834 | soothe
835 | soothsay
836 | sort
837 | sound
838 | sow
839 | spare
840 | spark
841 | sparkle
842 | speak
843 | specify
844 | speed
845 | spell
846 | spend
847 | spill
848 | spin
849 | spit
850 | split
851 | spoil
852 | spot
853 | spray
854 | spread
855 | spring
856 | sprout
857 | squash
858 | squeak
859 | squeal
860 | squeeze
861 | stain
862 | stamp
863 | stand
864 | stare
865 | start
866 | stay
867 | steal
868 | steer
869 | step
870 | stick
871 | stimulate
872 | sting
873 | stink
874 | stir
875 | stitch
876 | stop
877 | store
878 | strap
879 | streamline
880 | strengthen
881 | stretch
882 | stride
883 | strike
884 | string
885 | strip
886 | strive
887 | stroke
888 | structure
889 | study
890 | stuff
891 | sublet
892 | subtract
893 | succeed
894 | suck
895 | suffer
896 | suggest
897 | suit
898 | summarize
899 | supervise
900 | supply
901 | support
902 | suppose
903 | surprise
904 | surround
905 | suspect
906 | suspend
907 | swear
908 | sweat
909 | sweep
910 | swell
911 | swim
912 | swing
913 | switch
914 | symbolize
915 | synthesize
916 | systemize
917 | tabulate
918 | take
919 | talk
920 | tame
921 | tap
922 | target
923 | taste
924 | teach
925 | tear
926 | tease
927 | telephone
928 | tell
929 | tempt
930 | terrify
931 | test
932 | thank
933 | thaw
934 | think
935 | thrive
936 | throw
937 | thrust
938 | tick
939 | tickle
940 | tie
941 | time
942 | tip
943 | tire
944 | touch
945 | tour
946 | tow
947 | trace
948 | trade
949 | train
950 | transcribe
951 | transfer
952 | transform
953 | translate
954 | transport
955 | trap
956 | travel
957 | tread
958 | treat
959 | tremble
960 | trick
961 | trip
962 | trot
963 | trouble
964 | troubleshoot
965 | trust
966 | try
967 | tug
968 | tumble
969 | turn
970 | tutor
971 | twist
972 | type
973 | undergo
974 | understand
975 | undertake
976 | undress
977 | unfasten
978 | unify
979 | unite
980 | unlock
981 | unpack
982 | untidy
983 | update
984 | upgrade
985 | uphold
986 | upset
987 | use
988 | utilize
989 | vanish
990 | verbalize
991 | verify
992 | vex
993 | visit
994 | wail
995 | wait
996 | wake
997 | walk
998 | wander
999 | want
1000 | warm
1001 | warn
1002 | wash
1003 | waste
1004 | watch
1005 | water
1006 | wave
1007 | wear
1008 | weave
1009 | wed
1010 | weep
1011 | weigh
1012 | welcome
1013 | wend
1014 | wet
1015 | whine
1016 | whip
1017 | whirl
1018 | whisper
1019 | whistle
1020 | win
1021 | wind
1022 | wink
1023 | wipe
1024 | wish
1025 | withdraw
1026 | withhold
1027 | withstand
1028 | wobble
1029 | wonder
1030 | work
1031 | worry
1032 | wrap
1033 | wreck
1034 | wrestle
1035 | wriggle
1036 | wring
1037 | write
1038 | x-ray
1039 | yawn
1040 | yell
1041 | zip
1042 | zoom
--------------------------------------------------------------------------------