diff --git a/client/play/tossups/TossupBonusClient.js b/client/play/tossups/TossupBonusClient.js new file mode 100644 index 000000000..f3368335f --- /dev/null +++ b/client/play/tossups/TossupBonusClient.js @@ -0,0 +1,114 @@ +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..a33b1f6af 100644 --- a/client/play/tossups/TossupClient.js +++ b/client/play/tossups/TossupClient.js @@ -38,15 +38,22 @@ 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 +61,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..2beb43228 --- /dev/null +++ b/client/play/tossups/mp/MultiplayerClientMixin.js @@ -0,0 +1,752 @@ + +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..4865d48f7 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 }); + 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 new file mode 100644 index 000000000..3050ca9a7 --- /dev/null +++ b/quizbowl/TossupBonusRoom.js @@ -0,0 +1,270 @@ +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, 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..b68b76839 --- /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..1277b6d30 --- /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..fa766d7eb 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;