diff --git a/server/multiplayer/BanKickMixin.js b/server/multiplayer/BanKickMixin.js new file mode 100644 index 000000000..01a7ed656 --- /dev/null +++ b/server/multiplayer/BanKickMixin.js @@ -0,0 +1,31 @@ +const BAN_DURATION = 1000 * 60 * 30; // 30 minutes + +const BanKickMixin = (RoomClass) => class extends RoomClass { + 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.closeConnection({ userId: targetId, username: targetUsername }), 1000); + } + + 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); + } + }); + } +}; + +export default BanKickMixin; diff --git a/server/multiplayer/RoomSettingsMixin.js b/server/multiplayer/RoomSettingsMixin.js new file mode 100644 index 000000000..8c8a89fae --- /dev/null +++ b/server/multiplayer/RoomSettingsMixin.js @@ -0,0 +1,136 @@ +import isAppropriateString from '../moderation/is-appropriate-string.js'; +import { MODE_ENUM } from '../../quizbowl/constants.js'; + +const RoomSettingsMixin = (RoomClass) => class extends RoomClass { + allowed (userId) { + return (userId === this.ownerId) || this.settings.public || !this.settings.controlled; + } + + setCategories ({ userId, username }, { categories, subcategories, alternateSubcategories, percentView, categoryPercents }) { + if (this.isPermanent || !this.allowed(userId)) { return; } + super.setCategories({ userId, username }, { categories, subcategories, alternateSubcategories, percentView, categoryPercents }); + } + + setMode ({ userId, username }, { mode }) { + if (this.isPermanent || !this.allowed(userId)) { return; } + if (this.mode !== MODE_ENUM.SET_NAME && this.mode !== MODE_ENUM.RANDOM) { return; } + super.setMode({ userId, username }, { mode }); + this.adjustQuery(['setName'], [this.query.setName]); + } + + setPacketNumbers ({ userId, username }, { packetNumbers }) { + if (this.isPermanent || !this.allowed(userId)) { return; } + super.setPacketNumbers({ userId, username }, { doNotFetch: false, packetNumbers }); + } + + setReadingSpeed ({ userId, username }, { readingSpeed }) { + if (this.isPermanent || !this.allowed(userId)) { return false; } + super.setReadingSpeed({ userId, username }, { readingSpeed }); + } + + async setSetName ({ userId, username }, { setName }) { + if (!this.allowed(userId)) { return; } + if (!this.packetList) { return; } + if (!this.packetList.includes(setName)) { return; } + super.setSetName({ userId, username }, { doNotFetch: false, setName }); + } + + setStrictness ({ userId, username }, { strictness }) { + if (this.isPermanent || !this.allowed(userId)) { return; } + super.setStrictness({ userId, username }, { strictness }); + } + + setMinYear ({ userId, username }, { minYear }) { + if (this.isPermanent || !this.allowed(userId)) { return; } + super.setMinYear({ userId, username }, { minYear }); + } + + setMaxYear ({ userId, username }, { maxYear }) { + if (this.isPermanent || !this.allowed(userId)) { return; } + super.setMaxYear({ userId, username }, { 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, username }, { controlled }) { + if (this.settings.public) { return; } + if (userId !== this.ownerId) { return; } + this.settings.controlled = !!controlled; + this.emitMessage({ type: 'toggle-controlled', controlled, username }); + } + + toggleEnableBonuses ({ userId, username }, { enableBonuses }) { + if (this.isPermanent || !this.allowed(userId)) { return; } + super.toggleEnableBonuses({ userId, username }, { enableBonuses }); + } + + toggleLock ({ userId, username }, { lock }) { + if (this.settings.public || !this.allowed(userId)) { return; } + this.settings.lock = lock; + this.emitMessage({ type: 'toggle-lock', lock, username }); + } + + toggleLoginRequired ({ userId, username }, { loginRequired }) { + if (this.isVerified || this.settings.public || !this.allowed(userId)) { return; } + this.settings.loginRequired = loginRequired; + 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, username }, { powermarkOnly }) { + if (!this.allowed(userId)) { return; } + super.togglePowermarkOnly({ userId, username }, { powermarkOnly }); + } + + toggleSkip ({ userId, username }, { skip }) { + if (!this.allowed(userId)) { return; } + super.toggleSkip({ userId, username }, { skip }); + } + + toggleStandardOnly ({ userId, username }, { standardOnly }) { + if (!this.allowed(userId)) { return; } + super.toggleStandardOnly({ userId, username }, { doNotFetch: false, standardOnly }); + } + + togglePublic ({ userId, username }, { public: isPublic }) { + if (this.isPermanent || this.settings.controlled) { return; } + this.settings.public = isPublic; + if (isPublic) { + this.settings.lock = false; + this.settings.loginRequired = false; + this.settings.timer = true; + } + this.emitMessage({ type: 'toggle-public', public: isPublic, username }); + } + + toggleRebuzz ({ userId, username }, { rebuzz }) { + if (!this.allowed(userId)) { return false; } + super.toggleRebuzz({ userId, username }, { rebuzz }); + } + + toggleTimer ({ userId, username }, { timer }) { + if (this.settings.public || !this.allowed(userId)) { return; } + super.toggleTimer({ userId, username }, { timer }); + } +}; + +export default RoomSettingsMixin; diff --git a/server/multiplayer/ServerMultiplayerRoomMixin.js b/server/multiplayer/ServerMultiplayerRoomMixin.js index e73596134..fc9e0da5b 100644 --- a/server/multiplayer/ServerMultiplayerRoomMixin.js +++ b/server/multiplayer/ServerMultiplayerRoomMixin.js @@ -1,8 +1,10 @@ import ServerPlayer from './ServerPlayer.js'; -import Votekick from './VoteKick.js'; +import BanKickMixin from './BanKickMixin.js'; +import RoomSettingsMixin from './RoomSettingsMixin.js'; +import VotekickMixin from './VotekickMixin.js'; import { HEADER, ENDC, OKCYAN, OKBLUE } from '../bcolors.js'; import isAppropriateString from '../moderation/is-appropriate-string.js'; -import { MODE_ENUM, QUESTION_TYPE_ENUM, TOSSUP_PROGRESS_ENUM } from '../../quizbowl/constants.js'; +import { QUESTION_TYPE_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'; @@ -16,9 +18,7 @@ import getNumPackets from '../../database/qbreader/get-num-packets.js'; import checkAnswer from 'qb-answer-checker'; import Team from '../../quizbowl/Team.js'; -const BAN_DURATION = 1000 * 60 * 30; // 30 minutes - -const ServerMultiplayerRoomMixin = (RoomClass) => class extends RoomClass { +const ServerMultiplayerRoomMixin = (RoomClass) => class extends BanKickMixin(RoomSettingsMixin(VotekickMixin(RoomClass))) { constructor (name, ownerId, isPermanent, categoryManager, supportedQuestionTypes, isVerified = false) { super(name, categoryManager, supportedQuestionTypes); this.ownerId = ownerId; @@ -67,20 +67,6 @@ const ServerMultiplayerRoomMixin = (RoomClass) => class extends RoomClass { } } - allowed (userId) { - return (userId === this.ownerId) || this.settings.public || !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.closeConnection({ userId: targetId, username: targetUsername }), 1000); - } - connection (socket, userId, username, ip, userAgent = '') { console.log( `Connection in room ${HEADER}${this.name}${ENDC};`, @@ -222,22 +208,6 @@ const ServerMultiplayerRoomMixin = (RoomClass) => class extends RoomClass { 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); - } - }); - } - closeConnection ({ userId, username }) { if (!this.players[userId]) { return; } @@ -271,207 +241,6 @@ const ServerMultiplayerRoomMixin = (RoomClass) => class extends RoomClass { } } } - - setCategories ({ userId, username }, { categories, subcategories, alternateSubcategories, percentView, categoryPercents }) { - if (this.isPermanent || !this.allowed(userId)) { return; } - super.setCategories({ userId, username }, { categories, subcategories, alternateSubcategories, percentView, categoryPercents }); - } - - setMode ({ userId, username }, { mode }) { - if (this.isPermanent || !this.allowed(userId)) { return; } - if (this.mode !== MODE_ENUM.SET_NAME && this.mode !== MODE_ENUM.RANDOM) { return; } - super.setMode({ userId, username }, { mode }); - this.adjustQuery(['setName'], [this.query.setName]); - } - - setPacketNumbers ({ userId, username }, { packetNumbers }) { - if (this.isPermanent || !this.allowed(userId)) { return; } - super.setPacketNumbers({ userId, username }, { doNotFetch: false, packetNumbers }); - } - - setReadingSpeed ({ userId, username }, { readingSpeed }) { - if (this.isPermanent || !this.allowed(userId)) { return false; } - super.setReadingSpeed({ userId, username }, { readingSpeed }); - } - - async setSetName ({ userId, username }, { setName }) { - if (!this.allowed(userId)) { return; } - if (!this.packetList) { return; } - if (!this.packetList.includes(setName)) { return; } - super.setSetName({ userId, username }, { doNotFetch: false, setName }); - } - - setStrictness ({ userId, username }, { strictness }) { - if (this.isPermanent || !this.allowed(userId)) { return; } - super.setStrictness({ userId, username }, { strictness }); - } - - setMinYear ({ userId, username }, { minYear }) { - if (this.isPermanent || !this.allowed(userId)) { return; } - super.setMinYear({ userId, username }, { minYear }); - } - - setMaxYear ({ userId, username }, { maxYear }) { - if (this.isPermanent || !this.allowed(userId)) { return; } - super.setMaxYear({ userId, username }, { 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, username }, { controlled }) { - if (this.settings.public) { return; } - if (userId !== this.ownerId) { return; } - this.settings.controlled = !!controlled; - this.emitMessage({ type: 'toggle-controlled', controlled, username }); - } - - toggleEnableBonuses ({ userId, username }, { enableBonuses }) { - if (this.isPermanent || !this.allowed(userId)) { return; } - super.toggleEnableBonuses({ userId, username }, { enableBonuses }); - } - - toggleLock ({ userId, username }, { lock }) { - if (this.settings.public || !this.allowed(userId)) { return; } - this.settings.lock = lock; - this.emitMessage({ type: 'toggle-lock', lock, username }); - } - - toggleLoginRequired ({ userId, username }, { loginRequired }) { - if (this.isVerified || this.settings.public || !this.allowed(userId)) { return; } - this.settings.loginRequired = loginRequired; - 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, username }, { powermarkOnly }) { - if (!this.allowed(userId)) { return; } - super.togglePowermarkOnly({ userId, username }, { powermarkOnly }); - } - - toggleSkip ({ userId, username }, { skip }) { - if (!this.allowed(userId)) { return; } - super.toggleSkip({ userId, username }, { skip }); - } - - toggleStandardOnly ({ userId, username }, { standardOnly }) { - if (!this.allowed(userId)) { return; } - super.toggleStandardOnly({ userId, username }, { doNotFetch: false, standardOnly }); - } - - togglePublic ({ userId, username }, { public: isPublic }) { - if (this.isPermanent || this.settings.controlled) { return; } - this.settings.public = isPublic; - if (isPublic) { - this.settings.lock = false; - this.settings.loginRequired = false; - this.settings.timer = true; - } - this.emitMessage({ type: 'toggle-public', public: isPublic, username }); - } - - toggleRebuzz ({ userId, username }, { rebuzz }) { - if (!this.allowed(userId)) { return false; } - super.toggleRebuzz({ userId, username }, { rebuzz }); - } - - toggleTimer ({ userId, username }, { timer }) { - if (this.settings.public || !this.allowed(userId)) { return; } - super.toggleTimer({ userId, username }, { 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.closeConnection({ userId: targetId, username: targetUsername }), 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/VotekickMixin.js b/server/multiplayer/VotekickMixin.js new file mode 100644 index 000000000..835f620c1 --- /dev/null +++ b/server/multiplayer/VotekickMixin.js @@ -0,0 +1,80 @@ +import Votekick from './VoteKick.js'; + +const VotekickMixin = (RoomClass) => class extends RoomClass { + 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.closeConnection({ userId: targetId, username: targetUsername }), 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 VotekickMixin;