<commit_before>/**
 * Scavengers Plugin
 * Pokemon Showdown - http://pokemonshowdown.com/
 *
 * This is a game plugin to host scavenger games specifically in the Scavengers room,
 * where the players will race answer several hints.
 *
 * @license MIT license
 */

'use strict';

const fs = require('fs');

const DEFAULT_POINTS = [20, 15, 10, 5, 1];
const DEFAULT_BLITZ_POINTS = 10;

const path = require('path');
const DATA_FILE = path.resolve(__dirname, '../config/chat-plugins/scavdata.json');
const HOST_DATA_FILE = path.resolve(__dirname, '../config/chat-plugins/scavhostdata.json');
const PLAYER_DATA_FILE = path.resolve(__dirname, '../config/chat-plugins/scavplayerdata.json');

const SCAVENGE_REGEX = /^(?:\s)?(?:\/{2,}|\W)?(?:\s)?(?:s\W?cavenge|s\W?cav(?:engers)? guess|d\W?t|d\W?ata|d\W?etails|g\W?(?:uess)?|v)\b/i; // a regex of some of all the possible slips for leaks.
const FILTER_LENIENCY = 7;

const HISTORY_PERIOD = 6; // months

class Ladder {
	constructor(file) {
		this.file = file;
		this.data = {};

		this.load();
	}

	load() {
		fs.readFile(this.file, 'utf8', (err, content) => {
			if (err && err.code === 'ENOENT') return false; // file doesn't exist (yet)
			if (err) return console.log(`ERROR: Unable to load scavenger leaderboard: ${err}`);

			this.data = JSON.parse(content);
		});
	}

	addPoints(name, aspect, points, noUpdate) {
		let userid = toId(name);

		if (!userid || userid === 'constructor' || !points) return this;
		if (!this.data[userid]) this.data[userid] = {name: name};

		if (!this.data[userid][aspect]) this.data[userid][aspect] = 0;
		this.data[userid][aspect] += points;

		if (!noUpdate) this.data[userid].name = name; // always keep the last used name

		return this; // allow chaining
	}

	reset() {
		this.data = {};
		return this; // allow chaining
	}

	write() {
		fs.writeFile(this.file, JSON.stringify(this.data), err => {
			if (err) console.log(`ERROR: Failed to write scavengers ladder - ${err}`);
		});
	}

	visualize(sortBy, userid) {
		// return a promise for async sorting - make this less exploitable
		return new Promise((resolve, reject) => {
			let lowestScore = Infinity;
			let lastPlacement = 1;

			let ladder = Object.keys(this.data)
				.filter(k => sortBy in this.data[k])
				.sort((a, b) => this.data[b][sortBy] - this.data[a][sortBy])
				.map((u, i) => {
					u = this.data[u];
					if (u[sortBy] !== lowestScore) {
						lowestScore = u[sortBy];
						lastPlacement = i + 1;
					}
					return Object.assign(
						{rank: lastPlacement},
						u
					);
				}); // identify ties
			if (userid) {
				let rank = ladder.find(entry => toId(entry.name) === userid);
				resolve(rank);
			} else {
				resolve(ladder);
			}
		});
	}
}

class PlayerLadder extends Ladder {
	constructor(file) {
		super(file);
	}

	addPoints(name, aspect, points, noUpdate) {
		if (aspect.indexOf('cumulative-') !== 0) {
			this.addPoints(name, `cumulative-${aspect}`, points, noUpdate);
		}
		let userid = toId(name);

		if (!userid || userid === 'constructor' || !points) return this;
		if (!this.data[userid]) this.data[userid] = {name: name};

		if (!this.data[userid][aspect]) this.data[userid][aspect] = 0;
		this.data[userid][aspect] += points;

		if (!noUpdate) this.data[userid].name = name; // always keep the last used name

		return this; // allow chaining
	}

	// add the different keys to the history - async for larger leaderboards
	softReset() {
		return new Promise((resolve, reject) => {
			for (let u in this.data) {
				let userData = this.data[u];
				for (let a in userData) {
					if (/^(?:cumulative|history)\-/i.test(a) || a === 'name') continue; // cumulative does not need to be soft reset
					let historyKey = 'history-' + a;

					if (!userData[historyKey]) userData[historyKey] = [];

					userData[historyKey].unshift(userData[a]);
					userData[historyKey] = userData[historyKey].slice(0, HISTORY_PERIOD);

					userData[a] = 0; // set it back to 0
					// clean up if history is all 0's
					if (!userData[historyKey].some(p => p)) {
						delete userData[a];
						delete userData[historyKey];
					}
				}
			}
			resolve();
		});
	}

	hardReset() {
		this.data = {};
		return this; // allow chaining
	}
}

let Leaderboard = new Ladder(DATA_FILE);
let HostLeaderboard = new Ladder(HOST_DATA_FILE);
let PlayerLeaderboard = new PlayerLadder(PLAYER_DATA_FILE);

function formatQueue(queue, viewer) {
	let buf = queue.map((item, index) => `<tr${(viewer.userid !== item.hostId && viewer.userid !== item.staffHostId ? ` style="background-color: lightgray"` : "")}><td><button name="send" value="/scav dequeue ${index}" style="color: red; background-color: transparent; border: none; padding: 1px;">[x]</button>${Chat.escapeHTML(item.hostName)}${item.hostId !== item.staffHostId ? ` / ${item.staffHostId}` : ''}</td><td>${(item.hostId === viewer.userid || viewer.userid === item.staffHostId ? item.questions.map((q, i) => i % 2 ? `<span style="color: green"><em>[${Chat.escapeHTML(q)}]</em></span><br />` : Chat.escapeHTML(q)).join(" ") : `[${item.questions.length / 2} hidden questions]`)}</td></tr>`).join("");
	return `<div class="ladder"><table style="width: 100%"><tr><th>By</th><th>Questions</th></tr>${buf}</table></div>`;
}

function formatOrder(place) {
	// anything between 10 and 20 should always end with -th
	let remainder = place % 100;
	if (remainder >= 10 && remainder <= 20) return place + 'th';

	// follow standard rules with -st, -nd, -rd, and -th
	remainder = place % 10;
	if (remainder === 1) return place + 'st';
	if (remainder === 2) return place + 'nd';
	if (remainder === 3) return place + 'rd';
	return place + 'th';
}

class ScavengerHunt extends Rooms.RoomGame {
	constructor(room, staffHost, host, gameType, ...questions) {
		super(room);

		this.allowRenames = true;
		this.gameType = gameType;
		this.playerCap = Infinity;

		this.hostId = host.userid;
		this.hostName = host.name;
		this.staffHostId = staffHost.userid;
		this.staffHostName = staffHost.name;

		this.gameid = 'scavengers';
		this.title = 'Scavengers';

		this.state = 'signups';
		this.joinedIps = [];

		this.startTime = Date.now();
		this.questions = [];
		this.completed = [];

		this.onLoad(questions);
		if (gameType !== 'practice') HostLeaderboard.addPoints(host.name, 'points', 1).write();
	}

	// alert new users that are joining the room about the current hunt.
	onConnect(user, connection) {
		// send the fact that a hunt is currently going on.
		connection.sendTo(this.room, `|raw|<div class="broadcast-blue"><strong>${(this.gameType === 'official' ? "An official" : this.gameType === 'practice' ? "A practice" : "A")} Scavenger Hunt by <em>${Chat.escapeHTML(this.hostName)}</em> has been started${(this.hostId === this.staffHostId ? '' : ` by <em>${Chat.escapeHTML(this.staffHostName)}</em>`)}.<br />The first hint is: ${Chat.parseText(this.questions[0].hint)}</strong></div>`);
	}

	joinGame(user) {
		if (user.userid === this.hostId || user.userid === this.staffHostId) return user.sendTo(this.room, "You cannot join your own hunt! If you wish to view the check your questions, use /viewhunt instead!");
		if (Object.keys(user.ips).some(ip => this.joinedIps.includes(ip))) return user.sendTo(this.room, "You already have one alt in the hunt.");
		if (this.addPlayer(user)) {
			// limit to 1 IP in every game.
			for (let ip in user.ips) {
				this.joinedIps.push(ip);
			}

			user.sendTo("You joined the scavenger hunt! Use the command /scavenge to answer.");
			this.onSendQuestion(user);
			return true;
		}
		user.sendTo(this.room, "You have already joined the hunt.");
		return false;
	}

	leaveGame(user) {
		let player = this.players[user.userid];

		if (!player) return user.sendTo(this.room, "You have not joined the scavenger hunt.");
		if (player.completed) return user.sendTo(this.room, "You have already completed this scavenger hunt.");

		this.joinedIps = this.joinedIps.filter(ip => !player.joinIps.includes(ip));
		this.removePlayer(user);
		user.sendTo(this.room, "You have left the scavenger hunt.");
	}

	// overwrite the default makePlayer so it makes a ScavengerHuntPlayer instead.
	makePlayer(user) {
		return new ScavengerHuntPlayer(user, this);
	}

	onLoad(q) {
		for (let i = 0; i < q.length; i += 2) {
			let hint = q[i];
			let answer = q[i + 1];

			this.questions.push({hint: hint, answer: answer});
		}

		if (this.gameType === 'official') {
			this.setTimer(60);
		}

		this.announce(`A new ${(this.gameType === 'official' ? "official" : this.gameType === 'practice' ? "practice" : '')} Scavenger Hunt by <em>${Chat.escapeHTML(this.hostName)}</em> has been started${(this.hostId === this.staffHostId ? '' : ` by <em>${Chat.escapeHTML(this.staffHostName)}</em>`)}.<br />The first hint is: ${Chat.parseText(this.questions[0].hint)}`);
	}

	onEditQuestion(number, question_answer, ...value) {
		if (!['hint', 'answer'].includes(question_answer)) return false;

		value = question_answer === 'answer' ? toId(value.join(", ")) : value.join(", ");
		number = parseInt(number);

		if (!number || number < 1 || number > this.questions.length || !value) return false;

		number--; // indexOf starts at 0

		this.questions[number][question_answer] = value;
		this.announce(`The ${question_answer} for question ${number + 1} has been edited.`);
		if (question_answer === 'hint') {
			for (let p in this.players) {
				this.players[p].onNotifyChange(number);
			}
		}
		return true;
	}


	setTimer(minutes) {
		minutes = parseInt(minutes);

		if (this.timer) {
			clearTimeout(this.timer);
			delete this.timer;
		}

		if (minutes && minutes >= 1) {
			this.timer = setTimeout(() => this.onEnd(), minutes * 60000);
		}

		return minutes || 'off';
	}

	onSubmit(user, value) {
		if (!(user.userid in this.players) && !this.joinGame(user)) return false;
		value = toId(value);

		let player = this.players[user.userid];
		if (player.completed) return false;

		if (player.userid === this.hostId || player.userid === this.staffHostId) {
			// someone joining on an alt then going back to their original userid
			player.sendRoom("You have been automatically disqualified for doing your own hunt.");

			PlayerLeaderboard.addPoints(user.name, 'infraction', 1);

			// destroy them and get rid of their progress.
			player.destroy();
			delete this.players[user.userid];
			return false;
		}

		let uniqueConnections = this.getUniqueConnections(user);
		if (uniqueConnections > 1) {
			// multiple users on one alt
			player.sendRoom("You have been automatically disqualified for attempting a hunt with multiple connections on your account.  Staff have been notified.");

			// notify staff
			let staffMsg = `(${user.name} has been caught attempting a hunt with ${uniqueConnections} connections on the account, and was automatically disqualified. The user has also been given 1 infraction point on the player leaderboard.)`;
			this.room.sendModCommand(staffMsg);
			this.room.logEntry(staffMsg);
			this.room.modlog(staffMsg);

			PlayerLeaderboard.addPoints(user.name, 'infraction', 1);

			// destroy them and get rid of their progress.
			player.destroy();
			delete this.players[user.userid];
			return false;
		}

		if (player.verifyAnswer(value)) {
			player.sendRoom("Congratulations! You have gotten the correct answer.");
			player.currentQuestion++;
			if (player.currentQuestion === this.questions.length) {
				this.onComplete(player);
			} else {
				this.onSendQuestion(user);
			}
		} else {
			player.sendRoom("That is not the answer - try again!");
		}
	}

	onSendQuestion(user) {
		if (!(user.userid in this.players) || user.userid === this.hostId) return false;

		let player = this.players[user.userid];
		if (player.completed) return false;

		let current = player.getCurrentQuestion();

		player.sendRoom(`|raw|You are on ${(current.number === this.questions.length ? "final " : "")}hint #${current.number}: ${Chat.parseText(current.question.hint)}`);
		return true;
	}

	onComplete(player) {
		if (player.completed) return false;

		let now = Date.now();
		let time = Chat.toDurationString(now - this.startTime, {hhmmss: true});

		let blitz = this.gameType === 'official' && now - this.startTime <= 60000;

		player.completed = true;
		this.completed.push({name: player.name, time: time, blitz: blitz});
		let place = formatOrder(this.completed.length);

		this.announce(`<em>${Chat.escapeHTML(player.name)}</em> has finished the hunt in ${place} place! (${time}${(blitz ? " - BLITZ" : "")})`);
		player.destroy(); // remove from user.games;
	}

	onEnd(reset, endedBy) {
		if (!endedBy && this.completed.length === 0) {
			reset = true;
		}
		if (!reset) {
			// give points for winning and blitzes in official games

			let winPoints = this.room.winPoints || DEFAULT_POINTS;
			let blitzPoints = this.room.blitzPoints || DEFAULT_BLITZ_POINTS;

			if (this.gameType === 'official') {
				for (let i = 0; i < this.completed.length; i++) {
					if (!this.completed[i].blitz && i >= winPoints.length) break; // there won't be any more need to keep going
					let name = this.completed[i].name;
					if (winPoints[i]) Leaderboard.addPoints(name, 'points', winPoints[i]);
					if (this.completed[i].blitz) Leaderboard.addPoints(name, 'points', blitzPoints);
				}
				Leaderboard.write();
			}

			let sliceIndex = this.gameType === 'official' ? 5 : 3;

			this.announce(
				`The ${this.gameType ? `${this.gameType} ` : ""}scavenger hunt was ended ${(endedBy ? "by " + Chat.escapeHTML(endedBy.name) : "automatically")}.<br />` +
				`${this.completed.slice(0, sliceIndex).map((p, i) => `${formatOrder(i + 1)} place: <em>${Chat.escapeHTML(p.name)}</em>.<br />`).join("")}${this.completed.length > sliceIndex ? `Consolation Prize: ${this.completed.slice(sliceIndex).map(e => Chat.escapeHTML(e.name)).join(', ')}<br />` : ''}<br />` +
				`<details style="cursor: pointer;"><summary>Solution: </summary><br />${this.questions.map((q, i) => `${i + 1}) ${Chat.escapeHTML(q.hint)} <span style="color: lightgreen">[<em>${Chat.escapeHTML(q.answer)}</em>]</span>`).join("<br />")}</details>`
			);

			this.onTallyLeaderboard();

			this.tryRunQueue(this.room.id);
		} else if (endedBy) {
			this.announce(`The scavenger hunt has been reset by ${endedBy.name}.`);
		} else {
			this.announce("The hunt has been reset automatically, due to the lack of finishers.");
			this.tryRunQueue(this.room.id);
		}

		this.destroy();
	}

	onTallyLeaderboard() {
		// update player leaderboard with the statistics
		for (let p in this.players) {
			let player = this.players[p];
			PlayerLeaderboard.addPoints(player.name, 'join', 1);
		}
		for (let i = 0; i < this.completed.length; i++) {
			PlayerLeaderboard.addPoints(this.completed[i].name, 'finish', 1);
		}

		PlayerLeaderboard.write();
	}

	tryRunQueue(roomid) {
		// prepare the next queue'd game
		if (this.room.scavQueue && this.room.scavQueue.length) {
			setTimeout(() => {
				let room = Rooms(roomid);
				if (!room || room.game || !room.scavQueue.length) return;

				let next = room.scavQueue.shift();
				room.game = new ScavengerHunt(room, {userid: next.staffHostId, name: next.staffHostName}, {name: next.hostName, userid: next.hostId}, false, ...next.questions);
				room.game.setTimer(120); // auto timer of 2 hours for queue'd games.

				room.add(`|c|~|[ScavengerManager] A scavenger hunt by ${next.hostName} has been automatically started. It will automatically end in 2 hours.`).update(); // highlight the users with "hunt by"

				// update the saved queue.
				if (room.chatRoomData) {
					room.chatRoomData.scavQueue = room.scavQueue;
					Rooms.global.writeChatRoomData();
				}
			}, 1.5 * 60000); // 1.5 minute cooldown
		}
	}

	// modify destroy to get rid of any timers in the current roomgame.
	destroy() {
		delete this.room.game;
		this.room = null;

		if (this.timer) {
			clearTimeout(this.timer);
		}
		for (let i in this.players) {
			this.players[i].destroy();
		}
	}

	announce(msg) {
		this.room.add(`|raw|<div class="broadcast-blue"><strong>${msg}</strong></div>`).update();
	}

	onUpdateConnection() {}

	onChatMessage(msg) {
		let msgId = toId(msg);

		let commandMatch = msg.match(SCAVENGE_REGEX);
		if (commandMatch) msgId = msgId.slice(toId(commandMatch[0]).length);

		let filtered = this.questions.map(q => q.answer).some(a => {
			let md = Math.ceil((a.length - 3) / FILTER_LENIENCY);
			if (Dex.levenshtein(msgId, a, md) <= md) return true;
			return false;
		});

		if (filtered) return "Please do not leak the answer. Use /scavenge [guess] to submit your guess instead.";
	}<commit_msg>Scavengers: update leak guard (#3587)<commit_after>/**
 * Scavengers Plugin
 * Pokemon Showdown - http://pokemonshowdown.com/
 *
 * This is a game plugin to host scavenger games specifically in the Scavengers room,
 * where the players will race answer several hints.
 *
 * @license MIT license
 */

'use strict';

const fs = require('fs');

const DEFAULT_POINTS = [20, 15, 10, 5, 1];
const DEFAULT_BLITZ_POINTS = 10;

const path = require('path');
const DATA_FILE = path.resolve(__dirname, '../config/chat-plugins/scavdata.json');
const HOST_DATA_FILE = path.resolve(__dirname, '../config/chat-plugins/scavhostdata.json');
const PLAYER_DATA_FILE = path.resolve(__dirname, '../config/chat-plugins/scavplayerdata.json');

const SCAVENGE_REGEX = /^(?:\s)?(?:\/{2,}|\W)?(?:\s)?(?:s\W?cavenge|s\W?cav(?:engers)? guess|d\W?t|d\W?ata|d\W?etails|g\W?(?:uess)?|v)\b/i; // a regex of some of all the possible slips for leaks.
const FILTER_LENIENCY = 7;

const HISTORY_PERIOD = 6; // months

class Ladder {
	constructor(file) {
		this.file = file;
		this.data = {};

		this.load();
	}

	load() {
		fs.readFile(this.file, 'utf8', (err, content) => {
			if (err && err.code === 'ENOENT') return false; // file doesn't exist (yet)
			if (err) return console.log(`ERROR: Unable to load scavenger leaderboard: ${err}`);

			this.data = JSON.parse(content);
		});
	}

	addPoints(name, aspect, points, noUpdate) {
		let userid = toId(name);

		if (!userid || userid === 'constructor' || !points) return this;
		if (!this.data[userid]) this.data[userid] = {name: name};

		if (!this.data[userid][aspect]) this.data[userid][aspect] = 0;
		this.data[userid][aspect] += points;

		if (!noUpdate) this.data[userid].name = name; // always keep the last used name

		return this; // allow chaining
	}

	reset() {
		this.data = {};
		return this; // allow chaining
	}

	write() {
		fs.writeFile(this.file, JSON.stringify(this.data), err => {
			if (err) console.log(`ERROR: Failed to write scavengers ladder - ${err}`);
		});
	}

	visualize(sortBy, userid) {
		// return a promise for async sorting - make this less exploitable
		return new Promise((resolve, reject) => {
			let lowestScore = Infinity;
			let lastPlacement = 1;

			let ladder = Object.keys(this.data)
				.filter(k => sortBy in this.data[k])
				.sort((a, b) => this.data[b][sortBy] - this.data[a][sortBy])
				.map((u, i) => {
					u = this.data[u];
					if (u[sortBy] !== lowestScore) {
						lowestScore = u[sortBy];
						lastPlacement = i + 1;
					}
					return Object.assign(
						{rank: lastPlacement},
						u
					);
				}); // identify ties
			if (userid) {
				let rank = ladder.find(entry => toId(entry.name) === userid);
				resolve(rank);
			} else {
				resolve(ladder);
			}
		});
	}
}

class PlayerLadder extends Ladder {
	constructor(file) {
		super(file);
	}

	addPoints(name, aspect, points, noUpdate) {
		if (aspect.indexOf('cumulative-') !== 0) {
			this.addPoints(name, `cumulative-${aspect}`, points, noUpdate);
		}
		let userid = toId(name);

		if (!userid || userid === 'constructor' || !points) return this;
		if (!this.data[userid]) this.data[userid] = {name: name};

		if (!this.data[userid][aspect]) this.data[userid][aspect] = 0;
		this.data[userid][aspect] += points;

		if (!noUpdate) this.data[userid].name = name; // always keep the last used name

		return this; // allow chaining
	}

	// add the different keys to the history - async for larger leaderboards
	softReset() {
		return new Promise((resolve, reject) => {
			for (let u in this.data) {
				let userData = this.data[u];
				for (let a in userData) {
					if (/^(?:cumulative|history)\-/i.test(a) || a === 'name') continue; // cumulative does not need to be soft reset
					let historyKey = 'history-' + a;

					if (!userData[historyKey]) userData[historyKey] = [];

					userData[historyKey].unshift(userData[a]);
					userData[historyKey] = userData[historyKey].slice(0, HISTORY_PERIOD);

					userData[a] = 0; // set it back to 0
					// clean up if history is all 0's
					if (!userData[historyKey].some(p => p)) {
						delete userData[a];
						delete userData[historyKey];
					}
				}
			}
			resolve();
		});
	}

	hardReset() {
		this.data = {};
		return this; // allow chaining
	}
}

let Leaderboard = new Ladder(DATA_FILE);
let HostLeaderboard = new Ladder(HOST_DATA_FILE);
let PlayerLeaderboard = new PlayerLadder(PLAYER_DATA_FILE);

function formatQueue(queue, viewer) {
	let buf = queue.map((item, index) => `<tr${(viewer.userid !== item.hostId && viewer.userid !== item.staffHostId ? ` style="background-color: lightgray"` : "")}><td><button name="send" value="/scav dequeue ${index}" style="color: red; background-color: transparent; border: none; padding: 1px;">[x]</button>${Chat.escapeHTML(item.hostName)}${item.hostId !== item.staffHostId ? ` / ${item.staffHostId}` : ''}</td><td>${(item.hostId === viewer.userid || viewer.userid === item.staffHostId ? item.questions.map((q, i) => i % 2 ? `<span style="color: green"><em>[${Chat.escapeHTML(q)}]</em></span><br />` : Chat.escapeHTML(q)).join(" ") : `[${item.questions.length / 2} hidden questions]`)}</td></tr>`).join("");
	return `<div class="ladder"><table style="width: 100%"><tr><th>By</th><th>Questions</th></tr>${buf}</table></div>`;
}

function formatOrder(place) {
	// anything between 10 and 20 should always end with -th
	let remainder = place % 100;
	if (remainder >= 10 && remainder <= 20) return place + 'th';

	// follow standard rules with -st, -nd, -rd, and -th
	remainder = place % 10;
	if (remainder === 1) return place + 'st';
	if (remainder === 2) return place + 'nd';
	if (remainder === 3) return place + 'rd';
	return place + 'th';
}

class ScavengerHunt extends Rooms.RoomGame {
	constructor(room, staffHost, host, gameType, ...questions) {
		super(room);

		this.allowRenames = true;
		this.gameType = gameType;
		this.playerCap = Infinity;

		this.hostId = host.userid;
		this.hostName = host.name;
		this.staffHostId = staffHost.userid;
		this.staffHostName = staffHost.name;

		this.gameid = 'scavengers';
		this.title = 'Scavengers';

		this.state = 'signups';
		this.joinedIps = [];

		this.startTime = Date.now();
		this.questions = [];
		this.completed = [];

		this.onLoad(questions);
		if (gameType !== 'practice') HostLeaderboard.addPoints(host.name, 'points', 1).write();
	}

	// alert new users that are joining the room about the current hunt.
	onConnect(user, connection) {
		// send the fact that a hunt is currently going on.
		connection.sendTo(this.room, `|raw|<div class="broadcast-blue"><strong>${(this.gameType === 'official' ? "An official" : this.gameType === 'practice' ? "A practice" : "A")} Scavenger Hunt by <em>${Chat.escapeHTML(this.hostName)}</em> has been started${(this.hostId === this.staffHostId ? '' : ` by <em>${Chat.escapeHTML(this.staffHostName)}</em>`)}.<br />The first hint is: ${Chat.parseText(this.questions[0].hint)}</strong></div>`);
	}

	joinGame(user) {
		if (user.userid === this.hostId || user.userid === this.staffHostId) return user.sendTo(this.room, "You cannot join your own hunt! If you wish to view the check your questions, use /viewhunt instead!");
		if (Object.keys(user.ips).some(ip => this.joinedIps.includes(ip))) return user.sendTo(this.room, "You already have one alt in the hunt.");
		if (this.addPlayer(user)) {
			// limit to 1 IP in every game.
			for (let ip in user.ips) {
				this.joinedIps.push(ip);
			}

			user.sendTo("You joined the scavenger hunt! Use the command /scavenge to answer.");
			this.onSendQuestion(user);
			return true;
		}
		user.sendTo(this.room, "You have already joined the hunt.");
		return false;
	}

	leaveGame(user) {
		let player = this.players[user.userid];

		if (!player) return user.sendTo(this.room, "You have not joined the scavenger hunt.");
		if (player.completed) return user.sendTo(this.room, "You have already completed this scavenger hunt.");

		this.joinedIps = this.joinedIps.filter(ip => !player.joinIps.includes(ip));
		this.removePlayer(user);
		user.sendTo(this.room, "You have left the scavenger hunt.");
	}

	// overwrite the default makePlayer so it makes a ScavengerHuntPlayer instead.
	makePlayer(user) {
		return new ScavengerHuntPlayer(user, this);
	}

	onLoad(q) {
		for (let i = 0; i < q.length; i += 2) {
			let hint = q[i];
			let answer = q[i + 1];

			this.questions.push({hint: hint, answer: answer});
		}

		if (this.gameType === 'official') {
			this.setTimer(60);
		}

		this.announce(`A new ${(this.gameType === 'official' ? "official" : this.gameType === 'practice' ? "practice" : '')} Scavenger Hunt by <em>${Chat.escapeHTML(this.hostName)}</em> has been started${(this.hostId === this.staffHostId ? '' : ` by <em>${Chat.escapeHTML(this.staffHostName)}</em>`)}.<br />The first hint is: ${Chat.parseText(this.questions[0].hint)}`);
	}

	onEditQuestion(number, question_answer, ...value) {
		if (!['hint', 'answer'].includes(question_answer)) return false;

		value = question_answer === 'answer' ? toId(value.join(", ")) : value.join(", ");
		number = parseInt(number);

		if (!number || number < 1 || number > this.questions.length || !value) return false;

		number--; // indexOf starts at 0

		this.questions[number][question_answer] = value;
		this.announce(`The ${question_answer} for question ${number + 1} has been edited.`);
		if (question_answer === 'hint') {
			for (let p in this.players) {
				this.players[p].onNotifyChange(number);
			}
		}
		return true;
	}


	setTimer(minutes) {
		minutes = parseInt(minutes);

		if (this.timer) {
			clearTimeout(this.timer);
			delete this.timer;
		}

		if (minutes && minutes >= 1) {
			this.timer = setTimeout(() => this.onEnd(), minutes * 60000);
		}

		return minutes || 'off';
	}

	onSubmit(user, value) {
		if (!(user.userid in this.players) && !this.joinGame(user)) return false;
		value = toId(value);

		let player = this.players[user.userid];
		if (player.completed) return false;

		if (player.userid === this.hostId || player.userid === this.staffHostId) {
			// someone joining on an alt then going back to their original userid
			player.sendRoom("You have been automatically disqualified for doing your own hunt.");

			PlayerLeaderboard.addPoints(user.name, 'infraction', 1);

			// destroy them and get rid of their progress.
			player.destroy();
			delete this.players[user.userid];
			return false;
		}

		let uniqueConnections = this.getUniqueConnections(user);
		if (uniqueConnections > 1) {
			// multiple users on one alt
			player.sendRoom("You have been automatically disqualified for attempting a hunt with multiple connections on your account.  Staff have been notified.");

			// notify staff
			let staffMsg = `(${user.name} has been caught attempting a hunt with ${uniqueConnections} connections on the account, and was automatically disqualified. The user has also been given 1 infraction point on the player leaderboard.)`;
			this.room.sendModCommand(staffMsg);
			this.room.logEntry(staffMsg);
			this.room.modlog(staffMsg);

			PlayerLeaderboard.addPoints(user.name, 'infraction', 1);

			// destroy them and get rid of their progress.
			player.destroy();
			delete this.players[user.userid];
			return false;
		}

		if (player.verifyAnswer(value)) {
			player.sendRoom("Congratulations! You have gotten the correct answer.");
			player.currentQuestion++;
			if (player.currentQuestion === this.questions.length) {
				this.onComplete(player);
			} else {
				this.onSendQuestion(user);
			}
		} else {
			player.sendRoom("That is not the answer - try again!");
		}
	}

	onSendQuestion(user) {
		if (!(user.userid in this.players) || user.userid === this.hostId) return false;

		let player = this.players[user.userid];
		if (player.completed) return false;

		let current = player.getCurrentQuestion();

		player.sendRoom(`|raw|You are on ${(current.number === this.questions.length ? "final " : "")}hint #${current.number}: ${Chat.parseText(current.question.hint)}`);
		return true;
	}

	onComplete(player) {
		if (player.completed) return false;

		let now = Date.now();
		let time = Chat.toDurationString(now - this.startTime, {hhmmss: true});

		let blitz = this.gameType === 'official' && now - this.startTime <= 60000;

		player.completed = true;
		this.completed.push({name: player.name, time: time, blitz: blitz});
		let place = formatOrder(this.completed.length);

		this.announce(`<em>${Chat.escapeHTML(player.name)}</em> has finished the hunt in ${place} place! (${time}${(blitz ? " - BLITZ" : "")})`);
		player.destroy(); // remove from user.games;
	}

	onEnd(reset, endedBy) {
		if (!endedBy && this.completed.length === 0) {
			reset = true;
		}
		if (!reset) {
			// give points for winning and blitzes in official games

			let winPoints = this.room.winPoints || DEFAULT_POINTS;
			let blitzPoints = this.room.blitzPoints || DEFAULT_BLITZ_POINTS;

			if (this.gameType === 'official') {
				for (let i = 0; i < this.completed.length; i++) {
					if (!this.completed[i].blitz && i >= winPoints.length) break; // there won't be any more need to keep going
					let name = this.completed[i].name;
					if (winPoints[i]) Leaderboard.addPoints(name, 'points', winPoints[i]);
					if (this.completed[i].blitz) Leaderboard.addPoints(name, 'points', blitzPoints);
				}
				Leaderboard.write();
			}

			let sliceIndex = this.gameType === 'official' ? 5 : 3;

			this.announce(
				`The ${this.gameType ? `${this.gameType} ` : ""}scavenger hunt was ended ${(endedBy ? "by " + Chat.escapeHTML(endedBy.name) : "automatically")}.<br />` +
				`${this.completed.slice(0, sliceIndex).map((p, i) => `${formatOrder(i + 1)} place: <em>${Chat.escapeHTML(p.name)}</em>.<br />`).join("")}${this.completed.length > sliceIndex ? `Consolation Prize: ${this.completed.slice(sliceIndex).map(e => Chat.escapeHTML(e.name)).join(', ')}<br />` : ''}<br />` +
				`<details style="cursor: pointer;"><summary>Solution: </summary><br />${this.questions.map((q, i) => `${i + 1}) ${Chat.escapeHTML(q.hint)} <span style="color: lightgreen">[<em>${Chat.escapeHTML(q.answer)}</em>]</span>`).join("<br />")}</details>`
			);

			this.onTallyLeaderboard();

			this.tryRunQueue(this.room.id);
		} else if (endedBy) {
			this.announce(`The scavenger hunt has been reset by ${endedBy.name}.`);
		} else {
			this.announce("The hunt has been reset automatically, due to the lack of finishers.");
			this.tryRunQueue(this.room.id);
		}

		this.destroy();
	}

	onTallyLeaderboard() {
		// update player leaderboard with the statistics
		for (let p in this.players) {
			let player = this.players[p];
			PlayerLeaderboard.addPoints(player.name, 'join', 1);
		}
		for (let i = 0; i < this.completed.length; i++) {
			PlayerLeaderboard.addPoints(this.completed[i].name, 'finish', 1);
		}

		PlayerLeaderboard.write();
	}

	tryRunQueue(roomid) {
		// prepare the next queue'd game
		if (this.room.scavQueue && this.room.scavQueue.length) {
			setTimeout(() => {
				let room = Rooms(roomid);
				if (!room || room.game || !room.scavQueue.length) return;

				let next = room.scavQueue.shift();
				room.game = new ScavengerHunt(room, {userid: next.staffHostId, name: next.staffHostName}, {name: next.hostName, userid: next.hostId}, false, ...next.questions);
				room.game.setTimer(120); // auto timer of 2 hours for queue'd games.

				room.add(`|c|~|[ScavengerManager] A scavenger hunt by ${next.hostName} has been automatically started. It will automatically end in 2 hours.`).update(); // highlight the users with "hunt by"

				// update the saved queue.
				if (room.chatRoomData) {
					room.chatRoomData.scavQueue = room.scavQueue;
					Rooms.global.writeChatRoomData();
				}
			}, 1.5 * 60000); // 1.5 minute cooldown
		}
	}

	// modify destroy to get rid of any timers in the current roomgame.
	destroy() {
		delete this.room.game;
		this.room = null;

		if (this.timer) {
			clearTimeout(this.timer);
		}
		for (let i in this.players) {
			this.players[i].destroy();
		}
	}

	announce(msg) {
		this.room.add(`|raw|<div class="broadcast-blue"><strong>${msg}</strong></div>`).update();
	}

	onUpdateConnection() {}

	onChatMessage(msg) {
		let msgId = toId(msg);

		let commandMatch = msg.match(SCAVENGE_REGEX);
		if (commandMatch) msgId = msgId.slice(toId(commandMatch[0]).length);

		let filtered = this.questions.map(q => toId(q.answer)).some(a => {
			let md = Math.ceil((a.length - 3) / FILTER_LENIENCY);
			if (Dex.levenshtein(msgId, a, md) <= md) return true;
			return false;
		});

		if (filtered) return "Please do not leak the answer. Use /scavenge [guess] to submit your guess instead.";
	}
{'fraction_non_alphanumeric': 0.17021662834321674, 'fraction_numerical': 0.0060510710395740045, 'mean_word_length': 3.8330165228834625, 'pattern_counts': {'":': 0, '<': 135, '<?xml version=': 0, '>': 173, 'https://': 0, 'lorem ipsum': 0, 'www.': 0, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 0, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'dir': 'git-commits-cleaned', 'id': '5293673', 'n_tokens_mistral': 12611, 'n_tokens_neox': 11091, 'n_words': 2796}