From 2c75c1d64e002edcc8ea53331b857331b3d76962 Mon Sep 17 00:00:00 2001 From: Benjie Chen Date: Sun, 7 Dec 2025 16:55:35 -0500 Subject: [PATCH 1/2] Using mixin pattern to dynamically create ServerTossupRoom and MultiplayerTossupClient, so that later on we can create ServerTossupBonusRoom and MultiplayerTossupBonusClient and reuse logic that governs multiplayer rooms add basic skletons and wiring for TossupBonusRoom and TossupBonusClient, that are just subclasses of TossupRoom and TossupClient, respectively, for now, with no new features can advance to next bonus question, still needs client support for bonuses with one user, can now go through tossup to bonuses back to tossup successfully added bonuses to multiplayer with working UI/UX added bonus timer changed bonus timer on first part to 15s instead of 10s --- client/play/tossups/TossupBonusClient.js | 116 +++ client/play/tossups/TossupClient.js | 20 +- .../play/tossups/mp/MultiplayerClientMixin.js | 753 ++++++++++++++++++ .../mp/MultiplayerTossupBonusClient.js | 5 + .../tossups/mp/MultiplayerTossupClient.js | 698 +--------------- client/play/tossups/mp/room.html | 4 +- client/play/tossups/mp/room.jsx | 16 +- quizbowl/QuestionRoom.js | 5 +- quizbowl/TossupBonusRoom.js | 273 +++++++ quizbowl/TossupRoom.js | 20 +- .../multiplayer/ServerMultiplayerRoomMixin.js | 467 +++++++++++ server/multiplayer/ServerTossupBonusRoom.js | 5 + server/multiplayer/ServerTossupRoom.js | 421 +--------- server/multiplayer/handle-wss-connection.js | 8 +- 14 files changed, 1679 insertions(+), 1132 deletions(-) create mode 100644 client/play/tossups/TossupBonusClient.js create mode 100644 client/play/tossups/mp/MultiplayerClientMixin.js create mode 100644 client/play/tossups/mp/MultiplayerTossupBonusClient.js create mode 100644 quizbowl/TossupBonusRoom.js create mode 100644 server/multiplayer/ServerMultiplayerRoomMixin.js create mode 100644 server/multiplayer/ServerTossupBonusRoom.js diff --git a/client/play/tossups/TossupBonusClient.js b/client/play/tossups/TossupBonusClient.js new file mode 100644 index 000000000..8566ac8ec --- /dev/null +++ b/client/play/tossups/TossupBonusClient.js @@ -0,0 +1,116 @@ +import TossupClient from './TossupClient.js'; +import addBonusGameCard from '../bonuses/add-bonus-game-card.js'; + +export default class TossupBonusClient extends TossupClient { + constructor (room, userId, socket) { + super(room, userId, socket); + } + + onmessage (message) { + const data = JSON.parse(message); + switch (data.type) { + case 'reveal-leadin': return this.revealLeadin(data); + case 'reveal-next-answer': return this.revealNextAnswer(data); + case 'reveal-next-part': return this.revealNextPart(data); + case 'start-answer': return this.startAnswer(data); + default: return super.onmessage(message); + } + } + + next (data) { + if (data.bonus) { + super.next({ oldTossup: data.oldTossup, nextQuestion: data.bonus, packetLength: data.packetLength, type: data.type }); + document.getElementById('answer').textContent = ''; + + if (data.type === 'end') { + document.getElementById('next').disabled = true; + document.getElementById('reveal').disabled = true; + document.getElementById('buzz').disabled = false; + } else { + document.getElementById('reveal').disabled = false; + document.getElementById('buzz').disabled = true; + } + } + else { + if (data.type !== 'start' && data.oldBonus) { + addBonusGameCard({ bonus: data.oldBonus, starred: data.starred }); + } + + super.next(data); + } + } + + revealNextAnswer ({ answer, currentPartNumber, lastPartRevealed }) { + const paragraph = document.createElement('p'); + paragraph.innerHTML = 'ANSWER: ' + answer; + document.getElementById(`bonus-part-${currentPartNumber + 1}`).appendChild(paragraph); + + if (lastPartRevealed) { + document.getElementById('reveal').disabled = true; + document.getElementById('next').disabled = false; + } + } + + giveAnswer (data) { + const { directive, directedPrompt, score, userId } = data; + super.giveAnswer({ directive, directedPrompt, score, userId }); + + if (data.currentPartNumber !== undefined) { + const currentPartNumber = data.currentPartNumber; + + if (directive === 'accept') { + document.getElementById(`checkbox-${currentPartNumber + 1}`).checked = true; + } + } + } + + startAnswer (data) { + const { userId } = data; + + // Only show answer input for the user who can answer the bonus + if (userId === this.USER_ID) { + document.getElementById('answer-input-group').classList.remove('d-none'); + document.getElementById('answer-input').focus(); + } + + document.getElementById('reveal').disabled = true; + } + + revealLeadin ({ leadin }) { + const paragraph = document.createElement('p'); + paragraph.id = 'leadin'; + paragraph.innerHTML = leadin; + document.getElementById('question').appendChild(paragraph); + } + + revealNextPart ({ currentPartNumber, part, value, bonusEligibleUserId }) { + // Only enable Reveal button for the user who can answer the bonus + document.getElementById('reveal').disabled = (bonusEligibleUserId !== this.USER_ID); + + const input = document.createElement('input'); + input.id = `checkbox-${currentPartNumber + 1}`; + input.className = 'checkbox form-check-input rounded-0 me-1'; + input.type = 'checkbox'; + input.disabled = true; + input.style = 'width: 20px; height: 20px; cursor: not-allowed'; + + const inputWrapper = document.createElement('label'); + inputWrapper.style = 'cursor: default'; + inputWrapper.appendChild(input); + + const p = document.createElement('p'); + p.innerHTML = `[${value}] ${part}`; + + const bonusPart = document.createElement('div'); + bonusPart.id = `bonus-part-${currentPartNumber + 1}`; + bonusPart.appendChild(p); + + const row = document.createElement('div'); + row.className = 'd-flex'; + row.appendChild(inputWrapper); + row.appendChild(bonusPart); + + document.getElementById('question').appendChild(row); + } + +} diff --git a/client/play/tossups/TossupClient.js b/client/play/tossups/TossupClient.js index a74f34f36..07793376d 100644 --- a/client/play/tossups/TossupClient.js +++ b/client/play/tossups/TossupClient.js @@ -38,15 +38,23 @@ export default class TossupClient extends QuestionClient { } } - next ({ nextTossup, oldTossup, packetLength, starred, type }) { + next (data) { + if (data.type !== 'start' && data.oldTossup) { + addTossupGameCard({ starred: data.starred, tossup: data.oldTossup }); + } + if (data.nextQuestion) { // just passing through, e.g. from a child class that handles bonus questions + super.next(data); + } + else { + this.nextTossup(data); + } + } + + nextTossup ({ tossup: nextTossup, oldTossup, packetLength, starred, type }) { super.next({ nextQuestion: nextTossup, packetLength, type }); document.getElementById('answer').textContent = ''; - if (type !== 'start') { - addTossupGameCard({ starred, tossup: oldTossup }); - } - if (type === 'end') { document.getElementById('buzz').disabled = true; } else { @@ -54,6 +62,8 @@ export default class TossupClient extends QuestionClient { document.getElementById('buzz').disabled = false; document.getElementById('pause').textContent = 'Pause'; document.getElementById('pause').disabled = false; + + this.room.tossup = nextTossup; } } diff --git a/client/play/tossups/mp/MultiplayerClientMixin.js b/client/play/tossups/mp/MultiplayerClientMixin.js new file mode 100644 index 000000000..1e2927bff --- /dev/null +++ b/client/play/tossups/mp/MultiplayerClientMixin.js @@ -0,0 +1,753 @@ + +import { MODE_ENUM } from '../../../../quizbowl/constants.js'; +import questionStats from '../../../scripts/auth/question-stats.js'; +import { arrayToRange } from '../../ranges.js'; +import upsertPlayerItem from '../../upsert-player-item.js'; +import { setYear } from '../../year-slider.js'; + +const MultiplayerClientMixin = (ClientClass) => class extends ClientClass { + constructor (room, userId, socket) { + super(room, userId, socket); + this.socket = socket; + } + + onmessage (event) { + const data = JSON.parse(event.data); + console.log("MultiplayerClientMixin onmessage", data.type, data); + switch (data.type) { + case 'chat': return this.chat(data, false); + case 'chat-live-update': return this.chat(data, true); + case 'clear-stats': return this.clearStats(data); + case 'confirm-ban': return this.confirmBan(data); + case 'connection-acknowledged': return this.connectionAcknowledged(data); + case 'connection-acknowledged-query': return this.connectionAcknowledgedQuery(data); + case 'connection-acknowledged-tossup': return this.connectionAcknowledgedTossup(data); + case 'connection-acknowledged-bonus': return this.connectionAcknowledgedBonus(data); + case 'enforcing-removal': return this.ackRemovedFromRoom(data); + case 'error': return this.handleError(data); + case 'force-username': return this.forceUsername(data); + case 'give-answer-live-update': return this.logGiveAnswer(data); + case 'initiated-vk': return this.vkInit(data); + case 'join': return this.join(data); + case 'leave': return this.leave(data); + case 'lost-buzzer-race': return this.lostBuzzerRace(data); + case 'mute-player': return this.mutePlayer(data); + case 'no-points-votekick-attempt': return this.failedVotekickPoints(data); + case 'owner-change': return this.ownerChange(data); + case 'set-username': return this.setUsername(data); + case 'successful-vk': return this.vkHandle(data); + case 'toggle-controlled': return this.toggleControlled(data); + case 'toggle-lock': return this.toggleLock(data); + case 'toggle-login-required': return this.toggleLoginRequired(data); + case 'toggle-public': return this.togglePublic(data); + default: return super.onmessage(event.data); + } + } + + // if a banned/kicked user tries to join a this.room they were removed from this is the response + ackRemovedFromRoom ({ removalType }) { + if (removalType === 'kick') { + window.alert('You were kicked from this room by room players, and cannot rejoin it.'); + } else { + window.alert('You were banned from this room by the room owner, and cannot rejoin it.'); + } + setTimeout(() => { + window.location.replace('../'); + }, 100); + } + + buzz ({ userId, username }) { + this.logEventConditionally(username, 'buzzed'); + document.getElementById('skip').disabled = true; + + if (userId === this.USER_ID) { + document.getElementById('answer-input-group').classList.remove('d-none'); + document.getElementById('answer-input').focus(); + } + super.buzz({ userId }); + } + + chat ({ message, userId, username }, live = false) { + if (this.room.muteList.includes(userId)) { + return; + } + if (!live && message === '') { + document.getElementById('live-chat-' + userId).parentElement.remove(); + return; + } + + if (!live && message) { + document.getElementById('live-chat-' + userId).className = ''; + document.getElementById('live-chat-' + userId).id = ''; + return; + } + + if (document.getElementById('live-chat-' + userId)) { + document.getElementById('live-chat-' + userId).textContent = message; + return; + } + + const b = document.createElement('b'); + b.textContent = username; + + const span = document.createElement('span'); + span.classList.add('text-muted'); + span.id = 'live-chat-' + userId; + span.textContent = message; + + const li = document.createElement('li'); + li.appendChild(b); + li.appendChild(document.createTextNode(' ')); + li.appendChild(span); + document.getElementById('room-history').prepend(li); + } + + clearStats ({ userId }) { + for (const field of ['celerity', 'negs', 'points', 'powers', 'tens', 'tuh', 'zeroes']) { + this.room.players[userId][field] = 0; + } + upsertPlayerItem(this.room.players[userId], this.USER_ID, this.room.ownerId, this.socket, this.room.public); + this.sortPlayerListGroup(); + } + + confirmBan ({ targetId, targetUsername }) { + if (targetId === this.USER_ID) { + window.alert('You were banned from this room by the room owner.'); + setTimeout(() => { + window.location.replace('../'); + }, 100); + } else { + this.logEventConditionally(targetUsername + ' has been banned from this room.'); + } + } + + connectionAcknowledged ({ + buzzedIn, + canBuzz, + isPermanent, + ownerId: serverOwnerId, + mode, + packetLength, + players: messagePlayers, + questionProgress, + settings, + setLength: newSetLength, + userId + }) { + this.room.public = settings.public; + this.room.ownerId = serverOwnerId; + this.room.setLength = newSetLength; + this.USER_ID = userId; + window.localStorage.setItem('USER_ID', this.USER_ID); + + document.getElementById('buzz').disabled = !canBuzz; + + if (isPermanent) { + document.getElementById('category-select-button').disabled = true; + document.getElementById('permanent-room-warning').classList.remove('d-none'); + document.getElementById('reading-speed').disabled = true; + document.getElementById('set-strictness').disabled = true; + document.getElementById('set-mode').disabled = true; + document.getElementById('toggle-public').disabled = true; + } + + for (const userId of Object.keys(messagePlayers)) { + messagePlayers[userId].celerity = messagePlayers[userId].celerity.correct.average; + this.room.players[userId] = messagePlayers[userId]; + upsertPlayerItem(this.room.players[userId], this.USER_ID, this.room.ownerId, this.socket, this.room.public); + } + this.sortPlayerListGroup(); + + this.setMode({ mode }); + + document.getElementById('packet-length-info').textContent = mode === MODE_ENUM.SET_NAME ? packetLength : '-'; + + switch (questionProgress) { + case 0: + document.getElementById('next').textContent = 'Start'; + document.getElementById('next').classList.remove('btn-primary'); + document.getElementById('next').classList.add('btn-success'); + break; + case 1: + this.showSkipButton(); + document.getElementById('settings').classList.add('d-none'); + if (buzzedIn) { + document.getElementById('buzz').disabled = true; + document.getElementById('next').disabled = true; + document.getElementById('pause').disabled = true; + } else { + document.getElementById('buzz').disabled = false; + document.getElementById('pause').disabled = false; + } + break; + case 2: + this.showNextButton(); + document.getElementById('settings').classList.add('d-none'); + break; + } + + this.toggleLock({ lock: settings.lock }); + this.toggleLoginRequired({ loginRequired: settings.loginRequired }); + this.toggleRebuzz({ rebuzz: settings.rebuzz }); + this.toggleSkip({ skip: settings.skip }); + this.toggleTimer({ timer: settings.timer }); + this.setReadingSpeed({ readingSpeed: settings.readingSpeed }); + this.setStrictness({ strictness: settings.strictness }); + + if (settings.controlled) { + this.toggleControlled({ controlled: settings.controlled }); + } + if (settings.public) { + this.togglePublic({ public: settings.public }); + } + } + + async connectionAcknowledgedQuery ({ + difficulties = [], + minYear, + maxYear, + packetNumbers = [], + powermarkOnly, + setName = '', + standardOnly, + alternateSubcategories, + categories, + subcategories, + percentView, + categoryPercents + }) { + this.setDifficulties({ difficulties }); + + // need to set min year first to avoid conflicts between saved max year and default min year + setYear(minYear, 'min-year'); + setYear(maxYear, 'max-year'); + + document.getElementById('packet-number').value = arrayToRange(packetNumbers); + document.getElementById('set-name').value = setName; + document.getElementById('toggle-powermark-only').checked = powermarkOnly; + + if (setName !== '' && this.room.setLength === 0) { + document.getElementById('set-name').classList.add('is-invalid'); + } + + document.getElementById('toggle-standard-only').checked = standardOnly; + + this.setCategories({ categories, subcategories, alternateSubcategories, percentView, categoryPercents }); + + $(document).ready(function () { + $('#slider').slider('values', 0, minYear); + $('#slider').slider('values', 1, maxYear); + }); + } + + connectionAcknowledgedTossup ({ tossup: currentTossup }) { + this.room.tossup = currentTossup; + document.getElementById('set-name-info').textContent = this.room.tossup?.set?.name ?? ''; + document.getElementById('packet-number-info').textContent = this.room.tossup?.packet?.number ?? '-'; + document.getElementById('question-number-info').textContent = this.room.tossup?.number ?? '-'; + } + + connectionAcknowledgedBonus ({ bonus, bonusProgress, currentPartNumber, pointsPerPart, bonusEligibleUserId }) { + // Store bonus state in room + this.room.bonus = bonus; + this.room.bonusProgress = bonusProgress; + this.room.currentPartNumber = currentPartNumber; + this.room.pointsPerPart = pointsPerPart; + this.room.bonusEligibleUserId = bonusEligibleUserId; + + // Clear the question display - it will be rebuilt by the reveal messages + document.getElementById('question').textContent = ''; + document.getElementById('answer').textContent = ''; + + // Enable buzz button for bonuses (disabled for tossups during reconnection) + document.getElementById('buzz').disabled = true; + + // The reveal-next-part and reveal-next-answer messages will follow to rebuild the UI + // If bonus is complete (lastPartRevealed), enable Next button + if (bonusProgress === 2) { // BONUS_PROGRESS_ENUM.LAST_PART_REVEALED + this.showNextButton(); + } + } + + failedVotekickPoints ({ userId }) { + if (userId === this.USER_ID) { + window.alert('You can only votekick once you have answered a question correctly!'); + } + } + + forceUsername ({ message, username }) { + window.alert(message); + window.localStorage.setItem('multiplayer-username', username); + document.querySelector('#username').value = username; + } + + async giveAnswer (data) { + const { directive, directedPrompt, givenAnswer, score, userId, username } = data; + + this.logGiveAnswer({ directive, givenAnswer, username }); + + if (directive === 'prompt' && directedPrompt) { + this.logEventConditionally(username, `was prompted with "${directedPrompt}"`); + } else if (directive === 'prompt') { + this.logEventConditionally(username, 'was prompted'); + } else { + this.logEventConditionally(username, `${score > 0 ? '' : 'in'}correctly answered for ${score} points`); + } + + super.giveAnswer(data); + + if (directive === 'prompt') { return; } + + document.getElementById('pause').disabled = false; + + if (directive === 'accept') { + document.getElementById('buzz').disabled = true; + document.getElementById('reveal').disabled = true; + Array.from(document.getElementsByClassName('tuh')).forEach(element => { + element.textContent = parseInt(element.innerHTML) + 1; + }); + } + + if (directive === 'reject') { + if (data.tossup) { + document.getElementById('buzz').disabled = !document.getElementById('toggle-rebuzz').checked && userId === this.USER_ID; + } + else { + document.getElementById('reveal').disabled = !document.getElementById('toggle-rebuzz').checked && userId === this.USER_ID; + } + } + + if (data.tossup) { + const { celerity, tossup, perQuestionCelerity } = data; + + if (score > 10) { + this.room.players[userId].powers++; + } else if (score === 10) { + this.room.players[userId].tens++; + } else if (score < 0) { + this.room.players[userId].negs++; + } + + this.room.players[userId].points += score; + this.room.players[userId].tuh++; + this.room.players[userId].celerity = celerity; + + upsertPlayerItem(this.room.players[userId], this.USER_ID, this.room.ownerId, this.socket, this.room.public); + this.sortPlayerListGroup(); + + if (userId === this.USER_ID) { + questionStats.recordTossup({ + _id: tossup._id, + celerity: perQuestionCelerity, + isCorrect: score > 0, + multiplayer: true, + pointValue: score + }); + } + } + + if (data.bonus && data.currentPartNumber !== undefined) { + // Update player points for bonus parts + this.room.players[userId].points += score; + + upsertPlayerItem(this.room.players[userId], this.USER_ID, this.room.ownerId, this.socket, this.room.public); + this.sortPlayerListGroup(); + } + } + + handleError ({ message }) { + this.socket.close(3000); + window.alert(message); + window.location.href = '/multiplayer'; + } + + join ({ isNew, user, userId, username }) { + this.logEventConditionally(username, 'joined the game'); + if (userId === this.USER_ID) { return; } + this.room.players[userId] = user; + + if (isNew) { + user.celerity = user.celerity.correct.average; + upsertPlayerItem(user, this.USER_ID, this.room.ownerId, this.socket, this.room.public); + this.sortPlayerListGroup(); + } else { + document.getElementById(`list-group-${userId}`).classList.remove('offline'); + document.getElementById('points-' + userId).classList.add('bg-success'); + document.getElementById('points-' + userId).classList.remove('bg-secondary'); + document.getElementById('username-' + userId).textContent = username; + } + } + + leave ({ userId, username }) { + this.logEventConditionally(username, 'left the game'); + this.room.players[userId].online = false; + document.getElementById(`list-group-${userId}`).classList.add('offline'); + document.getElementById(`points-${userId}`).classList.remove('bg-success'); + document.getElementById(`points-${userId}`).classList.add('bg-secondary'); + } + + /** + * Log the event, but only if `username !== undefined`. + * If username is undefined, do nothing, regardless of the value of message. + * @param {string | undefined} username + * @param {string | undefined} message + */ + logEventConditionally (username, message) { + if (username === undefined) { return; } + + const span1 = document.createElement('span'); + span1.textContent = username; + + const span2 = document.createElement('span'); + span2.textContent = message; + + const i = document.createElement('i'); + i.appendChild(span1); + i.appendChild(document.createTextNode(' ')); + i.appendChild(span2); + + const li = document.createElement('li'); + li.appendChild(i); + + document.getElementById('room-history').prepend(li); + } + + logGiveAnswer ({ directive = null, givenAnswer, username }) { + const badge = document.createElement('span'); + badge.textContent = 'Buzz'; + switch (directive) { + case 'accept': + badge.className = 'badge text-dark bg-success'; + break; + case 'reject': + badge.className = 'badge text-light bg-danger'; + break; + case 'prompt': + badge.className = 'badge text-dark bg-warning'; + break; + default: + badge.className = 'badge text-light bg-primary'; + break; + } + + const b = document.createElement('b'); + b.textContent = username; + + const span = document.createElement('span'); + span.textContent = givenAnswer; + + let li; + if (document.getElementById('live-buzz')) { + li = document.getElementById('live-buzz'); + li.textContent = ''; + } else { + li = document.createElement('li'); + li.id = 'live-buzz'; + document.getElementById('room-history').prepend(li); + } + + li.appendChild(badge); + li.appendChild(document.createTextNode(' ')); + li.appendChild(b); + li.appendChild(document.createTextNode(' ')); + li.appendChild(span); + + if (directive === 'accept' || directive === 'reject') { + const secondBadge = document.createElement('span'); + secondBadge.className = badge.className; + + if (directive === 'accept') { + secondBadge.textContent = 'Correct'; + } else if (directive === 'reject') { + secondBadge.textContent = 'Incorrect'; + } + + li.appendChild(document.createTextNode(' ')); + li.appendChild(secondBadge); + } + + if (directive) { li.id = ''; } + } + + lostBuzzerRace ({ username, userId }) { + this.logEventConditionally(username, 'lost the buzzer race'); + if (userId === this.USER_ID) { document.getElementById('answer-input-group').classList.add('d-none'); } + } + + mutePlayer ({ targetId, targetUsername, muteStatus }) { + if (muteStatus === 'Mute') { + if (!this.room.muteList.includes(targetId)) { + this.room.muteList.push(targetId); + this.logEventConditionally(targetUsername, 'was muted'); + } + } else { + if (this.room.muteList.includes(targetId)) { + this.room.muteList = this.room.muteList.filter(Id => Id !== targetId); + this.logEventConditionally(targetUsername, 'was unmuted'); + } + } + } + + next (data) { + const username = data.username; + const type = data.type; + + const typeStrings = { + end: 'ended the game', + next: 'went to the next question', + skip: 'skipped the question', + start: 'started the game' + }; + this.logEventConditionally(username, typeStrings[type]); + + super.next(data); + + if (type === 'start') { + document.getElementById('next').classList.add('btn-primary'); + document.getElementById('next').classList.remove('btn-success'); + document.getElementById('next').textContent = 'Next'; + } + + if (type === 'end') { + document.getElementById('next').classList.remove('btn-primary'); + document.getElementById('next').classList.add('btn-success'); + document.getElementById('next').textContent = 'Start'; + } + + this.showSkipButton(); + } + + ownerChange ({ newOwner }) { + if (this.room.players[newOwner]) { + this.room.ownerId = newOwner; + this.logEventConditionally(this.room.players[newOwner].username, 'became the room owner'); + } else this.logEventConditionally(newOwner, 'became the room owner'); + + Object.keys(this.room.players).forEach((player) => { + upsertPlayerItem(this.room.players[player], this.USER_ID, this.room.ownerId, this.socket, this.room.public); + }); + + document.getElementById('toggle-controlled').disabled = this.room.public || (this.room.ownerId !== this.USER_ID); + } + + pause ({ paused, username }) { + this.logEventConditionally(username, `${paused ? '' : 'un'}paused the game`); + super.pause({ paused }); + } + + revealAnswer ({ answer, question }) { + super.revealAnswer({ answer, question }); + this.showNextButton(); + } + + revealNextAnswer ({ answer, currentPartNumber, lastPartRevealed }) { + super.revealNextAnswer({ answer, currentPartNumber, lastPartRevealed }); + if (lastPartRevealed) { this.showNextButton(); } + } + + setCategories ({ alternateSubcategories, categories, subcategories, percentView, categoryPercents, username }) { + this.logEventConditionally(username, 'updated the categories'); + this.room.categoryManager.import({ categories, subcategories, alternateSubcategories, percentView, categoryPercents }); + if (!document.getElementById('category-modal')) { return; } + super.setCategories(); + } + + setDifficulties ({ difficulties, username = undefined }) { + this.logEventConditionally(username, difficulties.length > 0 ? `set the difficulties to ${difficulties}` : 'cleared the difficulties'); + + if (!document.getElementById('difficulties')) { + this.room.difficulties = difficulties; + return; + } + + Array.from(document.getElementById('difficulties').children).forEach(li => { + const input = li.querySelector('input'); + if (difficulties.includes(parseInt(input.value))) { + input.checked = true; + li.classList.add('active'); + } else { + input.checked = false; + li.classList.remove('active'); + } + }); + } + + setMinYear ({ minYear, username }) { + const maxYear = parseInt(document.getElementById('max-year-label').textContent); + this.logEventConditionally(username, `changed the year range to ${minYear}-${maxYear}`); + super.setMinYear({ minYear }); + } + + setMaxYear ({ maxYear, username }) { + const minYear = parseInt(document.getElementById('min-year-label').textContent); + this.logEventConditionally(username, `changed the year range to ${minYear}-${maxYear}`); + super.setMaxYear({ maxYear }); + } + + setMode ({ mode, username }) { + this.logEventConditionally(username, 'changed the mode to ' + mode); + this.room.mode = mode; + super.setMode({ mode }); + } + + setPacketNumbers ({ username, packetNumbers }) { + super.setPacketNumbers({ packetNumbers }); + this.logEventConditionally(username, packetNumbers.length > 0 ? `changed packet numbers to ${arrayToRange(packetNumbers)}` : 'cleared packet numbers'); + } + + setReadingSpeed ({ username, readingSpeed }) { + super.setReadingSpeed({ readingSpeed }); + this.logEventConditionally(username, `changed the reading speed to ${readingSpeed}`); + } + + setStrictness ({ strictness, username }) { + this.logEventConditionally(username, `changed the strictness to ${strictness}`); + super.setStrictness({ strictness }); + } + + setSetName ({ username, setName, setLength }) { + this.logEventConditionally(username, setName.length > 0 ? `changed set name to ${setName}` : 'cleared set name'); + this.room.setLength = setLength; + super.setSetName({ setName, setLength }); + } + + setUsername ({ oldUsername, newUsername, userId }) { + this.logEventConditionally(oldUsername, `changed their username to ${newUsername}`); + document.getElementById('username-' + userId).textContent = newUsername; + this.room.players[userId].username = newUsername; + this.sortPlayerListGroup(); + + if (userId === this.USER_ID) { + this.room.username = newUsername; + window.localStorage.setItem('multiplayer-username', this.room.username); + document.getElementById('username').value = this.room.username; + } + upsertPlayerItem(this.room.players[userId], this.USER_ID, this.room.ownerId, this.socket, this.room.public); + } + + showNextButton () { + document.getElementById('next').classList.remove('d-none'); + document.getElementById('next').disabled = false; + document.getElementById('skip').classList.add('d-none'); + document.getElementById('skip').disabled = true; + } + + showSkipButton () { + document.getElementById('skip').classList.remove('d-none'); + document.getElementById('skip').disabled = !document.getElementById('toggle-skip').checked; + document.getElementById('next').classList.add('d-none'); + document.getElementById('next').disabled = true; + } + + sortPlayerListGroup (descending = true) { + const listGroup = document.getElementById('player-list-group'); + const items = Array.from(listGroup.children); + const offset = 'list-group-'.length; + items.sort((a, b) => { + const aPoints = parseInt(document.getElementById('points-' + a.id.substring(offset)).innerHTML); + const bPoints = parseInt(document.getElementById('points-' + b.id.substring(offset)).innerHTML); + // if points are equal, sort alphabetically by username + if (aPoints === bPoints) { + const aUsername = document.getElementById('username-' + a.id.substring(offset)).innerHTML; + const bUsername = document.getElementById('username-' + b.id.substring(offset)).innerHTML; + return descending ? aUsername.localeCompare(bUsername) : bUsername.localeCompare(aUsername); + } + return descending ? bPoints - aPoints : aPoints - bPoints; + }).forEach(item => { + listGroup.appendChild(item); + }); + } + + toggleControlled ({ controlled, username }) { + this.logEventConditionally(username, `${controlled ? 'enabled' : 'disabled'} controlled mode`); + + document.getElementById('toggle-controlled').checked = controlled; + document.getElementById('controlled-room-warning').classList.toggle('d-none', !controlled); + document.getElementById('toggle-public').disabled = controlled; + + controlled = controlled && (this.USER_ID !== this.room.ownerId); + document.getElementById('toggle-lock').disabled = controlled; + document.getElementById('toggle-login-required').disabled = controlled; + document.getElementById('toggle-timer').disabled = controlled; + document.getElementById('toggle-powermark-only').disabled = controlled; + document.getElementById('toggle-rebuzz').disabled = controlled; + document.getElementById('toggle-skip').disabled = controlled; + document.getElementById('toggle-standard-only').disabled = controlled; + + document.getElementById('category-select-button').disabled = controlled; + document.getElementById('reading-speed').disabled = controlled; + document.getElementById('set-mode').disabled = controlled; + document.getElementById('set-strictness').disabled = controlled; + } + + toggleLock ({ lock, username }) { + this.logEventConditionally(username, `${lock ? 'locked' : 'unlocked'} the room`); + document.getElementById('toggle-lock').checked = lock; + } + + toggleLoginRequired ({ loginRequired, username }) { + this.logEventConditionally(username, `${loginRequired ? 'enabled' : 'disabled'} requiring players to be logged in`); + document.getElementById('toggle-login-required').checked = loginRequired; + } + + togglePowermarkOnly ({ powermarkOnly, username }) { + this.logEventConditionally(username, `${powermarkOnly ? 'enabled' : 'disabled'} powermark only`); + super.togglePowermarkOnly({ powermarkOnly }); + } + + toggleRebuzz ({ rebuzz, username }) { + this.logEventConditionally(username, `${rebuzz ? 'enabled' : 'disabled'} multiple buzzes (effective next question)`); + super.toggleRebuzz({ rebuzz }); + } + + toggleSkip ({ skip, username }) { + this.logEventConditionally(username, `${skip ? 'enabled' : 'disabled'} skipping`); + super.toggleSkip({ skip }); + } + + toggleStandardOnly ({ standardOnly, username }) { + this.logEventConditionally(username, `${standardOnly ? 'enabled' : 'disabled'} standard format only`); + super.toggleStandardOnly({ standardOnly }); + } + + toggleTimer ({ timer, username }) { + this.logEventConditionally(username, `${timer ? 'enabled' : 'disabled'} the timer`); + super.toggleTimer({ timer }); + } + + togglePublic ({ public: isPublic, username }) { + this.logEventConditionally(username, `made the room ${isPublic ? 'public' : 'private'}`); + document.getElementById('chat').disabled = isPublic; + document.getElementById('toggle-controlled').disabled = isPublic || (this.room.ownerId !== this.USER_ID); + document.getElementById('toggle-lock').disabled = isPublic; + document.getElementById('toggle-login-required').disabled = isPublic; + document.getElementById('toggle-public').checked = isPublic; + document.getElementById('toggle-timer').disabled = isPublic; + this.room.public = isPublic; + if (isPublic) { + document.getElementById('toggle-lock').checked = false; + document.getElementById('toggle-login-required').checked = false; + this.toggleTimer({ timer: true }); + } + Object.keys(this.room.players).forEach((player) => { + upsertPlayerItem(this.room.players[player], this.USER_ID, this.room.ownerId, this.socket, this.room.public); + }); + } + + vkInit ({ targetUsername, threshold }) { + this.logEventConditionally(`A votekick has been started against user ${targetUsername} and needs ${threshold} votes to succeed.`); + } + + vkHandle ({ targetUsername, targetId }) { + if (this.USER_ID === targetId) { + window.alert('You were vote kicked from this room by others.'); + setTimeout(() => { + window.location.replace('../'); + }, 100); + } else { + this.logEventConditionally(targetUsername + ' has been vote kicked from this room.'); + } + } +}; + +export default MultiplayerClientMixin; diff --git a/client/play/tossups/mp/MultiplayerTossupBonusClient.js b/client/play/tossups/mp/MultiplayerTossupBonusClient.js new file mode 100644 index 000000000..53872fad5 --- /dev/null +++ b/client/play/tossups/mp/MultiplayerTossupBonusClient.js @@ -0,0 +1,5 @@ +import TossupBonusClient from '../TossupBonusClient.js'; +import MultiplayerClientMixin from './MultiplayerClientMixin.js'; + +const MultiplayerTossupBonusClient = MultiplayerClientMixin(TossupBonusClient); +export default MultiplayerTossupBonusClient; diff --git a/client/play/tossups/mp/MultiplayerTossupClient.js b/client/play/tossups/mp/MultiplayerTossupClient.js index 679440a05..c8f2e8044 100644 --- a/client/play/tossups/mp/MultiplayerTossupClient.js +++ b/client/play/tossups/mp/MultiplayerTossupClient.js @@ -1,697 +1,5 @@ import TossupClient from '../TossupClient.js'; +import MultiplayerClientMixin from './MultiplayerClientMixin.js'; -import { MODE_ENUM } from '../../../../quizbowl/constants.js'; -import questionStats from '../../../scripts/auth/question-stats.js'; -import { arrayToRange } from '../../ranges.js'; -import upsertPlayerItem from '../../upsert-player-item.js'; -import { setYear } from '../../year-slider.js'; - -export default class MultiplayerTossupClient extends TossupClient { - constructor (room, userId, socket) { - super(room, userId, socket); - this.socket = socket; - } - - onmessage (event) { - const data = JSON.parse(event.data); - switch (data.type) { - case 'chat': return this.chat(data, false); - case 'chat-live-update': return this.chat(data, true); - case 'clear-stats': return this.clearStats(data); - case 'confirm-ban': return this.confirmBan(data); - case 'connection-acknowledged': return this.connectionAcknowledged(data); - case 'connection-acknowledged-query': return this.connectionAcknowledgedQuery(data); - case 'connection-acknowledged-tossup': return this.connectionAcknowledgedTossup(data); - case 'enforcing-removal': return this.ackRemovedFromRoom(data); - case 'error': return this.handleError(data); - case 'force-username': return this.forceUsername(data); - case 'give-answer-live-update': return this.logGiveAnswer(data); - case 'initiated-vk': return this.vkInit(data); - case 'join': return this.join(data); - case 'leave': return this.leave(data); - case 'lost-buzzer-race': return this.lostBuzzerRace(data); - case 'mute-player': return this.mutePlayer(data); - case 'no-points-votekick-attempt': return this.failedVotekickPoints(data); - case 'owner-change': return this.ownerChange(data); - case 'set-username': return this.setUsername(data); - case 'successful-vk': return this.vkHandle(data); - case 'toggle-controlled': return this.toggleControlled(data); - case 'toggle-lock': return this.toggleLock(data); - case 'toggle-login-required': return this.toggleLoginRequired(data); - case 'toggle-public': return this.togglePublic(data); - default: return super.onmessage(event.data); - } - } - - // if a banned/kicked user tries to join a this.room they were removed from this is the response - ackRemovedFromRoom ({ removalType }) { - if (removalType === 'kick') { - window.alert('You were kicked from this room by room players, and cannot rejoin it.'); - } else { - window.alert('You were banned from this room by the room owner, and cannot rejoin it.'); - } - setTimeout(() => { - window.location.replace('../'); - }, 100); - } - - buzz ({ userId, username }) { - this.logEventConditionally(username, 'buzzed'); - document.getElementById('skip').disabled = true; - - if (userId === this.USER_ID) { - document.getElementById('answer-input-group').classList.remove('d-none'); - document.getElementById('answer-input').focus(); - } - super.buzz({ userId }); - } - - chat ({ message, userId, username }, live = false) { - if (this.room.muteList.includes(userId)) { - return; - } - if (!live && message === '') { - document.getElementById('live-chat-' + userId).parentElement.remove(); - return; - } - - if (!live && message) { - document.getElementById('live-chat-' + userId).className = ''; - document.getElementById('live-chat-' + userId).id = ''; - return; - } - - if (document.getElementById('live-chat-' + userId)) { - document.getElementById('live-chat-' + userId).textContent = message; - return; - } - - const b = document.createElement('b'); - b.textContent = username; - - const span = document.createElement('span'); - span.classList.add('text-muted'); - span.id = 'live-chat-' + userId; - span.textContent = message; - - const li = document.createElement('li'); - li.appendChild(b); - li.appendChild(document.createTextNode(' ')); - li.appendChild(span); - document.getElementById('room-history').prepend(li); - } - - clearStats ({ userId }) { - for (const field of ['celerity', 'negs', 'points', 'powers', 'tens', 'tuh', 'zeroes']) { - this.room.players[userId][field] = 0; - } - upsertPlayerItem(this.room.players[userId], this.USER_ID, this.room.ownerId, this.socket, this.room.public); - this.sortPlayerListGroup(); - } - - confirmBan ({ targetId, targetUsername }) { - if (targetId === this.USER_ID) { - window.alert('You were banned from this room by the room owner.'); - setTimeout(() => { - window.location.replace('../'); - }, 100); - } else { - this.logEventConditionally(targetUsername + ' has been banned from this room.'); - } - } - - connectionAcknowledged ({ - buzzedIn, - canBuzz, - isPermanent, - ownerId: serverOwnerId, - mode, - packetLength, - players: messagePlayers, - questionProgress, - settings, - setLength: newSetLength, - userId - }) { - this.room.public = settings.public; - this.room.ownerId = serverOwnerId; - this.room.setLength = newSetLength; - this.USER_ID = userId; - window.localStorage.setItem('USER_ID', this.USER_ID); - - document.getElementById('buzz').disabled = !canBuzz; - - if (isPermanent) { - document.getElementById('category-select-button').disabled = true; - document.getElementById('permanent-room-warning').classList.remove('d-none'); - document.getElementById('reading-speed').disabled = true; - document.getElementById('set-strictness').disabled = true; - document.getElementById('set-mode').disabled = true; - document.getElementById('toggle-public').disabled = true; - } - - for (const userId of Object.keys(messagePlayers)) { - messagePlayers[userId].celerity = messagePlayers[userId].celerity.correct.average; - this.room.players[userId] = messagePlayers[userId]; - upsertPlayerItem(this.room.players[userId], this.USER_ID, this.room.ownerId, this.socket, this.room.public); - } - this.sortPlayerListGroup(); - - this.setMode({ mode }); - - document.getElementById('packet-length-info').textContent = mode === MODE_ENUM.SET_NAME ? packetLength : '-'; - - switch (questionProgress) { - case 0: - document.getElementById('next').textContent = 'Start'; - document.getElementById('next').classList.remove('btn-primary'); - document.getElementById('next').classList.add('btn-success'); - break; - case 1: - this.showSkipButton(); - document.getElementById('settings').classList.add('d-none'); - if (buzzedIn) { - document.getElementById('buzz').disabled = true; - document.getElementById('next').disabled = true; - document.getElementById('pause').disabled = true; - } else { - document.getElementById('buzz').disabled = false; - document.getElementById('pause').disabled = false; - } - break; - case 2: - this.showNextButton(); - document.getElementById('settings').classList.add('d-none'); - break; - } - - this.toggleLock({ lock: settings.lock }); - this.toggleLoginRequired({ loginRequired: settings.loginRequired }); - this.toggleRebuzz({ rebuzz: settings.rebuzz }); - this.toggleSkip({ skip: settings.skip }); - this.toggleTimer({ timer: settings.timer }); - this.setReadingSpeed({ readingSpeed: settings.readingSpeed }); - this.setStrictness({ strictness: settings.strictness }); - - if (settings.controlled) { - this.toggleControlled({ controlled: settings.controlled }); - } - if (settings.public) { - this.togglePublic({ public: settings.public }); - } - } - - async connectionAcknowledgedQuery ({ - difficulties = [], - minYear, - maxYear, - packetNumbers = [], - powermarkOnly, - setName = '', - standardOnly, - alternateSubcategories, - categories, - subcategories, - percentView, - categoryPercents - }) { - this.setDifficulties({ difficulties }); - - // need to set min year first to avoid conflicts between saved max year and default min year - setYear(minYear, 'min-year'); - setYear(maxYear, 'max-year'); - - document.getElementById('packet-number').value = arrayToRange(packetNumbers); - document.getElementById('set-name').value = setName; - document.getElementById('toggle-powermark-only').checked = powermarkOnly; - - if (setName !== '' && this.room.setLength === 0) { - document.getElementById('set-name').classList.add('is-invalid'); - } - - document.getElementById('toggle-standard-only').checked = standardOnly; - - this.setCategories({ categories, subcategories, alternateSubcategories, percentView, categoryPercents }); - } - - connectionAcknowledgedTossup ({ tossup: currentTossup }) { - this.room.tossup = currentTossup; - document.getElementById('set-name-info').textContent = this.room.tossup?.set?.name ?? ''; - document.getElementById('packet-number-info').textContent = this.room.tossup?.packet?.number ?? '-'; - document.getElementById('question-number-info').textContent = this.room.tossup?.number ?? '-'; - } - - failedVotekickPoints ({ userId }) { - if (userId === this.USER_ID) { - window.alert('You can only votekick once you have answered a question correctly!'); - } - } - - forceUsername ({ message, username }) { - window.alert(message); - window.localStorage.setItem('multiplayer-username', username); - document.querySelector('#username').value = username; - } - - async giveAnswer ({ celerity, directive, directedPrompt, givenAnswer, perQuestionCelerity, score, tossup, userId, username }) { - this.logGiveAnswer({ directive, givenAnswer, username }); - - if (directive === 'prompt' && directedPrompt) { - this.logEventConditionally(username, `was prompted with "${directedPrompt}"`); - } else if (directive === 'prompt') { - this.logEventConditionally(username, 'was prompted'); - } else { - this.logEventConditionally(username, `${score > 0 ? '' : 'in'}correctly answered for ${score} points`); - } - - super.giveAnswer({ directive, directedPrompt, score, userId }); - - if (directive === 'prompt') { return; } - - document.getElementById('pause').disabled = false; - - if (directive === 'accept') { - document.getElementById('buzz').disabled = true; - Array.from(document.getElementsByClassName('tuh')).forEach(element => { - element.textContent = parseInt(element.innerHTML) + 1; - }); - } - - if (directive === 'reject') { - document.getElementById('buzz').disabled = !document.getElementById('toggle-rebuzz').checked && userId === this.USER_ID; - } - - if (score > 10) { - this.room.players[userId].powers++; - } else if (score === 10) { - this.room.players[userId].tens++; - } else if (score < 0) { - this.room.players[userId].negs++; - } - - this.room.players[userId].points += score; - this.room.players[userId].tuh++; - this.room.players[userId].celerity = celerity; - - upsertPlayerItem(this.room.players[userId], this.USER_ID, this.room.ownerId, this.socket, this.room.public); - this.sortPlayerListGroup(); - - if (userId === this.USER_ID) { - questionStats.recordTossup({ - _id: tossup._id, - celerity: perQuestionCelerity, - isCorrect: score > 0, - multiplayer: true, - pointValue: score - }); - } - } - - handleError ({ message }) { - this.socket.close(3000); - window.alert(message); - window.location.href = '/multiplayer'; - } - - join ({ isNew, user, userId, username }) { - this.logEventConditionally(username, 'joined the game'); - if (userId === this.USER_ID) { return; } - this.room.players[userId] = user; - - if (isNew) { - user.celerity = user.celerity.correct.average; - upsertPlayerItem(user, this.USER_ID, this.room.ownerId, this.socket, this.room.public); - this.sortPlayerListGroup(); - } else { - document.getElementById(`list-group-${userId}`).classList.remove('offline'); - document.getElementById('points-' + userId).classList.add('bg-success'); - document.getElementById('points-' + userId).classList.remove('bg-secondary'); - document.getElementById('username-' + userId).textContent = username; - } - } - - leave ({ userId, username }) { - this.logEventConditionally(username, 'left the game'); - this.room.players[userId].online = false; - document.getElementById(`list-group-${userId}`).classList.add('offline'); - document.getElementById(`points-${userId}`).classList.remove('bg-success'); - document.getElementById(`points-${userId}`).classList.add('bg-secondary'); - } - - /** - * Log the event, but only if `username !== undefined`. - * If username is undefined, do nothing, regardless of the value of message. - * @param {string | undefined} username - * @param {string | undefined} message - */ - logEventConditionally (username, message) { - if (username === undefined) { return; } - - const span1 = document.createElement('span'); - span1.textContent = username; - - const span2 = document.createElement('span'); - span2.textContent = message; - - const i = document.createElement('i'); - i.appendChild(span1); - i.appendChild(document.createTextNode(' ')); - i.appendChild(span2); - - const li = document.createElement('li'); - li.appendChild(i); - - document.getElementById('room-history').prepend(li); - } - - logGiveAnswer ({ directive = null, givenAnswer, username }) { - const badge = document.createElement('span'); - badge.textContent = 'Buzz'; - switch (directive) { - case 'accept': - badge.className = 'badge text-dark bg-success'; - break; - case 'reject': - badge.className = 'badge text-light bg-danger'; - break; - case 'prompt': - badge.className = 'badge text-dark bg-warning'; - break; - default: - badge.className = 'badge text-light bg-primary'; - break; - } - - const b = document.createElement('b'); - b.textContent = username; - - const span = document.createElement('span'); - span.textContent = givenAnswer; - - let li; - if (document.getElementById('live-buzz')) { - li = document.getElementById('live-buzz'); - li.textContent = ''; - } else { - li = document.createElement('li'); - li.id = 'live-buzz'; - document.getElementById('room-history').prepend(li); - } - - li.appendChild(badge); - li.appendChild(document.createTextNode(' ')); - li.appendChild(b); - li.appendChild(document.createTextNode(' ')); - li.appendChild(span); - - if (directive === 'accept' || directive === 'reject') { - const secondBadge = document.createElement('span'); - secondBadge.className = badge.className; - - if (directive === 'accept') { - secondBadge.textContent = 'Correct'; - } else if (directive === 'reject') { - secondBadge.textContent = 'Incorrect'; - } - - li.appendChild(document.createTextNode(' ')); - li.appendChild(secondBadge); - } - - if (directive) { li.id = ''; } - } - - lostBuzzerRace ({ username, userId }) { - this.logEventConditionally(username, 'lost the buzzer race'); - if (userId === this.USER_ID) { document.getElementById('answer-input-group').classList.add('d-none'); } - } - - mutePlayer ({ targetId, targetUsername, muteStatus }) { - if (muteStatus === 'Mute') { - if (!this.room.muteList.includes(targetId)) { - this.room.muteList.push(targetId); - this.logEventConditionally(targetUsername, 'was muted'); - } - } else { - if (this.room.muteList.includes(targetId)) { - this.room.muteList = this.room.muteList.filter(Id => Id !== targetId); - this.logEventConditionally(targetUsername, 'was unmuted'); - } - } - } - - next ({ packetLength, oldTossup, tossup: nextTossup, type, username }) { - const typeStrings = { - end: 'ended the game', - next: 'went to the next question', - skip: 'skipped the question', - start: 'started the game' - }; - this.logEventConditionally(username, typeStrings[type]); - - super.next({ nextTossup, oldTossup, packetLength, type }); - - if (type === 'start') { - document.getElementById('next').classList.add('btn-primary'); - document.getElementById('next').classList.remove('btn-success'); - document.getElementById('next').textContent = 'Next'; - } - - if (type === 'end') { - document.getElementById('next').classList.remove('btn-primary'); - document.getElementById('next').classList.add('btn-success'); - document.getElementById('next').textContent = 'Start'; - } else { - this.room.tossup = nextTossup; - } - - this.showSkipButton(); - } - - ownerChange ({ newOwner }) { - if (this.room.players[newOwner]) { - this.room.ownerId = newOwner; - this.logEventConditionally(this.room.players[newOwner].username, 'became the room owner'); - } else this.logEventConditionally(newOwner, 'became the room owner'); - - Object.keys(this.room.players).forEach((player) => { - upsertPlayerItem(this.room.players[player], this.USER_ID, this.room.ownerId, this.socket, this.room.public); - }); - - document.getElementById('toggle-controlled').disabled = this.room.public || (this.room.ownerId !== this.USER_ID); - } - - pause ({ paused, username }) { - this.logEventConditionally(username, `${paused ? '' : 'un'}paused the game`); - super.pause({ paused }); - } - - revealAnswer ({ answer, question }) { - super.revealAnswer({ answer, question }); - this.showNextButton(); - } - - setCategories ({ alternateSubcategories, categories, subcategories, percentView, categoryPercents, username }) { - this.logEventConditionally(username, 'updated the categories'); - this.room.categoryManager.import({ categories, subcategories, alternateSubcategories, percentView, categoryPercents }); - if (!document.getElementById('category-modal')) { return; } - super.setCategories(); - } - - setDifficulties ({ difficulties, username = undefined }) { - this.logEventConditionally(username, difficulties.length > 0 ? `set the difficulties to ${difficulties}` : 'cleared the difficulties'); - - if (!document.getElementById('difficulties')) { - this.room.difficulties = difficulties; - return; - } - - Array.from(document.getElementById('difficulties').children).forEach(li => { - const input = li.querySelector('input'); - if (difficulties.includes(parseInt(input.value))) { - input.checked = true; - li.classList.add('active'); - } else { - input.checked = false; - li.classList.remove('active'); - } - }); - } - - setMinYear ({ minYear, username }) { - const maxYear = parseInt(document.getElementById('max-year-label').textContent); - this.logEventConditionally(username, `changed the year range to ${minYear}-${maxYear}`); - super.setMinYear({ minYear }); - } - - setMaxYear ({ maxYear, username }) { - const minYear = parseInt(document.getElementById('min-year-label').textContent); - this.logEventConditionally(username, `changed the year range to ${minYear}-${maxYear}`); - super.setMaxYear({ maxYear }); - } - - setMode ({ mode, username }) { - this.logEventConditionally(username, 'changed the mode to ' + mode); - this.room.mode = mode; - super.setMode({ mode }); - } - - setPacketNumbers ({ username, packetNumbers }) { - super.setPacketNumbers({ packetNumbers }); - this.logEventConditionally(username, packetNumbers.length > 0 ? `changed packet numbers to ${arrayToRange(packetNumbers)}` : 'cleared packet numbers'); - } - - setReadingSpeed ({ username, readingSpeed }) { - super.setReadingSpeed({ readingSpeed }); - this.logEventConditionally(username, `changed the reading speed to ${readingSpeed}`); - } - - setStrictness ({ strictness, username }) { - this.logEventConditionally(username, `changed the strictness to ${strictness}`); - super.setStrictness({ strictness }); - } - - setSetName ({ username, setName, setLength }) { - this.logEventConditionally(username, setName.length > 0 ? `changed set name to ${setName}` : 'cleared set name'); - this.room.setLength = setLength; - super.setSetName({ setName, setLength }); - } - - setUsername ({ oldUsername, newUsername, userId }) { - this.logEventConditionally(oldUsername, `changed their username to ${newUsername}`); - document.getElementById('username-' + userId).textContent = newUsername; - this.room.players[userId].username = newUsername; - this.sortPlayerListGroup(); - - if (userId === this.USER_ID) { - this.room.username = newUsername; - window.localStorage.setItem('multiplayer-username', this.room.username); - document.getElementById('username').value = this.room.username; - } - upsertPlayerItem(this.room.players[userId], this.USER_ID, this.room.ownerId, this.socket, this.room.public); - } - - showNextButton () { - document.getElementById('next').classList.remove('d-none'); - document.getElementById('next').disabled = false; - document.getElementById('skip').classList.add('d-none'); - document.getElementById('skip').disabled = true; - } - - showSkipButton () { - document.getElementById('skip').classList.remove('d-none'); - document.getElementById('skip').disabled = !document.getElementById('toggle-skip').checked; - document.getElementById('next').classList.add('d-none'); - document.getElementById('next').disabled = true; - } - - sortPlayerListGroup (descending = true) { - const listGroup = document.getElementById('player-list-group'); - const items = Array.from(listGroup.children); - const offset = 'list-group-'.length; - items.sort((a, b) => { - const aPoints = parseInt(document.getElementById('points-' + a.id.substring(offset)).innerHTML); - const bPoints = parseInt(document.getElementById('points-' + b.id.substring(offset)).innerHTML); - // if points are equal, sort alphabetically by username - if (aPoints === bPoints) { - const aUsername = document.getElementById('username-' + a.id.substring(offset)).innerHTML; - const bUsername = document.getElementById('username-' + b.id.substring(offset)).innerHTML; - return descending ? aUsername.localeCompare(bUsername) : bUsername.localeCompare(aUsername); - } - return descending ? bPoints - aPoints : aPoints - bPoints; - }).forEach(item => { - listGroup.appendChild(item); - }); - } - - toggleControlled ({ controlled, username }) { - this.logEventConditionally(username, `${controlled ? 'enabled' : 'disabled'} controlled mode`); - - document.getElementById('toggle-controlled').checked = controlled; - document.getElementById('controlled-room-warning').classList.toggle('d-none', !controlled); - document.getElementById('toggle-public').disabled = controlled; - - controlled = controlled && (this.USER_ID !== this.room.ownerId); - document.getElementById('toggle-lock').disabled = controlled; - document.getElementById('toggle-login-required').disabled = controlled; - document.getElementById('toggle-timer').disabled = controlled; - document.getElementById('toggle-powermark-only').disabled = controlled; - document.getElementById('toggle-rebuzz').disabled = controlled; - document.getElementById('toggle-skip').disabled = controlled; - document.getElementById('toggle-standard-only').disabled = controlled; - - document.getElementById('category-select-button').disabled = controlled; - document.getElementById('reading-speed').disabled = controlled; - document.getElementById('set-mode').disabled = controlled; - document.getElementById('set-strictness').disabled = controlled; - } - - toggleLock ({ lock, username }) { - this.logEventConditionally(username, `${lock ? 'locked' : 'unlocked'} the room`); - document.getElementById('toggle-lock').checked = lock; - } - - toggleLoginRequired ({ loginRequired, username }) { - this.logEventConditionally(username, `${loginRequired ? 'enabled' : 'disabled'} requiring players to be logged in`); - document.getElementById('toggle-login-required').checked = loginRequired; - } - - togglePowermarkOnly ({ powermarkOnly, username }) { - this.logEventConditionally(username, `${powermarkOnly ? 'enabled' : 'disabled'} powermark only`); - super.togglePowermarkOnly({ powermarkOnly }); - } - - toggleRebuzz ({ rebuzz, username }) { - this.logEventConditionally(username, `${rebuzz ? 'enabled' : 'disabled'} multiple buzzes (effective next question)`); - super.toggleRebuzz({ rebuzz }); - } - - toggleSkip ({ skip, username }) { - this.logEventConditionally(username, `${skip ? 'enabled' : 'disabled'} skipping`); - super.toggleSkip({ skip }); - } - - toggleStandardOnly ({ standardOnly, username }) { - this.logEventConditionally(username, `${standardOnly ? 'enabled' : 'disabled'} standard format only`); - super.toggleStandardOnly({ standardOnly }); - } - - toggleTimer ({ timer, username }) { - this.logEventConditionally(username, `${timer ? 'enabled' : 'disabled'} the timer`); - super.toggleTimer({ timer }); - } - - togglePublic ({ public: isPublic, username }) { - this.logEventConditionally(username, `made the room ${isPublic ? 'public' : 'private'}`); - document.getElementById('chat').disabled = isPublic; - document.getElementById('toggle-controlled').disabled = isPublic || (this.room.ownerId !== this.USER_ID); - document.getElementById('toggle-lock').disabled = isPublic; - document.getElementById('toggle-login-required').disabled = isPublic; - document.getElementById('toggle-public').checked = isPublic; - document.getElementById('toggle-timer').disabled = isPublic; - this.room.public = isPublic; - if (isPublic) { - document.getElementById('toggle-lock').checked = false; - document.getElementById('toggle-login-required').checked = false; - this.toggleTimer({ timer: true }); - } - Object.keys(this.room.players).forEach((player) => { - upsertPlayerItem(this.room.players[player], this.USER_ID, this.room.ownerId, this.socket, this.room.public); - }); - } - - vkInit ({ targetUsername, threshold }) { - this.logEventConditionally(`A votekick has been started against user ${targetUsername} and needs ${threshold} votes to succeed.`); - } - - vkHandle ({ targetUsername, targetId }) { - if (this.USER_ID === targetId) { - window.alert('You were vote kicked from this room by others.'); - setTimeout(() => { - window.location.replace('../'); - }, 100); - } else { - this.logEventConditionally(targetUsername + ' has been vote kicked from this room.'); - } - } -} +const MultiplayerTossupClient = MultiplayerClientMixin(TossupClient); +export default MultiplayerTossupClient; diff --git a/client/play/tossups/mp/room.html b/client/play/tossups/mp/room.html index 91dc9e976..9df204a28 100644 --- a/client/play/tossups/mp/room.html +++ b/client/play/tossups/mp/room.html @@ -260,7 +260,9 @@

title="Shortcut: e key" type="button">
- + diff --git a/client/play/tossups/mp/room.jsx b/client/play/tossups/mp/room.jsx index c8d8664a4..6d9c65849 100644 --- a/client/play/tossups/mp/room.jsx +++ b/client/play/tossups/mp/room.jsx @@ -1,4 +1,4 @@ -import MultiplayerTossupClient from './MultiplayerTossupClient.js'; +import MultiplayerTossupBonusClient from './MultiplayerTossupBonusClient.js'; import CategoryManager from '../../../../quizbowl/category-manager.js'; import { getDropdownValues } from '../../../scripts/utilities/dropdown-checklist.js'; @@ -54,7 +54,7 @@ socket.onclose = function (event) { clearInterval(PING_INTERVAL_ID); }; -const client = new MultiplayerTossupClient(room, USER_ID, socket); +const client = new MultiplayerTossupBonusClient(room, USER_ID, socket); socket.onmessage = (message) => client.onmessage(message); document.getElementById('answer-input').addEventListener('input', function () { @@ -139,6 +139,11 @@ document.getElementById('toggle-public').addEventListener('click', function () { socket.send(JSON.stringify({ type: 'toggle-public', public: this.checked })); }); +document.getElementById('reveal').addEventListener('click', function () { + this.blur(); + socket.send(JSON.stringify({ type: 'start-answer' })); +}); + document.getElementById('username').addEventListener('change', function () { socket.send(JSON.stringify({ type: 'set-username', userId: USER_ID, username: this.value })); room.username = this.value; @@ -158,7 +163,12 @@ document.addEventListener('keydown', (event) => { switch (event.key?.toLowerCase()) { case ' ': - document.getElementById('buzz').click(); + // During bonus rounds, spacebar should reveal; during tossups, it should buzz + if (!document.getElementById('reveal').disabled) { + document.getElementById('reveal').click(); + } else { + document.getElementById('buzz').click(); + } // Prevent spacebar from scrolling the page if (event.target === document.body) { event.preventDefault(); } break; diff --git a/quizbowl/QuestionRoom.js b/quizbowl/QuestionRoom.js index d51efb756..41b3fdfa8 100644 --- a/quizbowl/QuestionRoom.js +++ b/quizbowl/QuestionRoom.js @@ -18,6 +18,7 @@ export default class QuestionRoom extends Room { this.packetLength = undefined; this.queryingQuestion = false; this.randomQuestionCache = []; + this.useRandomQuestionCache = true; this.setCache = []; this.setLength = 24; // length of 2023 PACE NSC @@ -137,7 +138,9 @@ export default class QuestionRoom extends Room { const randomCategory = this.categoryManager.getRandomCategory(); this.randomQuestionCache = await this.getRandomQuestions({ ...this.query, number: 1, categories: [randomCategory], subcategories: [], alternateSubcategories: [] }); } else if (this.randomQuestionCache.length === 0) { - this.randomQuestionCache = await this.getRandomQuestions({ ...this.query, number: 20 }); + var cache_size = 20; + if (this.useRandomQuestionCache === false) { cache_size = 1; } + this.randomQuestionCache = await this.getRandomQuestions({ ...this.query, number: cache_size }); } if (this.randomQuestionCache?.length === 0) { diff --git a/quizbowl/TossupBonusRoom.js b/quizbowl/TossupBonusRoom.js new file mode 100644 index 000000000..2c86bfde3 --- /dev/null +++ b/quizbowl/TossupBonusRoom.js @@ -0,0 +1,273 @@ +import { ANSWER_TIME_LIMIT, BONUS_PROGRESS_ENUM } from './constants.js'; +import TossupRoom from './TossupRoom.js'; + +const ROUND = Object.freeze({ + TOSSUP: 0, + BONUS: 1 +}); + +export default class TossupBonusRoom extends TossupRoom { + constructor (name, categories = [], subcategories = [], alternateSubcategories = []) { + super(name, categories, subcategories, alternateSubcategories); + this.currentRound = ROUND.TOSSUP; + this.useRandomQuestionCache = false; + } + + switchToTossupRound () { + this.currentRound = ROUND.TOSSUP; + this.randomQuestionCache = []; + this.bonusEligibleUserId = null; + this.getNextLocalQuestion = super.getNextLocalQuestion; + this.getRandomQuestions = this.getRandomTossups; + } + + switchToBonusRound() { + this.currentRound = ROUND.BONUS; + this.randomQuestionCache = []; + + this.getNextLocalQuestion = () => { + if (this.localQuestions.bonuses.length === 0) { return null; } + if (this.settings.randomizeOrder) { + const randomIndex = Math.floor(Math.random() * this.localQuestions.bonuses.length); + return this.localQuestions.bonuses.splice(randomIndex, 1)[0]; + } + return this.localQuestions.bonuses.shift(); + }; + this.getRandomQuestions = this.getRandomBonuses; + + this.bonus = {}; + this.bonusProgress = BONUS_PROGRESS_ENUM.NOT_STARTED; + /** + * 0-indexed variable that tracks current part of the bonus being read + */ + this.currentPartNumber = -1; + /** + * tracks how well the team is doing on the bonus + * @type {number[]} + */ + this.pointsPerPart = []; + + /** + * tracks the buzz timer (time limit to click "Reveal" before auto-buzzing) + */ + this.buzzTimer = { + interval: null, + timeRemaining: 0 + }; + + this.query = { + bonusLength: 3, + ...this.query + }; + } + + async message (userId, message) { + switch (message.type) { + case 'give-answer': return this.giveAnswer(userId, message); + case 'start-answer': return this.startAnswer(userId, message); + default: return super.message(userId, message); + } + } + + scoreTossup ({ givenAnswer }) { + const decision = super.scoreTossup({ givenAnswer }); + if (decision.directive === "accept") { + this.bonusEligibleUserId = this.buzzedIn; + this.switchToBonusRound(); + } + return decision; + } + + async nextRound (userId, { type }) { + if (this.currentRound === ROUND.TOSSUP) { + await this.nextTossup(userId, { type }); + } + else { + await this.nextBonus(userId, { type }); + } + } + + lastQuestionDict () { + if (this.currentRound === ROUND.TOSSUP) { + return { oldTossup: this.tossup }; + } + else { + return { oldBonus: this.bonus }; + } + } + + async nextBonus (userId, { type }) { + if (this.queryingQuestion) { return false; } + if (this.bonusProgress === BONUS_PROGRESS_ENUM.READING && !this.settings.skip) { return false; } + + clearInterval(this.timer.interval); + clearInterval(this.buzzTimer.interval); + this.emitMessage({ type: 'timer-update', timeRemaining: 0 }); + + const bonusStarted = this.bonusProgress !== BONUS_PROGRESS_ENUM.NOT_STARTED; + const lastPartRevealed = this.bonusProgress === BONUS_PROGRESS_ENUM.LAST_PART_REVEALED; + const pointsPerPart = this.pointsPerPart; + + if (type === 'next' && bonusStarted) { + // Points already added incrementally during bonus, just update stats with 0 points + this.players[userId].updateStats(0, 1); + const oldBonus = this.bonus; // Preserve oldBonus before switching rounds + this.switchToTossupRound(); + await this.nextTossup(userId, { type, oldBonus }); + } + else { + const lastQuestionDict = this.lastQuestionDict(); + + // If this is the first bonus (transitioning from tossup), preserve the oldTossup + const preservedTossup = (!bonusStarted && this.currentRound === ROUND.BONUS) + ? { oldTossup: this.tossup } + : {}; + + this.bonus = await this.advanceQuestion(); + this.queryingQuestion = false; + + if (!this.bonus) { + this.emitMessage({ ...lastQuestionDict, ...preservedTossup, type: 'end', lastPartRevealed, pointsPerPart, stats, userId }); + return false; + } + + if (!this.bonus.parts || !Array.isArray(this.bonus.parts) || this.bonus.parts.length === 0) { + console.error('Invalid bonus received - missing or empty parts array:', this.bonus); + this.emitMessage({ ...lastQuestionDict, ...preservedTossup, type: 'end', lastPartRevealed, pointsPerPart, userId }); + return false; + } + + this.emitMessage({ ...lastQuestionDict, ...preservedTossup, type, bonus: this.bonus, lastPartRevealed, packetLength: this.packetLength, pointsPerPart }); + + this.currentPartNumber = -1; + this.pointsPerPart = []; + this.bonusProgress = BONUS_PROGRESS_ENUM.READING; + this.revealLeadin(); + this.revealNextPart(); + } + } + + giveAnswer (userId, { givenAnswer }) { + if (this.currentRound === ROUND.TOSSUP) { + return super.giveAnswer(userId, { givenAnswer }); + } + + // Only the user who answered the tossup correctly can answer the bonus + if (userId !== this.bonusEligibleUserId) { + return false; + } + + if (typeof givenAnswer !== 'string') { return false; } + + this.liveAnswer = ''; + clearInterval(this.timer.interval); + clearInterval(this.buzzTimer.interval); + this.emitMessage({ type: 'timer-update', timeRemaining: ANSWER_TIME_LIMIT * 10 }); + + const { directive, directedPrompt } = this.checkAnswer(this.bonus.answers[this.currentPartNumber], givenAnswer); + const points = directive === 'accept' ? this.getPartValue() : 0; + this.emitMessage({ + type: 'give-answer', + currentPartNumber: this.currentPartNumber, + directive, + directedPrompt, + givenAnswer, + score: points, + userId, + username: this.players[userId].username, + bonus: this.bonus + }); + + if (directive === 'prompt') { + this.startServerTimer( + ANSWER_TIME_LIMIT * 10, + (time) => this.emitMessage({ type: 'timer-update', timeRemaining: time }), + () => this.giveAnswer(userId, { givenAnswer: this.liveAnswer }) + ); + } else { + const partPoints = directive === 'accept' ? this.getPartValue() : 0; + this.pointsPerPart.push(partPoints); + // Immediately update player score on server so reconnection state is correct + this.players[userId].points += partPoints; + this.revealNextAnswer(); + this.revealNextPart(); + } + } + + startAnswer (userId) { + // Only the user who answered the tossup correctly can answer the bonus + if (userId !== this.bonusEligibleUserId) { + return false; + } + + // Cancel buzz timer when manually revealing + clearInterval(this.buzzTimer.interval); + + this.liveAnswer = ''; // Clear any previous answer + this.emitMessage({ type: 'start-answer', userId: this.bonusEligibleUserId }); + this.startServerTimer( + ANSWER_TIME_LIMIT * 10, + (time) => this.emitMessage({ type: 'timer-update', timeRemaining: time, timerType: 'answer' }), + () => this.giveAnswer(userId, { givenAnswer: this.liveAnswer }) + ); + } + + startBuzzTimer (userId) { + clearInterval(this.buzzTimer.interval); + // Use 15 seconds for first part (includes leadin), 10 seconds for others + const timeLimit = this.currentPartNumber === 0 ? 15 : ANSWER_TIME_LIMIT; + this.buzzTimer.timeRemaining = timeLimit * 10; + + this.buzzTimer.interval = setInterval(() => { + if (this.buzzTimer.timeRemaining <= 0) { + clearInterval(this.buzzTimer.interval); + // Auto-trigger startAnswer when timer expires + this.startAnswer(userId); + return; + } + this.emitMessage({ + type: 'timer-update', + timeRemaining: this.buzzTimer.timeRemaining, + timerType: 'buzz' + }); + this.buzzTimer.timeRemaining--; + }, 100); + } + + getPartValue (partNumber = this.currentPartNumber) { + return this.bonus?.values?.[this.currentPartNumber] ?? 10; + } + + revealLeadin () { + this.emitMessage({ type: 'reveal-leadin', leadin: this.bonus.leadin }); + } + + revealNextAnswer () { + const lastPartRevealed = this.currentPartNumber === this.bonus.parts.length - 1; + if (lastPartRevealed) { + this.bonusProgress = BONUS_PROGRESS_ENUM.LAST_PART_REVEALED; + } + this.emitMessage({ + type: 'reveal-next-answer', + answer: this.bonus.answers[this.currentPartNumber], + currentPartNumber: this.currentPartNumber, + lastPartRevealed + }); + } + + revealNextPart () { + if (this.bonusProgress === BONUS_PROGRESS_ENUM.LAST_PART_REVEALED) { return; } + + this.currentPartNumber++; + this.emitMessage({ + type: 'reveal-next-part', + currentPartNumber: this.currentPartNumber, + part: this.bonus.parts[this.currentPartNumber], + value: this.getPartValue(), + bonusEligibleUserId: this.bonusEligibleUserId + }); + + // Start 10-second buzz timer - auto-reveals if not manually revealed + this.startBuzzTimer(this.bonusEligibleUserId); + } +} diff --git a/quizbowl/TossupRoom.js b/quizbowl/TossupRoom.js index beac294e5..eb44e3ffb 100644 --- a/quizbowl/TossupRoom.js +++ b/quizbowl/TossupRoom.js @@ -164,8 +164,6 @@ export default class TossupRoom extends QuestionRoom { if (this.queryingQuestion) { return false; } if (this.tossupProgress === TOSSUP_PROGRESS_ENUM.READING && !this.settings.skip) { return false; } - const username = this.players[userId].username; - clearInterval(this.timer.interval); this.emitMessage({ type: 'timer-update', timeRemaining: 0 }); @@ -176,17 +174,29 @@ export default class TossupRoom extends QuestionRoom { this.paused = false; if (this.tossupProgress !== TOSSUP_PROGRESS_ENUM.ANSWER_REVEALED) { this.revealQuestion(); } + await this.nextRound(userId, { type }); + } + + async nextRound (userId, { type }) { + await this.nextTossup(userId, { type }); + } + + lastQuestionDict () { return { oldTossup: this.tossup }; } + + async nextTossup (userId, { type, oldBonus }) { + const username = this.players[userId].username; + // Only include oldTossup if we're not coming from a bonus (oldBonus would already have the history) + const lastQuestionDict = oldBonus ? {} : this.lastQuestionDict(); - const oldTossup = this.tossup; this.tossup = await this.advanceQuestion(); this.queryingQuestion = false; if (!this.tossup) { - this.emitMessage({ type: 'end', oldTossup, userId, username }); + this.emitMessage({ ...lastQuestionDict, ...(oldBonus ? { oldBonus } : {}), type: 'end', userId, username }); return false; } this.questionSplit = this.tossup.question_sanitized.split(' ').filter(word => word !== ''); - this.emitMessage({ type, packetLength: this.packetLength, oldTossup, tossup: this.tossup, userId, username }); + this.emitMessage({ ...lastQuestionDict, ...(oldBonus ? { oldBonus } : {}), type, packetLength: this.packetLength, tossup: this.tossup, userId, username }); this.wordIndex = 0; this.tossupProgress = TOSSUP_PROGRESS_ENUM.READING; diff --git a/server/multiplayer/ServerMultiplayerRoomMixin.js b/server/multiplayer/ServerMultiplayerRoomMixin.js new file mode 100644 index 000000000..18fdb2419 --- /dev/null +++ b/server/multiplayer/ServerMultiplayerRoomMixin.js @@ -0,0 +1,467 @@ +import ServerPlayer from './ServerPlayer.js'; +import Votekick from './VoteKick.js'; +import { HEADER, ENDC, OKCYAN, OKBLUE } from '../bcolors.js'; +import isAppropriateString from '../moderation/is-appropriate-string.js'; +import { MODE_ENUM, TOSSUP_PROGRESS_ENUM } from '../../quizbowl/constants.js'; +import insertTokensIntoHTML from '../../quizbowl/insert-tokens-into-html.js'; +import TossupRoom from '../../quizbowl/TossupRoom.js'; +import RateLimit from '../RateLimit.js'; + +import getRandomTossups from '../../database/qbreader/get-random-tossups.js'; +import getRandomBonuses from '../../database/qbreader/get-random-bonuses.js'; +import getSet from '../../database/qbreader/get-set.js'; +import getSetList from '../../database/qbreader/get-set-list.js'; +import getNumPackets from '../../database/qbreader/get-num-packets.js'; + +import checkAnswer from 'qb-answer-checker'; + +const BAN_DURATION = 1000 * 60 * 30; // 30 minutes + +const ServerMultiplayerRoomMixin = (RoomClass) => class extends RoomClass { + constructor (name, ownerId, isPermanent = false, categories = [], subcategories = [], alternateSubcategories = []) { + super(name, categories, subcategories, alternateSubcategories); + this.ownerId = ownerId; + this.isPermanent = isPermanent; + this.checkAnswer = checkAnswer; + this.getNumPackets = getNumPackets; + + this.getRandomQuestions = getRandomTossups; + // in case we are in a room that supports switching between Tossup and Bonus rounds + this.getRandomTossups = getRandomTossups; + this.getRandomBonuses = getRandomBonuses; + + this.getSet = getSet; + this.bannedUserList = new Map(); + this.kickedUserList = new Map(); + this.votekickList = []; + this.lastVotekickTime = {}; + + this.rateLimiter = new RateLimit(50, 1000); + this.rateLimitExceeded = new Set(); + this.settings = { + ...this.settings, + lock: false, + loginRequired: false, + public: true, + controlled: false + }; + + getSetList().then(setList => { this.setList = setList; }); + setInterval(this.cleanupExpiredBansAndKicks.bind(this), 5 * 60 * 1000); // 5 minutes + } + + async message (userId, message) { + switch (message.type) { + case 'ban': return this.ban(userId, message); + case 'chat': return this.chat(userId, message); + case 'chat-live-update': return this.chatLiveUpdate(userId, message); + case 'give-answer-live-update': return this.giveAnswerLiveUpdate(userId, message); + case 'toggle-controlled': return this.toggleControlled(userId, message); + case 'toggle-lock': return this.toggleLock(userId, message); + case 'toggle-login-required': return this.toggleLoginRequired(userId, message); + case 'toggle-mute': return this.toggleMute(userId, message); + case 'toggle-public': return this.togglePublic(userId, message); + case 'votekick-init': return this.votekickInit(userId, message); + case 'votekick-vote': return this.votekickVote(userId, message); + default: super.message(userId, message); + } + } + + allowed (userId) { + // public rooms have this.settings.controlled === false + return (userId === this.ownerId) || !this.settings.controlled; + } + + ban (userId, { targetId, targetUsername }) { + console.log('Ban request received. Target ' + targetId); + if (this.ownerId !== userId) { return; } + + this.emitMessage({ type: 'confirm-ban', targetId, targetUsername }); + this.bannedUserList.set(targetId, Date.now()); + + setTimeout(() => this.close(targetId), 1000); + } + + connection (socket, userId, username, ip, userAgent = '') { + console.log( + `Connection in room ${HEADER}${this.name}${ENDC};`, + `ip: ${OKCYAN}${ip}${ENDC};`, + userAgent ? `userAgent: ${OKCYAN}${userAgent}${ENDC};` : '', + `userId: ${OKBLUE}${userId}${ENDC};`, + `username: ${OKBLUE}${username}${ENDC};` + ); + this.cleanupExpiredBansAndKicks(); + + if (this.sockets[userId]) { + this.sendToSocket(userId, { type: 'error', message: 'You joined on another tab' }); + setTimeout(() => this.close(userId), 5000); + } + + const isNew = !(userId in this.players); + if (isNew) { this.players[userId] = new ServerPlayer(userId); } + this.players[userId].online = true; + this.sockets[userId] = socket; + username = this.players[userId].safelySetUsername(username); + + if (this.bannedUserList.has(userId)) { + console.log(`Banned user ${userId} (${username}) tried to join a room`); + this.sendToSocket(userId, { type: 'enforcing-removal', removalType: 'ban' }); + return; + } + + if (this.kickedUserList.has(userId)) { + console.log(`Kicked user ${userId} (${username}) tried to join a room`); + this.sendToSocket(userId, { type: 'enforcing-removal', removalType: 'kick' }); + return; + } + + socket.on('message', message => { + if (this.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)); + + socket.send(JSON.stringify({ + type: 'connection-acknowledged', + userId, + + ownerId: this.ownerId, + players: this.players, + isPermanent: this.isPermanent, + + buzzedIn: this.buzzedIn, + canBuzz: this.settings.rebuzz || !this.buzzes.includes(userId), + mode: this.mode, + packetLength: this.packetLength, + questionProgress: this.tossupProgress, + setLength: this.setLength, + + settings: this.settings + })); + + socket.send(JSON.stringify({ type: 'connection-acknowledged-query', ...this.query, ...this.categoryManager.export() })); + socket.send(JSON.stringify({ type: 'connection-acknowledged-tossup', tossup: this.tossup })); + + if (this.tossupProgress === TOSSUP_PROGRESS_ENUM.READING) { + socket.send(JSON.stringify({ + type: 'update-question', + word: this.questionSplit.slice(0, this.wordIndex).join(' ') + })); + } + + if (this.tossupProgress === TOSSUP_PROGRESS_ENUM.ANSWER_REVEALED && this.tossup?.answer) { + socket.send(JSON.stringify({ + type: 'reveal-answer', + question: insertTokensIntoHTML(this.tossup.question, this.tossup.question_sanitized, { ' (#) ': this.buzzpointIndices }), + answer: this.tossup.answer + })); + } + + // Handle TossupBonusRoom reconnection - send bonus state if in bonus round + if (this.currentRound !== undefined && this.currentRound === 1 && this.bonus && this.bonus.parts) { // 1 = ROUND.BONUS + socket.send(JSON.stringify({ + type: 'connection-acknowledged-bonus', + bonus: this.bonus, + bonusProgress: this.bonusProgress, + currentPartNumber: this.currentPartNumber, + pointsPerPart: this.pointsPerPart, + bonusEligibleUserId: this.bonusEligibleUserId + })); + + // Reconstruct bonus UI by sending reveal messages for completed parts + if (this.bonus.leadin) { + socket.send(JSON.stringify({ type: 'reveal-leadin', leadin: this.bonus.leadin })); + } + + // Reveal each part that has been shown + for (let i = 0; i <= this.currentPartNumber && i < this.bonus.parts.length; i++) { + socket.send(JSON.stringify({ + type: 'reveal-next-part', + currentPartNumber: i, + part: this.bonus.parts[i], + value: this.bonus.values?.[i] ?? 10, + bonusEligibleUserId: this.bonusEligibleUserId + })); + + // If this part has been answered, reveal its answer + if (i < this.pointsPerPart.length) { + socket.send(JSON.stringify({ + type: 'reveal-next-answer', + answer: this.bonus.answers[i], + currentPartNumber: i, + lastPartRevealed: i === this.bonus.parts.length - 1 + })); + } + } + } + + this.emitMessage({ type: 'join', isNew, userId, username, user: this.players[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 }); + } + + 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 }); + } + + cleanupExpiredBansAndKicks () { + const now = Date.now(); + + this.bannedUserList.forEach((banTime, userId) => { + if (now - banTime > BAN_DURATION) { + this.bannedUserList.delete(userId); + } + }); + + this.kickedUserList.forEach((kickTime, userId) => { + if (now - kickTime > BAN_DURATION) { + this.kickedUserList.delete(userId); + } + }); + } + + close (userId) { + if (!this.players[userId]) return; + + if (this.buzzedIn === userId) { + this.giveAnswer(userId, { givenAnswer: this.liveAnswer }); + this.buzzedIn = null; + } + this.leave(userId); + } + + giveAnswerLiveUpdate (userId, { givenAnswer }) { + if (typeof givenAnswer !== 'string') { return false; } + // Allow live updates during bonuses (when buzzedIn is null) or from the user who buzzed + if (this.buzzedIn && userId !== this.buzzedIn) { return false; } + this.liveAnswer = givenAnswer; + const username = this.players[userId].username; + this.emitMessage({ type: 'give-answer-live-update', givenAnswer, username }); + } + + next (userId, { type }) { + if (type === 'skip' && this.wordIndex < 3) { return false; } // prevents spam-skipping trolls + super.next(userId, { type }); + } + + setCategories (userId, { categories, subcategories, alternateSubcategories, percentView, categoryPercents }) { + if (this.isPermanent || !this.allowed(userId)) { return; } + super.setCategories(userId, { categories, subcategories, alternateSubcategories, percentView, categoryPercents }); + } + + setMode (userId, { mode }) { + if (this.isPermanent || !this.allowed(userId)) { return; } + if (this.mode !== MODE_ENUM.SET_NAME && this.mode !== MODE_ENUM.RANDOM) { return; } + super.setMode(userId, { mode }); + this.adjustQuery(['setName'], [this.query.setName]); + } + + setPacketNumbers (userId, { packetNumbers }) { + if (this.isPermanent || !this.allowed(userId)) { return; } + super.setPacketNumbers(userId, { doNotFetch: false, packetNumbers }); + } + + setReadingSpeed (userId, { readingSpeed }) { + if (this.isPermanent || !this.allowed(userId)) { return false; } + super.setReadingSpeed(userId, { readingSpeed }); + } + + async setSetName (userId, { setName }) { + if (!this.allowed(userId)) { return; } + if (!this.setList) { return; } + if (!this.setList.includes(setName)) { return; } + super.setSetName(userId, { doNotFetch: false, setName }); + } + + setStrictness (userId, { strictness }) { + if (this.isPermanent || !this.allowed) { return; } + super.setStrictness(userId, { strictness }); + } + + setMinYear (userId, { minYear }) { + if (this.isPermanent || !this.allowed(userId)) { return; } + super.setMinYear(userId, { minYear }); + } + + setMaxYear (userId, { maxYear }) { + if (this.isPermanent || !this.allowed(userId)) { return; } + super.setMaxYear(userId, { maxYear }); + } + + setUsername (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.' + }); + return; + } + + const oldUsername = this.players[userId].username; + const newUsername = this.players[userId].safelySetUsername(username); + this.emitMessage({ type: 'set-username', userId, oldUsername, newUsername }); + } + + toggleControlled (userId, { controlled }) { + if (this.settings.public) { return; } + if (userId !== this.ownerId) { return; } + this.settings.controlled = !!controlled; + const username = this.players[userId].username; + this.emitMessage({ type: 'toggle-controlled', controlled, username }); + } + + toggleLock (userId, { lock }) { + if (this.settings.public || !this.allowed(userId)) { return; } + this.settings.lock = lock; + const username = this.players[userId].username; + this.emitMessage({ type: 'toggle-lock', lock, username }); + } + + toggleLoginRequired (userId, { loginRequired }) { + if (this.settings.public || !this.allowed(userId)) { return; } + this.settings.loginRequired = loginRequired; + const username = this.players[userId].username; + this.emitMessage({ type: 'toggle-login-required', loginRequired, username }); + } + + toggleMute (userId, { targetId, targetUsername, muteStatus }) { + if (userId !== this.ownerId) return; + this.sendToSocket(userId, { type: 'mute-player', targetId, targetUsername, muteStatus }); + } + + togglePowermarkOnly (userId, { powermarkOnly }) { + if (!this.allowed(userId)) { return; } + super.togglePowermarkOnly(userId, { powermarkOnly }); + } + + toggleSkip (userId, { skip }) { + if (!this.allowed(userId)) { return; } + super.toggleSkip(userId, { skip }); + } + + toggleStandardOnly (userId, { standardOnly }) { + if (!this.allowed(userId)) { return; } + super.toggleStandardOnly(userId, { doNotFetch: false, standardOnly }); + } + + togglePublic (userId, { public: isPublic }) { + if (this.isPermanent || this.settings.controlled) { return; } + this.settings.public = isPublic; + const username = this.players[userId].username; + if (isPublic) { + this.settings.lock = false; + this.settings.loginRequired = false; + this.settings.timer = true; + } + this.emitMessage({ type: 'toggle-public', public: isPublic, username }); + } + + toggleRebuzz (userId, { rebuzz }) { + if (!this.allowed(userId)) { return false; } + super.toggleRebuzz(userId, { rebuzz }); + } + + toggleTimer (userId, { timer }) { + if (this.settings.public || !this.allowed(userId)) { return; } + super.toggleTimer(userId, { timer }); + } + + votekickInit (userId, { targetId }) { + if (this.players[userId].tens === 0 && this.players[userId].powers === 0) { return; } + if (!this.players[targetId]) { return; } + const targetUsername = this.players[targetId].username; + + const currentTime = Date.now(); + if (this.lastVotekickTime[userId] && (currentTime - this.lastVotekickTime[userId] < 90000)) { + return; + } + + this.lastVotekickTime[userId] = currentTime; + + for (const votekick of this.votekickList) { + if (votekick.exists(targetId)) { return; } + } + let activePlayers = 0; + Object.keys(this.players).forEach(playerId => { + if (this.players[playerId].online) { + activePlayers += 1; + } + }); + + const threshold = Math.max(Math.floor(activePlayers * 3 / 4), 2); + const votekick = new Votekick(targetId, threshold, []); + votekick.vote(userId); + this.votekickList.push(votekick); + if (votekick.check()) { + this.emitMessage({ type: 'successful-vk', targetUsername, targetId }); + this.kickedUserList.set(targetId, Date.now()); + } else { + this.kickedUserList.set(targetId, Date.now()); + this.emitMessage({ type: 'initiated-vk', targetUsername, threshold }); + } + } + + votekickVote (userId, { targetId }) { + if (this.players[userId].tens === 0 && this.players[userId].powers === 0) { + this.emitMessage({ type: 'no-points-votekick-attempt', userId }); + return; + } + if (!this.players[targetId]) { return; } + const targetUsername = this.players[targetId].username; + + let exists = false; + let thisVotekick; + for (const votekick of this.votekickList) { + if (votekick.exists(targetId)) { + thisVotekick = votekick; + exists = true; + } + } + if (!exists) { return; } + + thisVotekick.vote(userId); + if (thisVotekick.check()) { + this.emitMessage({ type: 'successful-vk', targetUsername, targetId }); + this.kickedUserList.set(targetId, Date.now()); + + setTimeout(() => this.close(userId), 1000); + + if (targetId === this.ownerId) { + const onlinePlayers = Object.keys(this.players).filter(playerId => this.players[playerId].online && playerId !== targetId); + const newHost = onlinePlayers.reduce( + (maxPlayer, playerId) => (this.players[playerId].tuh || 0) > (this.players[maxPlayer].tuh || 0) ? playerId : maxPlayer, + onlinePlayers[0] + ); + // ^^ highest tuh player becomes new host + + this.ownerId = newHost; + + this.emitMessage({ type: 'owner-change', newOwner: newHost }); + } + } + } +}; + +export default ServerMultiplayerRoomMixin; diff --git a/server/multiplayer/ServerTossupBonusRoom.js b/server/multiplayer/ServerTossupBonusRoom.js new file mode 100644 index 000000000..b6e3c5dc6 --- /dev/null +++ b/server/multiplayer/ServerTossupBonusRoom.js @@ -0,0 +1,5 @@ +import ServerMultiplayerRoomMixin from './ServerMultiplayerRoomMixin.js' +import TossupBonusRoom from '../../quizbowl/TossupBonusRoom.js'; + +const ServerTossupBonusRoom = ServerMultiplayerRoomMixin(TossupBonusRoom); +export default ServerTossupBonusRoom; diff --git a/server/multiplayer/ServerTossupRoom.js b/server/multiplayer/ServerTossupRoom.js index 63d620545..620b4a3e4 100644 --- a/server/multiplayer/ServerTossupRoom.js +++ b/server/multiplayer/ServerTossupRoom.js @@ -1,420 +1,5 @@ -import ServerPlayer from './ServerPlayer.js'; -import Votekick from './VoteKick.js'; -import { HEADER, ENDC, OKCYAN, OKBLUE } from '../bcolors.js'; -import isAppropriateString from '../moderation/is-appropriate-string.js'; -import { MODE_ENUM, TOSSUP_PROGRESS_ENUM } from '../../quizbowl/constants.js'; -import insertTokensIntoHTML from '../../quizbowl/insert-tokens-into-html.js'; +import ServerMultiplayerRoomMixin from './ServerMultiplayerRoomMixin.js' import TossupRoom from '../../quizbowl/TossupRoom.js'; -import RateLimit from '../RateLimit.js'; -import getRandomTossups from '../../database/qbreader/get-random-tossups.js'; -import getSet from '../../database/qbreader/get-set.js'; -import getSetList from '../../database/qbreader/get-set-list.js'; -import getNumPackets from '../../database/qbreader/get-num-packets.js'; - -import checkAnswer from 'qb-answer-checker'; - -const BAN_DURATION = 1000 * 60 * 30; // 30 minutes - -export default class ServerTossupRoom extends TossupRoom { - constructor (name, ownerId, isPermanent = false, categories = [], subcategories = [], alternateSubcategories = []) { - super(name, categories, subcategories, alternateSubcategories); - this.ownerId = ownerId; - this.isPermanent = isPermanent; - this.checkAnswer = checkAnswer; - this.getNumPackets = getNumPackets; - this.getRandomQuestions = getRandomTossups; - this.getSet = getSet; - this.bannedUserList = new Map(); - this.kickedUserList = new Map(); - this.votekickList = []; - this.lastVotekickTime = {}; - - this.rateLimiter = new RateLimit(50, 1000); - this.rateLimitExceeded = new Set(); - this.settings = { - ...this.settings, - lock: false, - loginRequired: false, - public: true, - controlled: false - }; - - getSetList().then(setList => { this.setList = setList; }); - setInterval(this.cleanupExpiredBansAndKicks.bind(this), 5 * 60 * 1000); // 5 minutes - } - - async message (userId, message) { - switch (message.type) { - case 'ban': return this.ban(userId, message); - case 'chat': return this.chat(userId, message); - case 'chat-live-update': return this.chatLiveUpdate(userId, message); - case 'give-answer-live-update': return this.giveAnswerLiveUpdate(userId, message); - case 'toggle-controlled': return this.toggleControlled(userId, message); - case 'toggle-lock': return this.toggleLock(userId, message); - case 'toggle-login-required': return this.toggleLoginRequired(userId, message); - case 'toggle-mute': return this.toggleMute(userId, message); - case 'toggle-public': return this.togglePublic(userId, message); - case 'votekick-init': return this.votekickInit(userId, message); - case 'votekick-vote': return this.votekickVote(userId, message); - default: super.message(userId, message); - } - } - - allowed (userId) { - // public rooms have this.settings.controlled === false - return (userId === this.ownerId) || !this.settings.controlled; - } - - ban (userId, { targetId, targetUsername }) { - console.log('Ban request received. Target ' + targetId); - if (this.ownerId !== userId) { return; } - - this.emitMessage({ type: 'confirm-ban', targetId, targetUsername }); - this.bannedUserList.set(targetId, Date.now()); - - setTimeout(() => this.close(targetId), 1000); - } - - connection (socket, userId, username, ip, userAgent = '') { - console.log( - `Connection in room ${HEADER}${this.name}${ENDC};`, - `ip: ${OKCYAN}${ip}${ENDC};`, - userAgent ? `userAgent: ${OKCYAN}${userAgent}${ENDC};` : '', - `userId: ${OKBLUE}${userId}${ENDC};`, - `username: ${OKBLUE}${username}${ENDC};` - ); - this.cleanupExpiredBansAndKicks(); - - if (this.sockets[userId]) { - this.sendToSocket(userId, { type: 'error', message: 'You joined on another tab' }); - setTimeout(() => this.close(userId), 5000); - } - - const isNew = !(userId in this.players); - if (isNew) { this.players[userId] = new ServerPlayer(userId); } - this.players[userId].online = true; - this.sockets[userId] = socket; - username = this.players[userId].safelySetUsername(username); - - if (this.bannedUserList.has(userId)) { - console.log(`Banned user ${userId} (${username}) tried to join a room`); - this.sendToSocket(userId, { type: 'enforcing-removal', removalType: 'ban' }); - return; - } - - if (this.kickedUserList.has(userId)) { - console.log(`Kicked user ${userId} (${username}) tried to join a room`); - this.sendToSocket(userId, { type: 'enforcing-removal', removalType: 'kick' }); - return; - } - - socket.on('message', message => { - if (this.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)); - - socket.send(JSON.stringify({ - type: 'connection-acknowledged', - userId, - - ownerId: this.ownerId, - players: this.players, - isPermanent: this.isPermanent, - - buzzedIn: this.buzzedIn, - canBuzz: this.settings.rebuzz || !this.buzzes.includes(userId), - mode: this.mode, - packetLength: this.packetLength, - questionProgress: this.tossupProgress, - setLength: this.setLength, - - settings: this.settings - })); - - socket.send(JSON.stringify({ type: 'connection-acknowledged-query', ...this.query, ...this.categoryManager.export() })); - socket.send(JSON.stringify({ type: 'connection-acknowledged-tossup', tossup: this.tossup })); - - if (this.tossupProgress === TOSSUP_PROGRESS_ENUM.READING) { - socket.send(JSON.stringify({ - type: 'update-question', - word: this.questionSplit.slice(0, this.wordIndex).join(' ') - })); - } - - if (this.tossupProgress === TOSSUP_PROGRESS_ENUM.ANSWER_REVEALED && this.tossup?.answer) { - socket.send(JSON.stringify({ - type: 'reveal-answer', - question: insertTokensIntoHTML(this.tossup.question, this.tossup.question_sanitized, { ' (#) ': this.buzzpointIndices }), - answer: this.tossup.answer - })); - } - - this.emitMessage({ type: 'join', isNew, userId, username, user: this.players[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 }); - } - - 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 }); - } - - cleanupExpiredBansAndKicks () { - const now = Date.now(); - - this.bannedUserList.forEach((banTime, userId) => { - if (now - banTime > BAN_DURATION) { - this.bannedUserList.delete(userId); - } - }); - - this.kickedUserList.forEach((kickTime, userId) => { - if (now - kickTime > BAN_DURATION) { - this.kickedUserList.delete(userId); - } - }); - } - - close (userId) { - if (!this.players[userId]) return; - - if (this.buzzedIn === userId) { - this.giveAnswer(userId, { givenAnswer: this.liveAnswer }); - this.buzzedIn = null; - } - this.leave(userId); - } - - giveAnswerLiveUpdate (userId, { givenAnswer }) { - if (typeof givenAnswer !== 'string') { return false; } - if (userId !== this.buzzedIn) { return false; } - this.liveAnswer = givenAnswer; - const username = this.players[userId].username; - this.emitMessage({ type: 'give-answer-live-update', givenAnswer, username }); - } - - next (userId, { type }) { - if (type === 'skip' && this.wordIndex < 3) { return false; } // prevents spam-skipping trolls - super.next(userId, { type }); - } - - setCategories (userId, { categories, subcategories, alternateSubcategories, percentView, categoryPercents }) { - if (this.isPermanent || !this.allowed(userId)) { return; } - super.setCategories(userId, { categories, subcategories, alternateSubcategories, percentView, categoryPercents }); - } - - setMode (userId, { mode }) { - if (this.isPermanent || !this.allowed(userId)) { return; } - if (this.mode !== MODE_ENUM.SET_NAME && this.mode !== MODE_ENUM.RANDOM) { return; } - super.setMode(userId, { mode }); - this.adjustQuery(['setName'], [this.query.setName]); - } - - setPacketNumbers (userId, { packetNumbers }) { - if (this.isPermanent || !this.allowed(userId)) { return; } - super.setPacketNumbers(userId, { doNotFetch: false, packetNumbers }); - } - - setReadingSpeed (userId, { readingSpeed }) { - if (this.isPermanent || !this.allowed(userId)) { return false; } - super.setReadingSpeed(userId, { readingSpeed }); - } - - async setSetName (userId, { setName }) { - if (!this.allowed(userId)) { return; } - if (!this.setList) { return; } - if (!this.setList.includes(setName)) { return; } - super.setSetName(userId, { doNotFetch: false, setName }); - } - - setStrictness (userId, { strictness }) { - if (this.isPermanent || !this.allowed) { return; } - super.setStrictness(userId, { strictness }); - } - - setMinYear (userId, { minYear }) { - if (this.isPermanent || !this.allowed(userId)) { return; } - super.setMinYear(userId, { minYear }); - } - - setMaxYear (userId, { maxYear }) { - if (this.isPermanent || !this.allowed(userId)) { return; } - super.setMaxYear(userId, { maxYear }); - } - - setUsername (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.' - }); - return; - } - - const oldUsername = this.players[userId].username; - const newUsername = this.players[userId].safelySetUsername(username); - this.emitMessage({ type: 'set-username', userId, oldUsername, newUsername }); - } - - toggleControlled (userId, { controlled }) { - if (this.settings.public) { return; } - if (userId !== this.ownerId) { return; } - this.settings.controlled = !!controlled; - const username = this.players[userId].username; - this.emitMessage({ type: 'toggle-controlled', controlled, username }); - } - - toggleLock (userId, { lock }) { - if (this.settings.public || !this.allowed(userId)) { return; } - this.settings.lock = lock; - const username = this.players[userId].username; - this.emitMessage({ type: 'toggle-lock', lock, username }); - } - - toggleLoginRequired (userId, { loginRequired }) { - if (this.settings.public || !this.allowed(userId)) { return; } - this.settings.loginRequired = loginRequired; - const username = this.players[userId].username; - this.emitMessage({ type: 'toggle-login-required', loginRequired, username }); - } - - toggleMute (userId, { targetId, targetUsername, muteStatus }) { - if (userId !== this.ownerId) return; - this.sendToSocket(userId, { type: 'mute-player', targetId, targetUsername, muteStatus }); - } - - togglePowermarkOnly (userId, { powermarkOnly }) { - if (!this.allowed(userId)) { return; } - super.togglePowermarkOnly(userId, { powermarkOnly }); - } - - toggleSkip (userId, { skip }) { - if (!this.allowed(userId)) { return; } - super.toggleSkip(userId, { skip }); - } - - toggleStandardOnly (userId, { standardOnly }) { - if (!this.allowed(userId)) { return; } - super.toggleStandardOnly(userId, { doNotFetch: false, standardOnly }); - } - - togglePublic (userId, { public: isPublic }) { - if (this.isPermanent || this.settings.controlled) { return; } - this.settings.public = isPublic; - const username = this.players[userId].username; - if (isPublic) { - this.settings.lock = false; - this.settings.loginRequired = false; - this.settings.timer = true; - } - this.emitMessage({ type: 'toggle-public', public: isPublic, username }); - } - - toggleRebuzz (userId, { rebuzz }) { - if (!this.allowed(userId)) { return false; } - super.toggleRebuzz(userId, { rebuzz }); - } - - toggleTimer (userId, { timer }) { - if (this.settings.public || !this.allowed(userId)) { return; } - super.toggleTimer(userId, { timer }); - } - - votekickInit (userId, { targetId }) { - if (this.players[userId].tens === 0 && this.players[userId].powers === 0) { return; } - if (!this.players[targetId]) { return; } - const targetUsername = this.players[targetId].username; - - const currentTime = Date.now(); - if (this.lastVotekickTime[userId] && (currentTime - this.lastVotekickTime[userId] < 90000)) { - return; - } - - this.lastVotekickTime[userId] = currentTime; - - for (const votekick of this.votekickList) { - if (votekick.exists(targetId)) { return; } - } - let activePlayers = 0; - Object.keys(this.players).forEach(playerId => { - if (this.players[playerId].online) { - activePlayers += 1; - } - }); - - const threshold = Math.max(Math.floor(activePlayers * 3 / 4), 2); - const votekick = new Votekick(targetId, threshold, []); - votekick.vote(userId); - this.votekickList.push(votekick); - if (votekick.check()) { - this.emitMessage({ type: 'successful-vk', targetUsername, targetId }); - this.kickedUserList.set(targetId, Date.now()); - } else { - this.kickedUserList.set(targetId, Date.now()); - this.emitMessage({ type: 'initiated-vk', targetUsername, threshold }); - } - } - - votekickVote (userId, { targetId }) { - if (this.players[userId].tens === 0 && this.players[userId].powers === 0) { - this.emitMessage({ type: 'no-points-votekick-attempt', userId }); - return; - } - if (!this.players[targetId]) { return; } - const targetUsername = this.players[targetId].username; - - let exists = false; - let thisVotekick; - for (const votekick of this.votekickList) { - if (votekick.exists(targetId)) { - thisVotekick = votekick; - exists = true; - } - } - if (!exists) { return; } - - thisVotekick.vote(userId); - if (thisVotekick.check()) { - this.emitMessage({ type: 'successful-vk', targetUsername, targetId }); - this.kickedUserList.set(targetId, Date.now()); - - setTimeout(() => this.close(userId), 1000); - - if (targetId === this.ownerId) { - const onlinePlayers = Object.keys(this.players).filter(playerId => this.players[playerId].online && playerId !== targetId); - const newHost = onlinePlayers.reduce( - (maxPlayer, playerId) => (this.players[playerId].tuh || 0) > (this.players[maxPlayer].tuh || 0) ? playerId : maxPlayer, - onlinePlayers[0] - ); - // ^^ highest tuh player becomes new host - - this.ownerId = newHost; - - this.emitMessage({ type: 'owner-change', newOwner: newHost }); - } - } - } -} +const ServerTossupRoom = ServerMultiplayerRoomMixin(TossupRoom); +export default ServerTossupRoom; diff --git a/server/multiplayer/handle-wss-connection.js b/server/multiplayer/handle-wss-connection.js index ef4e149d7..b8c919c66 100644 --- a/server/multiplayer/handle-wss-connection.js +++ b/server/multiplayer/handle-wss-connection.js @@ -1,5 +1,5 @@ import { MAX_ONLINE_PLAYERS, PERMANENT_ROOMS, ROOM_NAME_MAX_LENGTH } from './constants.js'; -import ServerTossupRoom from './ServerTossupRoom.js'; +import ServerTossupBonusRoom from './ServerTossupBonusRoom.js'; import { checkToken } from '../authentication.js'; import getRandomName from '../../quizbowl/get-random-name.js'; import hasValidCharacters from '../moderation/has-valid-characters.js'; @@ -20,21 +20,21 @@ const DOMPurify = createDOMPurify(window); export const tossupRooms = {}; for (const room of PERMANENT_ROOMS) { const { name, categories, subcategories } = room; - tossupRooms[name] = new ServerTossupRoom(name, Symbol('unique permanent room owner'), true, categories, subcategories); + tossupRooms[name] = new ServerTossupBonusRoom(name, Symbol('unique permanent room owner'), true, categories, subcategories); } /** * Returns the room with the given room name. * If the room does not exist, it is created. * @param {String} roomName - * @returns {ServerTossupRoom} + * @returns {ServerTossupBonusRoom} */ function createAndReturnRoom (roomName, userId, isPrivate = false, isControlled = false) { roomName = DOMPurify.sanitize(roomName); roomName = roomName?.substring(0, ROOM_NAME_MAX_LENGTH) ?? ''; if (!Object.prototype.hasOwnProperty.call(tossupRooms, roomName)) { - const newRoom = new ServerTossupRoom(roomName, userId, false); + const newRoom = new ServerTossupBonusRoom(roomName, userId, false); // A room cannot be both public and controlled newRoom.settings.public = !isPrivate && !isControlled; newRoom.settings.controlled = isControlled; From f2d5a8633d54771cd9339133a0e2bad8bc0d21fa Mon Sep 17 00:00:00 2001 From: Theodore Chen Date: Fri, 2 Jan 2026 15:40:07 -0500 Subject: [PATCH 2/2] Fix lint stuff --- client/play/tossups/TossupBonusClient.js | 10 ++++------ client/play/tossups/TossupClient.js | 7 +++---- .../play/tossups/mp/MultiplayerClientMixin.js | 5 ++--- quizbowl/QuestionRoom.js | 6 +++--- quizbowl/TossupBonusRoom.js | 19 ++++++++----------- .../multiplayer/ServerMultiplayerRoomMixin.js | 2 +- server/multiplayer/ServerTossupBonusRoom.js | 2 +- server/multiplayer/ServerTossupRoom.js | 2 +- 8 files changed, 23 insertions(+), 30 deletions(-) diff --git a/client/play/tossups/TossupBonusClient.js b/client/play/tossups/TossupBonusClient.js index 8566ac8ec..f3368335f 100644 --- a/client/play/tossups/TossupBonusClient.js +++ b/client/play/tossups/TossupBonusClient.js @@ -2,9 +2,9 @@ import TossupClient from './TossupClient.js'; import addBonusGameCard from '../bonuses/add-bonus-game-card.js'; export default class TossupBonusClient extends TossupClient { - constructor (room, userId, socket) { - super(room, userId, socket); - } + // constructor (room, userId, socket) { + // super(room, userId, socket); + // } onmessage (message) { const data = JSON.parse(message); @@ -30,8 +30,7 @@ export default class TossupBonusClient extends TossupClient { document.getElementById('reveal').disabled = false; document.getElementById('buzz').disabled = true; } - } - else { + } else { if (data.type !== 'start' && data.oldBonus) { addBonusGameCard({ bonus: data.oldBonus, starred: data.starred }); } @@ -112,5 +111,4 @@ export default class TossupBonusClient extends TossupClient { document.getElementById('question').appendChild(row); } - } diff --git a/client/play/tossups/TossupClient.js b/client/play/tossups/TossupClient.js index 07793376d..a33b1f6af 100644 --- a/client/play/tossups/TossupClient.js +++ b/client/play/tossups/TossupClient.js @@ -42,10 +42,9 @@ export default class TossupClient extends QuestionClient { if (data.type !== 'start' && data.oldTossup) { addTossupGameCard({ starred: data.starred, tossup: data.oldTossup }); } - if (data.nextQuestion) { // just passing through, e.g. from a child class that handles bonus questions - super.next(data); - } - else { + if (data.nextQuestion) { // just passing through, e.g. from a child class that handles bonus questions + super.next(data); + } else { this.nextTossup(data); } } diff --git a/client/play/tossups/mp/MultiplayerClientMixin.js b/client/play/tossups/mp/MultiplayerClientMixin.js index 1e2927bff..2beb43228 100644 --- a/client/play/tossups/mp/MultiplayerClientMixin.js +++ b/client/play/tossups/mp/MultiplayerClientMixin.js @@ -13,7 +13,7 @@ const MultiplayerClientMixin = (ClientClass) => class extends ClientClass { onmessage (event) { const data = JSON.parse(event.data); - console.log("MultiplayerClientMixin onmessage", data.type, data); + console.log('MultiplayerClientMixin onmessage', data.type, data); switch (data.type) { case 'chat': return this.chat(data, false); case 'chat-live-update': return this.chat(data, true); @@ -311,8 +311,7 @@ const MultiplayerClientMixin = (ClientClass) => class extends ClientClass { if (directive === 'reject') { if (data.tossup) { document.getElementById('buzz').disabled = !document.getElementById('toggle-rebuzz').checked && userId === this.USER_ID; - } - else { + } else { document.getElementById('reveal').disabled = !document.getElementById('toggle-rebuzz').checked && userId === this.USER_ID; } } diff --git a/quizbowl/QuestionRoom.js b/quizbowl/QuestionRoom.js index 41b3fdfa8..4865d48f7 100644 --- a/quizbowl/QuestionRoom.js +++ b/quizbowl/QuestionRoom.js @@ -138,9 +138,9 @@ export default class QuestionRoom extends Room { const randomCategory = this.categoryManager.getRandomCategory(); this.randomQuestionCache = await this.getRandomQuestions({ ...this.query, number: 1, categories: [randomCategory], subcategories: [], alternateSubcategories: [] }); } else if (this.randomQuestionCache.length === 0) { - var cache_size = 20; - if (this.useRandomQuestionCache === false) { cache_size = 1; } - this.randomQuestionCache = await this.getRandomQuestions({ ...this.query, number: cache_size }); + let cacheSize = 20; + if (this.useRandomQuestionCache === false) { cacheSize = 1; } + this.randomQuestionCache = await this.getRandomQuestions({ ...this.query, number: cacheSize }); } if (this.randomQuestionCache?.length === 0) { diff --git a/quizbowl/TossupBonusRoom.js b/quizbowl/TossupBonusRoom.js index 2c86bfde3..3050ca9a7 100644 --- a/quizbowl/TossupBonusRoom.js +++ b/quizbowl/TossupBonusRoom.js @@ -21,7 +21,7 @@ export default class TossupBonusRoom extends TossupRoom { this.getRandomQuestions = this.getRandomTossups; } - switchToBonusRound() { + switchToBonusRound () { this.currentRound = ROUND.BONUS; this.randomQuestionCache = []; @@ -71,7 +71,7 @@ export default class TossupBonusRoom extends TossupRoom { scoreTossup ({ givenAnswer }) { const decision = super.scoreTossup({ givenAnswer }); - if (decision.directive === "accept") { + if (decision.directive === 'accept') { this.bonusEligibleUserId = this.buzzedIn; this.switchToBonusRound(); } @@ -81,8 +81,7 @@ export default class TossupBonusRoom extends TossupRoom { async nextRound (userId, { type }) { if (this.currentRound === ROUND.TOSSUP) { await this.nextTossup(userId, { type }); - } - else { + } else { await this.nextBonus(userId, { type }); } } @@ -90,8 +89,7 @@ export default class TossupBonusRoom extends TossupRoom { lastQuestionDict () { if (this.currentRound === ROUND.TOSSUP) { return { oldTossup: this.tossup }; - } - else { + } else { return { oldBonus: this.bonus }; } } @@ -111,11 +109,10 @@ export default class TossupBonusRoom extends TossupRoom { if (type === 'next' && bonusStarted) { // Points already added incrementally during bonus, just update stats with 0 points this.players[userId].updateStats(0, 1); - const oldBonus = this.bonus; // Preserve oldBonus before switching rounds + const oldBonus = this.bonus; // Preserve oldBonus before switching rounds this.switchToTossupRound(); await this.nextTossup(userId, { type, oldBonus }); - } - else { + } else { const lastQuestionDict = this.lastQuestionDict(); // If this is the first bonus (transitioning from tossup), preserve the oldTossup @@ -127,7 +124,7 @@ export default class TossupBonusRoom extends TossupRoom { this.queryingQuestion = false; if (!this.bonus) { - this.emitMessage({ ...lastQuestionDict, ...preservedTossup, type: 'end', lastPartRevealed, pointsPerPart, stats, userId }); + this.emitMessage({ ...lastQuestionDict, ...preservedTossup, type: 'end', lastPartRevealed, pointsPerPart, userId }); return false; } @@ -203,7 +200,7 @@ export default class TossupBonusRoom extends TossupRoom { // Cancel buzz timer when manually revealing clearInterval(this.buzzTimer.interval); - this.liveAnswer = ''; // Clear any previous answer + this.liveAnswer = ''; // Clear any previous answer this.emitMessage({ type: 'start-answer', userId: this.bonusEligibleUserId }); this.startServerTimer( ANSWER_TIME_LIMIT * 10, diff --git a/server/multiplayer/ServerMultiplayerRoomMixin.js b/server/multiplayer/ServerMultiplayerRoomMixin.js index 18fdb2419..b68b76839 100644 --- a/server/multiplayer/ServerMultiplayerRoomMixin.js +++ b/server/multiplayer/ServerMultiplayerRoomMixin.js @@ -4,7 +4,7 @@ import { HEADER, ENDC, OKCYAN, OKBLUE } from '../bcolors.js'; import isAppropriateString from '../moderation/is-appropriate-string.js'; import { MODE_ENUM, TOSSUP_PROGRESS_ENUM } from '../../quizbowl/constants.js'; import insertTokensIntoHTML from '../../quizbowl/insert-tokens-into-html.js'; -import TossupRoom from '../../quizbowl/TossupRoom.js'; +// import TossupRoom from '../../quizbowl/TossupRoom.js'; import RateLimit from '../RateLimit.js'; import getRandomTossups from '../../database/qbreader/get-random-tossups.js'; diff --git a/server/multiplayer/ServerTossupBonusRoom.js b/server/multiplayer/ServerTossupBonusRoom.js index b6e3c5dc6..1277b6d30 100644 --- a/server/multiplayer/ServerTossupBonusRoom.js +++ b/server/multiplayer/ServerTossupBonusRoom.js @@ -1,4 +1,4 @@ -import ServerMultiplayerRoomMixin from './ServerMultiplayerRoomMixin.js' +import ServerMultiplayerRoomMixin from './ServerMultiplayerRoomMixin.js'; import TossupBonusRoom from '../../quizbowl/TossupBonusRoom.js'; const ServerTossupBonusRoom = ServerMultiplayerRoomMixin(TossupBonusRoom); diff --git a/server/multiplayer/ServerTossupRoom.js b/server/multiplayer/ServerTossupRoom.js index 620b4a3e4..fa766d7eb 100644 --- a/server/multiplayer/ServerTossupRoom.js +++ b/server/multiplayer/ServerTossupRoom.js @@ -1,4 +1,4 @@ -import ServerMultiplayerRoomMixin from './ServerMultiplayerRoomMixin.js' +import ServerMultiplayerRoomMixin from './ServerMultiplayerRoomMixin.js'; import TossupRoom from '../../quizbowl/TossupRoom.js'; const ServerTossupRoom = ServerMultiplayerRoomMixin(TossupRoom);