diff --git a/server/multiplayer/ServerMultiplayerRoomMixin.js b/server/multiplayer/ServerMultiplayerRoomMixin.js index e73596134..6097e70a2 100644 --- a/server/multiplayer/ServerMultiplayerRoomMixin.js +++ b/server/multiplayer/ServerMultiplayerRoomMixin.js @@ -277,6 +277,11 @@ const ServerMultiplayerRoomMixin = (RoomClass) => class extends RoomClass { super.setCategories({ userId, username }, { categories, subcategories, alternateSubcategories, percentView, categoryPercents }); } + setDifficulties ({ userId, username }, { difficulties }) { + if (this.isPermanent || !this.allowed(userId)) { return; } + super.setDifficulties({ userId, username }, { difficulties }); + } + setMode ({ userId, username }, { mode }) { if (this.isPermanent || !this.allowed(userId)) { return; } if (this.mode !== MODE_ENUM.SET_NAME && this.mode !== MODE_ENUM.RANDOM) { return; } @@ -377,8 +382,13 @@ const ServerMultiplayerRoomMixin = (RoomClass) => class extends RoomClass { super.toggleStandardOnly({ userId, username }, { doNotFetch: false, standardOnly }); } + toggleStopOnPower ({ userId, username }, { stopOnPower }) { + if (!this.allowed(userId)) { return; } + super.toggleStopOnPower({ userId, username }, { stopOnPower }); + } + togglePublic ({ userId, username }, { public: isPublic }) { - if (this.isPermanent || this.settings.controlled) { return; } + if (this.isPermanent) { return; } this.settings.public = isPublic; if (isPublic) { this.settings.lock = false; diff --git a/server/multiplayer/ServerPlayer.js b/server/multiplayer/ServerPlayer.js index 54ab67d05..0e0590fb0 100644 --- a/server/multiplayer/ServerPlayer.js +++ b/server/multiplayer/ServerPlayer.js @@ -5,5 +5,15 @@ export default class ServerPlayer extends Player { constructor (userId) { super(userId, USERNAME_MAX_LENGTH); this.online = true; + this.consecutiveEarlyCorrect = 0; + } + + resetBotDetectionCounter () { + this.consecutiveEarlyCorrect = 0; + } + + recordEarlyCorrect () { + this.consecutiveEarlyCorrect++; + return this.consecutiveEarlyCorrect >= 3; } } diff --git a/server/multiplayer/ServerTossupBonusRoom.js b/server/multiplayer/ServerTossupBonusRoom.js index 9610b60c7..6945a6c80 100644 --- a/server/multiplayer/ServerTossupBonusRoom.js +++ b/server/multiplayer/ServerTossupBonusRoom.js @@ -1,3 +1,4 @@ +import { EARLY_CORRECT_CELERITY_THRESHOLD } from './constants.js'; import ServerMultiplayerRoomMixin from './ServerMultiplayerRoomMixin.js'; import TossupBonusRoom from '../../quizbowl/TossupBonusRoom.js'; import { QUESTION_TYPE_ENUM, TOSSUP_PROGRESS_ENUM } from '../../quizbowl/constants.js'; @@ -8,7 +9,6 @@ export default class ServerTossupBonusRoom extends ServerMultiplayerRoomMixin(To } giveAnswerLiveUpdate ({ userId, username }, { givenAnswer }) { - // Allow live updates during bonuses (when buzzedIn is null) or from the user who buzzed switch (this.currentQuestionType) { case QUESTION_TYPE_ENUM.TOSSUP: if (userId !== this.buzzedIn) { return false; } @@ -20,8 +20,30 @@ export default class ServerTossupBonusRoom extends ServerMultiplayerRoomMixin(To super.giveAnswerLiveUpdate({ userId, username }, { givenAnswer }); } + giveTossupAnswer ({ userId, username }, { givenAnswer }) { + if (typeof givenAnswer !== 'string') { return false; } + if (this.buzzedIn !== userId) { return false; } + + if (Object.keys(this.tossup || {}).length === 0) { return; } + + const { celerity, directive } = this.scoreTossup({ givenAnswer }); + + if (directive === 'accept' && celerity >= EARLY_CORRECT_CELERITY_THRESHOLD) { + const shouldKick = this.players[userId].recordEarlyCorrect(); + if (shouldKick) { + console.log(`Bot detected: User ${userId} (${username}) got 3 correct with abnormally high celerity. Kicking.`); + this.sendToSocket(userId, { type: 'alert', message: 'You were removed for suspected bot behavior.' }); + setTimeout(() => this.closeConnection({ userId, username }), 100); + return; + } + } else { + this.players[userId].resetBotDetectionCounter(); + } + + super.giveTossupAnswer({ userId, username }, { givenAnswer }); + } + next ({ userId, username }) { - // prevents spam-skipping trolls if ( this.currentQuestionType === QUESTION_TYPE_ENUM.TOSSUP && this.tossupProgress === TOSSUP_PROGRESS_ENUM.READING && diff --git a/server/multiplayer/configure-permanent-room.js b/server/multiplayer/configure-permanent-room.js new file mode 100644 index 000000000..ef61c1db9 --- /dev/null +++ b/server/multiplayer/configure-permanent-room.js @@ -0,0 +1,52 @@ +export function configurePermanentRoomSettings (room, roomName) { + room.settings.public = true; + room.settings.controlled = true; + + room.settings.skip = false; + room.settings.rebuzz = false; + + room.settings.timer = true; + + const difficultyConfig = getPermanentRoomDifficulty(roomName); + if (difficultyConfig) { + room.query.difficulties = difficultyConfig.difficulties; + if (room.adjustQuery) { + room.adjustQuery(['difficulties'], [difficultyConfig.difficulties]); + } + } +} + +function getPermanentRoomDifficulty (roomName) { + switch (roomName) { + case 'msquizbowl': + return { difficulties: [1] }; + case 'hsquizbowl': + return { difficulties: [2, 3, 4, 5] }; + case 'collegequizbowl': + return { difficulties: [6, 7, 8, 9] }; + case 'literature': + case 'history': + case 'science': + case 'fine-arts': + case 'rmpss': + case 'geography': + case 'pop-culture': + return { difficulties: [2, 3, 4, 5] }; + case 'verified-msquizbowl': + return { difficulties: [1] }; + case 'verified-hsquizbowl': + return { difficulties: [2, 3, 4, 5] }; + case 'verified-collegequizbowl': + return { difficulties: [6, 7, 8, 9] }; + case 'verified-literature': + case 'verified-history': + case 'verified-science': + case 'verified-fine-arts': + case 'verified-rmpss': + case 'verified-geography': + case 'verified-pop-culture': + return { difficulties: [2, 3, 4, 5] }; + default: + return null; + } +} diff --git a/server/multiplayer/constants.js b/server/multiplayer/constants.js index 4b052ea33..957a82628 100644 --- a/server/multiplayer/constants.js +++ b/server/multiplayer/constants.js @@ -6,6 +6,8 @@ export const USERNAME_MAX_LENGTH = 32; export const MAX_ONLINE_PLAYERS = 500; export const MAX_CONNECTIONS_PER_IP = 50; +export const EARLY_CORRECT_CELERITY_THRESHOLD = 0.95; + /** * List of multiplayer permanent room names. */ diff --git a/server/multiplayer/handle-wss-connection.js b/server/multiplayer/handle-wss-connection.js index 9d82b31d6..b2b59e82e 100644 --- a/server/multiplayer/handle-wss-connection.js +++ b/server/multiplayer/handle-wss-connection.js @@ -1,5 +1,6 @@ import { MAX_ONLINE_PLAYERS, MAX_CONNECTIONS_PER_IP, PERMANENT_ROOMS, VERIFIED_ROOMS, ROOM_NAME_MAX_LENGTH } from './constants.js'; import ServerTossupBonusRoom from './ServerTossupBonusRoom.js'; +import { configurePermanentRoomSettings } from './configure-permanent-room.js'; import { checkToken } from '../authentication.js'; import CategoryManager from '../../quizbowl/category-manager.js'; import getRandomName from '../../quizbowl/get-random-name.js'; @@ -22,15 +23,19 @@ export const tossupBonusRooms = {}; const connectionsByIp = new Map(); for (const room of PERMANENT_ROOMS) { const { name, categories, subcategories } = room; - tossupBonusRooms[name] = new ServerTossupBonusRoom( + const permanentRoom = new ServerTossupBonusRoom( name, Symbol('unique permanent room owner'), true, new CategoryManager(categories, subcategories), false ); + configurePermanentRoomSettings(permanentRoom, name); + tossupBonusRooms[name] = permanentRoom; } for (const room of VERIFIED_ROOMS) { const { name, categories, subcategories } = room; - tossupBonusRooms[name] = new ServerTossupBonusRoom( + const verifiedRoom = new ServerTossupBonusRoom( name, Symbol('unique verified room owner'), true, new CategoryManager(categories, subcategories), true ); + configurePermanentRoomSettings(verifiedRoom, name); + tossupBonusRooms[name] = verifiedRoom; } /**