diff --git a/server/multiplayer/Room.js b/server/multiplayer/Room.js new file mode 100644 index 00000000..cb793b6a --- /dev/null +++ b/server/multiplayer/Room.js @@ -0,0 +1,95 @@ +import { HEADER, ENDC, OKBLUE, OKGREEN } from '../bcolors.js'; +import Player from './Player.js'; +import RateLimit from '../RateLimit.js'; +const rateLimiter = new RateLimit(50, 1000); + +export default class Room { + /** + * @param {string} name - The name of the room + */ + constructor (name) { + this.name = name; + + this.players = {}; + this.sockets = {}; + this.rateLimitExceeded = new Set(); + this.timer = { + interval: null, + timeRemaining: 0 + }; + } + + connection (socket, userId, username) { + console.log(`Connection in room ${HEADER}${this.name}${ENDC} - userId: ${OKBLUE}${userId}${ENDC}, username: ${OKBLUE}${username}${ENDC} - with settings ${OKGREEN}${Object.keys(this.settings).map(key => [key, this.settings[key]].join(': ')).join('; ')};${ENDC}`); + + const isNew = !(userId in this.players); + if (isNew) { this.players[userId] = new Player(userId); } + this.players[userId].isOnline = true; + this.sockets[userId] = socket; + username = this.players[userId].updateUsername(username); + + socket.on('message', message => { + if (rateLimiter(socket) && !this.rateLimitExceeded.has(username)) { + console.log(`Rate limit exceeded for ${username} in room ${this.name}`); + this.rateLimitExceeded.add(username); + return; + } + + try { + message = JSON.parse(message); + } catch (error) { + console.log(`Error parsing message: ${message}`); + return; + } + this.message(userId, message); + }); + + socket.on('close', this.close.bind(this, userId)); + this.connection2(socket, userId, username, isNew); + } + + connection2 (socket, userId, username, isNew) { throw new Error('Not implemented'); } + close (userId) { throw new Error('Not implemented'); } + async message (userId, message) { throw new Error('Not implemented'); } + + /** + * Sends a message to all sockets + * @param {{}} message + */ + emitMessage (message) { + message = JSON.stringify(message); + for (const socket of Object.values(this.sockets)) { + socket.send(message); + } + } + + /** + * Sends a message to a socket at a specific userId + * @param {string} userId + * @param {{}} message + */ + sendToSocket (userId, message) { + message = JSON.stringify(message); + this.sockets[userId].send(message); + } + + /** + * @param {number} time - time in ticks, where 10 ticks = 1 second + * @param {(time: number) => void} ontick - called every tick + * @param {() => void} callback - called when timer is up + * @returns {void} + */ + startServerTimer (time, ontick, callback) { + clearInterval(this.timer.interval); + this.timer.timeRemaining = time; + + this.timer.interval = setInterval(() => { + if (this.timer.timeRemaining <= 0) { + clearInterval(this.timer.interval); + callback(); + } + ontick(this.timer.timeRemaining); + this.timer.timeRemaining--; + }, 100); + } +} diff --git a/server/multiplayer/TossupRoom.js b/server/multiplayer/TossupRoom.js index 592b08b8..a622463e 100644 --- a/server/multiplayer/TossupRoom.js +++ b/server/multiplayer/TossupRoom.js @@ -1,9 +1,6 @@ -import Player from './Player.js'; import { PERMANENT_ROOMS, ROOM_NAME_MAX_LENGTH } from './constants.js'; +import Room from './Room.js'; -import RateLimit from '../RateLimit.js'; - -import { HEADER, ENDC, OKBLUE, OKGREEN } from '../bcolors.js'; import { DEFAULT_MIN_YEAR, DEFAULT_MAX_YEAR, CATEGORIES, SUBCATEGORIES_FLATTENED, ALTERNATE_SUBCATEGORIES_FLATTENED, SUBCATEGORY_TO_CATEGORY, ALTERNATE_SUBCATEGORY_TO_CATEGORY } from '../../constants.js'; import getRandomTossups from '../../database/qbreader/get-random-tossups.js'; import getSet from '../../database/qbreader/get-set.js'; @@ -20,8 +17,6 @@ import isAppropriateString from '../moderation/is-appropriate-string.js'; const window = new JSDOM('').window; const DOMPurify = createDOMPurify(window); -const rateLimiter = new RateLimit(50, 1000); - const QuestionProgressEnum = Object.freeze({ NOT_STARTED: 0, READING: 1, @@ -37,23 +32,17 @@ function scoreTossup ({ isCorrect, inPower, endOfQuestion, isPace = false }) { return isCorrect ? (inPower ? powerValue : 10) : (endOfQuestion ? 0 : negValue); } -class TossupRoom { +class TossupRoom extends Room { constructor (name, isPermanent = false, categories = [], subcategories = [], alternateSubcategories = []) { - this.name = name; + super(name); this.isPermanent = isPermanent; - /** - * @type {Object.} - */ - this.players = {}; - this.sockets = {}; - this.timeoutID = null; /** - * @type {string | null} - * The userId of the player who buzzed in. - * We should ensure that buzzedIn is null before calling any readQuestion. - */ + * @type {string | null} + * The userId of the player who buzzed in. + * We should ensure that buzzedIn is null before calling any readQuestion. + */ this.buzzedIn = null; this.buzzes = []; this.buzzpointIndices = []; @@ -93,59 +82,21 @@ class TossupRoom { timer: true }; - this.rateLimitExceeded = new Set(); - - this.timerInterval = null; - this.timeRemaining = 0; - this.DEAD_TIME_LIMIT = 5; // time to buzz after question is read this.ANSWER_TIME_LIMIT = 10; // time to give answer after buzzing - getSetList().then(setList => { - this.setList = setList; - }); + getSetList().then(setList => { this.setList = setList; }); } - connection (socket, userId, username) { - const isNew = !(userId in this.players); - if (isNew) { - this.createPlayer(userId); + close (userId) { + if (this.buzzedIn === userId) { + this.giveAnswer(userId, ''); + this.buzzedIn = null; } - username = this.players[userId].updateUsername(username); - this.players[userId].isOnline = true; - - console.log(`Connection in room ${HEADER}${this.name}${ENDC} - userId: ${OKBLUE}${userId}${ENDC}, username: ${OKBLUE}${username}${ENDC} - with settings ${OKGREEN}${Object.keys(this.settings).map(key => [key, this.settings[key]].join(': ')).join('; ')};${ENDC}`); - socket.on('message', message => { - if (rateLimiter(socket) && !this.rateLimitExceeded.has(username)) { - console.log(`Rate limit exceeded for ${OKBLUE}${username}${ENDC} in room ${HEADER}${this.name}${ENDC}`); - this.rateLimitExceeded.add(username); - return; - } - - try { - message = JSON.parse(message); - } catch (error) { - console.log(`Error parsing message: ${message}`); - return; - } - this.message(userId, message); - }); - - socket.on('close', () => { - if (this.buzzedIn === userId) { - this.giveAnswer(userId, ''); - this.buzzedIn = null; - } - - this.message(userId, { - type: 'leave', - userId, - username - }); - }); - - this.sockets[userId] = socket; + this.leave(userId); + } + connection2 (socket, userId, username, isNew) { socket.send(JSON.stringify({ type: 'connection-acknowledged', userId, @@ -191,7 +142,7 @@ class TossupRoom { })); } - this.sendSocketMessage({ + this.emitMessage({ type: 'join', isNew, userId, @@ -201,287 +152,35 @@ class TossupRoom { } async message (userId, message) { - const type = message.type || ''; - let allowedPacketNumbers; - - switch (type) { - case 'buzz': - this.buzz(userId); - break; - - case 'change-username': { - if (typeof message.username !== 'string') { break; } - - if (!isAppropriateString(message.username)) { - this.sendPrivateMessage(userId, { - type: 'force-username', - username: this.players[userId].username, - message: 'Your username contains an inappropriate word, so it has been reverted.' - }); - break; - } - - const oldUsername = this.players[userId].username; - const newUsername = this.players[userId].updateUsername(message.username); - - this.sendSocketMessage({ - type: 'change-username', - userId, - oldUsername, - newUsername - }); - break; - } - - case 'chat': - // prevent chat messages if room is public, since they can still be sent with API - if (this.settings.public || typeof message.message !== 'string') { return; } - - this.sendSocketMessage({ - type: 'chat', - username: this.players[userId].username, - message: message.message, - userId - }); - break; - - case 'chat-live-update': - if (this.settings.public || typeof message.message !== 'string') { return; } - - this.sendSocketMessage({ - type: 'chat-live-update', - username: this.players[userId].username, - message: message.message, - userId - }); - break; - - case 'clear-stats': - this.players[userId].clearStats(); - this.sendSocketMessage({ - type: 'clear-stats', - userId - }); - break; - - case 'difficulties': - if (message.value.some((value) => typeof value !== 'number' || isNaN(value) || value < 0 || value > 10)) { return; } - - this.sendSocketMessage({ - type: 'difficulties', - username: this.players[userId].username, - value: message.value - }); - this.adjustQuery(['difficulties'], [message.value]); - break; - - case 'give-answer': - if (userId !== this.buzzedIn || typeof message.givenAnswer !== 'string') { return; } - - this.giveAnswer(userId, message.givenAnswer); - break; - - case 'give-answer-live-update': - if (userId !== this.buzzedIn || typeof message.message !== 'string') { return; } - - this.liveAnswer = message.message; - this.sendSocketMessage({ - type: 'give-answer-live-update', - username: this.players[userId].username, - message: message.message - }); - break; - - case 'leave': - // this.deletePlayer(userId); - this.players[userId].isOnline = false; - delete this.sockets[userId]; - this.sendSocketMessage(message); - break; + switch (message.type) { + case 'buzz': return this.buzz(userId, message); + case 'change-username': return this.changeUsername(userId, message); + case 'chat': return this.chat(userId, message); + case 'chat-live-update': return this.chatLiveUpdate(userId, message); + case 'clear-stats': return this.clearStats(userId, message); + case 'difficulties': return this.difficulties(userId, message); + case 'give-answer': return this.giveAnswer(userId, message); + case 'give-answer-live-update': return this.giveAnswerLiveUpdate(userId, message); case 'next': case 'skip': case 'start': - this.next(userId, type); - break; - - case 'packet-number': - allowedPacketNumbers = await getNumPackets(this.query.setName); - if (message.value.some((value) => typeof value !== 'number' || value < 1 || value > allowedPacketNumbers)) return; - - this.adjustQuery(['packetNumbers'], [message.value]); - this.sendSocketMessage({ - type: 'packet-number', - username: this.players[userId].username, - value: this.query.packetNumbers - }); - break; - - case 'pause': - this.pause(userId, message.pausedTime); - break; - - case 'reading-speed': - if (isNaN(message.value) || message.value > 100 || message.value < 0) { return; } - - this.settings.readingSpeed = message.value; - this.sendSocketMessage({ - type: 'reading-speed', - username: this.players[userId].username, - value: this.settings.readingSpeed - }); - break; - - case 'set-name': - if (typeof message.value !== 'string' || !this.setList || !this.setList.includes(message.value)) { return; } - - allowedPacketNumbers = await getNumPackets(message.value); - if (message.packetNumbers.some((num) => num > allowedPacketNumbers || num < 1)) return; - - this.sendSocketMessage({ - type: 'set-name', - username: this.players[userId].username, - value: message.value - }); - this.adjustQuery(['setName', 'packetNumbers'], [message.value, message.packetNumbers]); - break; - - case 'toggle-lock': - if (this.settings.public) { - return; - } - - this.settings.lock = message.lock; - this.sendSocketMessage({ - type: 'toggle-lock', - lock: this.settings.lock, - username: this.players[userId].username - }); - break; - - case 'toggle-powermark-only': - this.query.powermarkOnly = message.powermarkOnly; - this.sendSocketMessage({ - type: 'toggle-powermark-only', - powermarkOnly: message.powermarkOnly, - username: this.players[userId].username - }); - this.adjustQuery(['powermarkOnly'], [message.powermarkOnly]); - break; - - case 'toggle-rebuzz': - this.settings.rebuzz = message.rebuzz; - this.sendSocketMessage({ - type: 'toggle-rebuzz', - rebuzz: this.settings.rebuzz, - username: this.players[userId].username - }); - break; - - case 'toggle-select-by-set-name': - if (this.isPermanent || !this.setList || !this.setList.includes(message.setName)) { break; } - - this.sendSocketMessage({ - type: 'toggle-select-by-set-name', - selectBySetName: message.selectBySetName, - setName: this.query.setName, - username: this.players[userId].username - }); - this.settings.selectBySetName = message.selectBySetName; - this.adjustQuery(['setName'], [message.setName]); - break; - - case 'toggle-skip': - this.settings.skip = message.skip; - this.sendSocketMessage({ - type: 'toggle-skip', - skip: this.settings.skip, - username: this.players[userId].username - }); - break; - - case 'toggle-standard-only': - this.query.standardOnly = message.standardOnly; - this.sendSocketMessage({ - type: 'toggle-standard-only', - standardOnly: message.standardOnly, - username: this.players[userId].username - }); - this.adjustQuery(['standardOnly'], [message.standardOnly]); - break; - - case 'toggle-timer': - if (this.settings.public) { - return; - } - - this.settings.timer = message.timer; - this.sendSocketMessage({ - type: 'toggle-timer', - timer: this.settings.timer, - username: this.players[userId].username - }); - break; - - case 'toggle-visibility': - if (this.isPermanent) { break; } - - this.settings.public = message.public; - this.settings.timer = true; - this.sendSocketMessage({ - type: 'toggle-visibility', - public: this.settings.public, - username: this.players[userId].username - }); - break; - - case 'update-categories': - if (this.isPermanent) { break; } - - if ([message.categories, message.subcategories, message.alternateSubcategories].some((array) => !Array.isArray(array))) { break; } - - message.categories = message.categories.filter(category => CATEGORIES.includes(category)); - message.subcategories = message.subcategories.filter(subcategory => SUBCATEGORIES_FLATTENED.includes(subcategory)); - message.alternateSubcategories = message.alternateSubcategories.filter(subcategory => ALTERNATE_SUBCATEGORIES_FLATTENED.includes(subcategory)); - - if (message.subcategories.some(sub => { - const parent = SUBCATEGORY_TO_CATEGORY[sub]; - return !message.categories.includes(parent); - }) || message.alternateSubcategories.some(sub => { - const parent = ALTERNATE_SUBCATEGORY_TO_CATEGORY[sub]; - return !message.categories.includes(parent); - })) { break; } - - this.sendSocketMessage({ - type: 'update-categories', - categories: message.categories, - subcategories: message.subcategories, - alternateSubcategories: message.alternateSubcategories, - username: this.players[userId].username - }); - this.adjustQuery(['categories', 'subcategories', 'alternateSubcategories'], [message.categories, message.subcategories, message.alternateSubcategories]); - break; - - case 'year-range': { - const minYear = isNaN(message.minYear) ? DEFAULT_MIN_YEAR : parseInt(message.minYear); - const maxYear = isNaN(message.maxYear) ? DEFAULT_MAX_YEAR : parseInt(message.maxYear); - - if (maxYear < minYear) { - return this.sendPrivateMessage(userId, { - type: 'year-range', - minYear: this.query.minYear, - maxYear: this.query.maxYear - }); - } - - this.sendSocketMessage({ - type: 'year-range', - minYear, - maxYear - }); - this.adjustQuery(['minYear', 'maxYear'], [minYear, maxYear]); - break; - } + return this.next(userId, message); + + case 'packet-number': return this.packetNumber(userId, message); + case 'pause': return this.pause(userId, message); + case 'reading-speed': return this.readingSpeed(userId, message); + case 'set-name': return this.setName(userId, message); + case 'toggle-lock': return this.toggleLock(userId, message); + case 'toggle-powermark-only': return this.togglePowermarkOnly(userId, message); + case 'toggle-rebuzz': return this.toggleRebuzz(userId, message); + case 'toggle-select-by-set-name': return this.toggleSelectBySetName(userId, message); + case 'toggle-skip': return this.toggleSkip(userId, message); + case 'toggle-standard-only': return this.toggleStandardOnly(userId, message); + case 'toggle-timer': return this.toggleTimer(userId, message); + case 'toggle-visibility': return this.togglePublic(userId, message); + case 'update-categories': return this.updateCategories(userId, message); + case 'year-range': return this.yearRange(userId, message); } } @@ -513,9 +212,7 @@ class TossupRoom { if (this.settings.selectBySetName) { if (this.setCache.length === 0) { - this.sendSocketMessage({ - type: 'end-of-set' - }); + this.emitMessage({ type: 'end-of-set' }); return false; } else { this.tossup = this.setCache.pop(); @@ -527,9 +224,7 @@ class TossupRoom { this.randomQuestionCache = await getRandomTossups(this.query); if (this.randomQuestionCache.length === 0) { this.tossup = {}; - this.sendSocketMessage({ - type: 'no-questions-found' - }); + this.emitMessage({ type: 'no-questions-found' }); return false; } } @@ -542,14 +237,12 @@ class TossupRoom { } buzz (userId) { - if (!this.settings.rebuzz && this.buzzes.includes(userId)) return; + if (!this.settings.rebuzz && this.buzzes.includes(userId)) { return; } + const username = this.players[userId].username; if (this.buzzedIn) { - return this.sendSocketMessage({ - type: 'lost-buzzer-race', - userId, - username: this.players[userId].username - }); + this.emitMessage({ type: 'lost-buzzer-race', userId, username }); + return; } clearTimeout(this.timeoutID); @@ -558,45 +251,65 @@ class TossupRoom { this.buzzpointIndices.push(this.questionSplit.slice(0, this.wordIndex).join(' ').length); this.paused = false; - this.sendSocketMessage({ - type: 'buzz', - userId, - username: this.players[userId].username - }); + this.emitMessage({ type: 'buzz', userId, username }); + this.emitMessage({ type: 'update-question', word: '(#)' }); - this.sendSocketMessage({ - type: 'update-question', - word: '(#)' - }); + this.startServerTimer( + this.ANSWER_TIME_LIMIT * 10, + (time) => this.emitMessage({ type: 'timer-update', timeRemaining: time }), + () => this.giveAnswer(userId, { givenAnswer: this.liveAnswer }) + ); + } - this.startServerTimer(this.ANSWER_TIME_LIMIT * 10, () => { - this.giveAnswer(userId, this.liveAnswer); - }); + changeUsername (userId, { username }) { + if (typeof username !== 'string') { return false; } + + if (!isAppropriateString(username)) { + this.sendToSocket(userId, { + type: 'force-username', + username: this.players[userId].username, + message: 'Your username contains an inappropriate word, so it has been reverted.' + }); + } + + const oldUsername = this.players[userId].username; + const newUsername = this.players[userId].updateUsername(username); + this.emitMessage({ type: 'change-username', userId, oldUsername, newUsername }); } - createPlayer (userId) { - this.players[userId] = new Player(userId); + chat (userId, { message }) { + // prevent chat messages if room is public, since they can still be sent with API + if (this.settings.public || typeof message !== 'string') { return false; } + const username = this.players[userId].username; + this.emitMessage({ type: 'chat', message, username, userId }); } - deletePlayer (userId) { - this.sendSocketMessage({ - type: 'leave', - userId, - username: this.players[userId].username - }); + chatLiveUpdate (userId, { message }) { + if (this.settings.public || typeof message !== 'string') { return false; } + const username = this.players[userId].username; + this.emitMessage({ type: 'chat-live-update', message, username, userId }); + } - delete this.players[userId]; + clearStats (userId) { + this.players[userId].clearStats(); + this.emitMessage({ type: 'clear-stats', userId }); } - giveAnswer (userId, givenAnswer) { - if (this.buzzedIn !== userId) return; + difficulties (userId, { value }) { + const invalid = value.some((value) => typeof value !== 'number' || isNaN(value) || value < 0 || value > 10); + if (invalid) { return false; } + const username = this.players[userId].username; + this.emitMessage({ type: 'difficulties', username, value }); + this.adjustQuery(['difficulties'], [value]); + } + + giveAnswer (userId, { givenAnswer }) { + if (typeof givenAnswer !== 'string') { return false; } + if (this.buzzedIn !== userId) { return false; } this.liveAnswer = ''; clearInterval(this.timerInterval); - this.sendSocketMessage({ - type: 'timer-update', - timeRemaining: this.ANSWER_TIME_LIMIT * 10 - }); + this.emitMessage({ type: 'timer-update', timeRemaining: this.ANSWER_TIME_LIMIT * 10 }); if (Object.keys(this.tossup).length === 0) { return; } @@ -604,11 +317,7 @@ class TossupRoom { const endOfQuestion = (this.wordIndex === this.questionSplit.length); const inPower = this.questionSplit.indexOf('(*)') >= this.wordIndex; const { directive, directedPrompt } = checkAnswer(this.tossup.answer, givenAnswer); - const points = scoreTossup({ - isCorrect: directive === 'accept', - inPower, - endOfQuestion - }); + const points = scoreTossup({ isCorrect: directive === 'accept', inPower, endOfQuestion }); switch (directive) { case 'accept': @@ -628,12 +337,14 @@ class TossupRoom { } break; case 'prompt': - this.startServerTimer(this.ANSWER_TIME_LIMIT * 10, () => { - this.giveAnswer(userId, this.liveAnswer); - }); + this.startServerTimer( + this.ANSWER_TIME_LIMIT * 10, + (time) => this.emitMessage({ type: 'timer-update', timeRemaining: time }), + () => this.giveAnswer(userId, { givenAnswer: this.liveAnswer }) + ); } - this.sendSocketMessage({ + this.emitMessage({ type: 'give-answer', userId, username: this.players[userId].username, @@ -648,81 +359,107 @@ class TossupRoom { }); } - /** - * Logic for when the user presses the next button. - * @param {string} userId - The userId of the user who pressed the next button. - * @param {'next' | 'skip' | 'start'} type - The type of next button pressed. - * @returns - */ - async next (userId, type) { - if (this.queryingQuestion) return; - if (this.buzzedIn) return; // prevents skipping when someone has buzzed in + giveAnswerLiveUpdate (userId, { message }) { + if (typeof message !== 'string') { return false; } + if (userId !== this.buzzedIn) { return false; } + this.liveAnswer = message; + const username = this.players[userId].username; + this.emitMessage({ type: 'give-answer-live-update', message, username }); + } - if (this.questionProgress === QuestionProgressEnum.READING && !this.settings.skip) return; - if (type === 'skip' && this.wordIndex < 5) return; // prevents spam-skipping bots + leave (userId) { + // this.deletePlayer(userId); + this.players[userId].isOnline = false; + delete this.sockets[userId]; + const username = this.players[userId].username; + this.emitMessage({ type: 'leave', userId, username }); + } - clearTimeout(this.timeoutID); + /** + * Logic for when the user presses the next button. + * @param {string} userId - The userId of the user who pressed the next button. + * @param {'next' | 'skip' | 'start'} type - The type of next button pressed. + * @returns + */ + async next (userId, { type }) { + if (this.buzzedIn) { return false; } // prevents skipping when someone has buzzed in + if (this.queryingQuestion) { return false; } + if (this.questionProgress === QuestionProgressEnum.READING && !this.settings.skip) { return false; } + if (type === 'skip' && this.wordIndex < 5) { return false; } // prevents spam-skipping bots + clearTimeout(this.timeoutID); this.buzzedIn = null; this.buzzes = []; this.buzzpointIndices = []; this.paused = false; - if (this.questionProgress !== QuestionProgressEnum.ANSWER_REVEALED) { - this.revealQuestion(); - } + if (this.questionProgress !== QuestionProgressEnum.ANSWER_REVEALED) { this.revealQuestion(); } const hasNextQuestion = await this.advanceQuestion(); this.queryingQuestion = false; - if (!hasNextQuestion) return; + if (!hasNextQuestion) { return; } - this.sendSocketMessage({ - type, - userId, - username: this.players[userId].username, - tossup: this.tossup - }); + const username = this.players[userId].username; + this.emitMessage({ type, userId, username, tossup: this.tossup }); this.wordIndex = 0; this.questionProgress = QuestionProgressEnum.READING; this.readQuestion(Date.now()); } + async packetNumber (userId, { value }) { + const allowedPacketNumbers = await getNumPackets(this.query.setName); + if (value.some((value) => typeof value !== 'number' || value < 1 || value > allowedPacketNumbers)) { return false; } + + const username = this.players[userId].username; + this.emitMessage({ type: 'packet-number', username, value }); + this.adjustQuery(['packetNumbers'], [value]); + } + pause (userId) { - if (this.buzzedIn) return; + if (this.buzzedIn) { return false; } this.paused = !this.paused; - if (this.paused) { clearTimeout(this.timeoutID); - clearInterval(this.timerInterval); + clearInterval(this.timer.interval); } else if (this.wordIndex >= this.questionSplit.length) { - this.startServerTimer(this.timeRemaining, this.revealQuestion.bind(this)); + this.startServerTimer( + this.timer.timeRemaining, + (time) => this.emitMessage({ type: 'timer-update', timeRemaining: time }), + () => this.revealQuestion() + ); } else { this.readQuestion(Date.now()); } + const username = this.players[userId].username; + this.emitMessage({ type: 'pause', paused: this.paused, username }); + } - this.sendSocketMessage({ - type: 'pause', - paused: this.paused, - username: this.players[userId].username - }); + readingSpeed (userId, { value }) { + if (isNaN(value)) { return false; } + if (value > 100) { value = 100; } + if (value < 0) { value = 0; } + + this.settings.readingSpeed = value; + const username = this.players[userId].username; + this.emitMessage({ type: 'reading-speed', username, value }); } async readQuestion (expectedReadTime) { - if (Object.keys(this.tossup).length === 0) return; + if (Object.keys(this.tossup).length === 0) { return; } if (this.wordIndex >= this.questionSplit.length) { - this.startServerTimer(this.DEAD_TIME_LIMIT * 10, this.revealQuestion.bind(this)); + this.startServerTimer( + this.DEAD_TIME_LIMIT * 10, + (time) => this.emitMessage({ type: 'timer-update', timeRemaining: time }), + () => this.revealQuestion() + ); return; } const word = this.questionSplit[this.wordIndex]; this.wordIndex++; - - this.sendSocketMessage({ - type: 'update-question', - word - }); + this.emitMessage({ type: 'update-question', word }); // calculate time needed before reading next word let time = Math.log(word.length) + 1; @@ -747,54 +484,119 @@ class TossupRoom { if (Object.keys(this.tossup).length === 0) return; this.questionProgress = QuestionProgressEnum.ANSWER_REVEALED; - this.sendSocketMessage({ + this.emitMessage({ type: 'reveal-answer', question: insertTokensIntoHTML(this.tossup.question, this.tossup.question_sanitized, [this.buzzpointIndices], [' (#) ']), answer: this.tossup.answer }); } - sendSocketMessage (message) { - message = JSON.stringify(message); - for (const socket of Object.values(this.sockets)) { - socket.send(message); - } + async setName (userId, { packetNumbers, value }) { + if (typeof value !== 'string') { return; } + if (!this.setList) { return; } + if (!this.setList.includes(value)) { return; } + const maxPacketNumber = await getNumPackets(value); + if (packetNumbers.some((num) => num > maxPacketNumber || num < 1)) { return; } + + const username = this.players[userId].username; + this.emitMessage({ type: 'set-name', username, value }); + this.adjustQuery(['setName', 'packetNumbers'], [value, packetNumbers]); } - sendPrivateMessage (userId, message) { - message = JSON.stringify(message); - this.sockets[userId].send(message); + toggleLock (userId, { lock }) { + if (this.settings.public) { return; } + + this.settings.lock = lock; + const username = this.players[userId].username; + this.emitMessage({ type: 'toggle-lock', lock, username }); } - /** - * - * @param {number} time - * @param {Function} callback - called when timer is up - * @returns - */ - startServerTimer (time, callback) { - if (this.settings.timer === false) { - return; - } + togglePowermarkOnly (userId, { powermarkOnly }) { + this.query.powermarkOnly = powermarkOnly; + const username = this.players[userId].username; + this.emitMessage({ type: 'toggle-powermark-only', powermarkOnly, username }); + this.adjustQuery(['powermarkOnly'], [powermarkOnly]); + } - if (this.timerInterval) { - clearInterval(this.timerInterval); - } + toggleRebuzz (userId, { rebuzz }) { + this.settings.rebuzz = rebuzz; + const username = this.players[userId].username; + this.emitMessage({ type: 'toggle-rebuzz', rebuzz, username }); + } - this.timeRemaining = time; + toggleSelectBySetName (userId, { selectBySetName, setName }) { + if (this.isPermanent) { return; } + if (!this.setList) { return; } + if (!this.setList.includes(setName)) { return; } - this.timerInterval = setInterval(() => { - if (this.timeRemaining <= 0) { - clearInterval(this.timerInterval); - callback(); - } + this.settings.selectBySetName = selectBySetName; + const username = this.players[userId].username; + this.emitMessage({ type: 'toggle-select-by-set-name', selectBySetName, setName, username }); + this.adjustQuery(['setName'], [setName]); + } + + toggleSkip (userId, { skip }) { + this.settings.skip = skip; + const username = this.players[userId].username; + this.emitMessage({ type: 'toggle-skip', skip, username }); + } + + toggleStandardOnly (userId, { standardOnly }) { + this.query.standardOnly = standardOnly; + const username = this.players[userId].username; + this.emitMessage({ type: 'toggle-standard-only', standardOnly, username }); + this.adjustQuery(['standardOnly'], [standardOnly]); + } + + toggleTimer (userId, { timer }) { + if (this.settings.public) { return; } + this.settings.timer = timer; + const username = this.players[userId].username; + this.emitMessage({ type: 'toggle-timer', timer, username }); + } - this.sendSocketMessage({ - type: 'timer-update', - timeRemaining: this.timeRemaining + togglePublic (userId, { public: isPublic }) { + if (this.isPermanent) { return; } + this.settings.public = isPublic; + this.settings.timer = true; + const username = this.players[userId].username; + this.emitMessage({ type: 'toggle-visibility', public: isPublic, username }); + } + + updateCategories (userId, { categories, subcategories, alternateSubcategories }) { + if (this.isPermanent) { return; } + if (!Array.isArray(categories)) { return; } + if (!Array.isArray(subcategories)) { return; } + if (!Array.isArray(alternateSubcategories)) { return; } + + categories = categories.filter(category => CATEGORIES.includes(category)); + subcategories = subcategories.filter(subcategory => SUBCATEGORIES_FLATTENED.includes(subcategory)); + alternateSubcategories = alternateSubcategories.filter(subcategory => ALTERNATE_SUBCATEGORIES_FLATTENED.includes(subcategory)); + + if (subcategories.some(sub => !categories.includes(SUBCATEGORY_TO_CATEGORY[sub]))) { return; } + if (alternateSubcategories.some(sub => !categories.includes(ALTERNATE_SUBCATEGORY_TO_CATEGORY[sub]))) { return; } + + const username = this.players[userId].username; + this.emitMessage({ type: 'update-categories', categories, subcategories, alternateSubcategories, username }); + this.adjustQuery(['categories', 'subcategories', 'alternateSubcategories'], [categories, subcategories, alternateSubcategories]); + } + + yearRange (userId, { minYear, maxYear }) { + minYear = parseInt(minYear); + maxYear = parseInt(maxYear); + if (isNaN(minYear)) { minYear = DEFAULT_MIN_YEAR; } + if (isNaN(maxYear)) { maxYear = DEFAULT_MAX_YEAR; } + + if (maxYear < minYear) { + this.sendToSocket(userId, { + type: 'year-range', + minYear: this.query.minYear, + maxYear: this.query.maxYear }); - this.timeRemaining--; - }, 100); + } else { + this.emitMessage({ type: 'year-range', minYear, maxYear }); + this.adjustQuery(['minYear', 'maxYear'], [minYear, maxYear]); + } } }