/**
 * 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
 */

import { FS, Utils } from '../../lib';
import { ScavMods, type TwistEvent } from './scavenger-games';
import type { ChatHandler } from '../chat';

type GameTypes = 'official' | 'regular' | 'mini' | 'unrated' | 'practice' | 'recycled';

export interface QueuedHunt {
	hosts: { id: string, name: string, noUpdate?: boolean }[];
	questions: (string | string[])[];
	isHTML: boolean;
	staffHostId: string;
	staffHostName: string;
	gameType: GameTypes;
}
export interface FakeUser {
	name: string;
	id: string;
	noUpdate?: boolean;
}
interface ModEvent {
	priority: number;
	exec: TwistEvent;
}

const RATED_TYPES = ['official', 'regular', 'mini'];
const DEFAULT_POINTS: { [k: string]: number[] } = {
	official: [20, 15, 10, 5, 1],
};
const DEFAULT_BLITZ_POINTS: { [k: string]: number } = {
	official: 10,
};
const DEFAULT_HOST_POINTS = 4;
const DEFAULT_TIMER_DURATION = 120;

const DATA_FILE = 'config/chat-plugins/ScavMods.json';
const HOST_DATA_FILE = 'config/chat-plugins/scavhostdata.json';
const PLAYER_DATA_FILE = 'config/chat-plugins/scavplayerdata.json';
const DATABASE_FILE = 'config/chat-plugins/scavhunts.json';

const ACCIDENTAL_LEAKS = /^((?:\s)?(?:\/{2,}|[^\w/]+)|\s\/)?(?:\s)?(?:s\W?cavenge|s\W?cav(?:engers)? guess|d\W?t|d\W?ata|d\W?etails|g\W?(?:uess)?|v)\b/i;

const FILTER_LENIENCY = 7;

const HISTORY_PERIOD = 6; // months

const databaseContentsJSON = FS(DATABASE_FILE).readIfExistsSync();
const scavengersData = databaseContentsJSON ? JSON.parse(databaseContentsJSON) : { recycledHunts: [] };

const SCAVENGER_ROOMID = 'scavengers';
function getScavsRoom(room?: Room) {
	if (!room) return Rooms.get(SCAVENGER_ROOMID);
	if (room.roomid === SCAVENGER_ROOMID) return room;
	if (room.parent?.roomid === SCAVENGER_ROOMID) return room.parent;
	return null;
}

// Normalize answers before checking, eg: Pokémon! -> pokemon
export function sanitizeAnswer(answer: string): string {
	return toID(answer.normalize('NFD'));
}

class Ladder {
	file: string;
	data: { [userid: string]: AnyObject };
	constructor(file: string) {
		this.file = file;
		this.data = {};

		this.load();
	}

	load() {
		const json = FS(this.file).readIfExistsSync();
		if (json) this.data = JSON.parse(json);
	}

	addPoints(name: string, aspect: string, points: number, noUpdate?: boolean) {
		const userid = toID(name);

		if (!userid || userid === 'constructor' || !points) return this;
		if (!this.data[userid]) this.data[userid] = { 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(this.file).writeUpdate(() => JSON.stringify(this.data));
	}

	visualize(sortBy: string): Promise<({ rank: number } & AnyObject)[]>;
	visualize(sortBy: string, userid: ID): Promise<({ rank: number } & AnyObject) | undefined>;
	visualize(sortBy: string, userid?: ID) {
		// return a promise for async sorting - make this less exploitable
		return new Promise((resolve, reject) => {
			let lowestScore = Infinity;
			let lastPlacement = 1;

			const ladder = Utils.sortBy(
				Object.entries(this.data).filter(([u, bit]) => sortBy in bit),
				([u, bit]) => -bit[sortBy]
			).map(([u, chunk], i) => {
				if (chunk[sortBy] !== lowestScore) {
					lowestScore = chunk[sortBy];
					lastPlacement = i + 1;
				}
				return {
					rank: lastPlacement,
					...chunk,
				} as { rank: number } & AnyObject;
			}); // identify ties
			if (userid) {
				const rank = ladder.find(entry => toID(entry.name) === userid);
				resolve(rank);
			} else {
				resolve(ladder);
			}
		});
	}
}

class PlayerLadder extends Ladder {
	addPoints(name: string, aspect: string, points: number, noUpdate?: boolean) {
		if (!aspect.startsWith('cumulative-')) {
			this.addPoints(name, `cumulative-${aspect}`, points, noUpdate);
		}
		const userid = toID(name);

		if (!userid || userid === 'constructor' || !points) return this;
		if (!this.data[userid]) this.data[userid] = { 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
	// FIXME: this is not what "async" means
	softReset() {
		return new Promise<void>((resolve, reject) => {
			for (const u in this.data) {
				const userData = this.data[u];
				for (const a in userData) {
					if (/^(?:cumulative|history)-/i.test(a) || a === 'name') continue; // cumulative does not need to be soft reset
					const 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: any) => !!p)) {
						delete userData[a];
						delete userData[historyKey];
					}
				}
			}
			resolve();
		});
	}

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

// initialize roomsettings
const LeaderboardRoom = getScavsRoom();

const Leaderboard = LeaderboardRoom?.scavLeaderboard?.scavsLeaderboard || new Ladder(DATA_FILE);
const HostLeaderboard = LeaderboardRoom?.scavLeaderboard?.scavsHostLeaderboard || new PlayerLadder(HOST_DATA_FILE);
const PlayerLeaderboard = LeaderboardRoom?.scavLeaderboard?.scavsPlayerLeaderboard ||
	new PlayerLadder(PLAYER_DATA_FILE);

if (LeaderboardRoom) {
	if (!LeaderboardRoom.scavLeaderboard) LeaderboardRoom.scavLeaderboard = {};
	// bind ladders to scavenger room to persist through restarts
	LeaderboardRoom.scavLeaderboard.scavsLeaderboard = Leaderboard;
	LeaderboardRoom.scavLeaderboard.scavsHostLeaderboard = HostLeaderboard;
	LeaderboardRoom.scavLeaderboard.scavsPlayerLeaderboard = PlayerLeaderboard;
}

function formatQueue(queue: QueuedHunt[] | undefined, viewer: User, room: Room, broadcasting?: boolean) {
	const showStaff = viewer.can('mute', null, room) && !broadcasting;
	const queueDisabled = room.settings.scavSettings?.scavQueueDisabled;
	const timerDuration = room.settings.scavSettings?.defaultScavTimer || DEFAULT_TIMER_DURATION;
	let buffer;
	if (queue?.length) {
		buffer = queue.map((item, index) => {
			const removeButton = `<button name="send" value="/scav dequeue ${index}" style="color: red; background-color: transparent; border: none; padding: 1px;">[x]</button>`;
			const startButton = `<button name="send" value="/scav next ${index}" style="color: green; background-color: transparent; border: none; padding: 1px;">[start]</button>`;
			const unratedText = item.gameType === 'unrated' ?
				'<span style="color: blue; font-style: italic">[Unrated]</span> ' :
				'';
			const hosts = Utils.escapeHTML(Chat.toListString(item.hosts.map(h => h.name)));
			const queuedBy = item.hosts.every(h => h.id !== item.staffHostId) ? ` / ${item.staffHostId}` : '';
			let questions;
			if (!broadcasting && (item.hosts.some(h => h.id === viewer.id) || viewer.id === item.staffHostId)) {
				questions = item.questions.map(
					(q, i) => {
						if (i % 2) {
							q = q as string[];
							return Utils.html`<span style="color: green"><em>[${q.join(' / ')}]</em></span><br />`;
						} else {
							q = q as string;
							return item.isHTML ? q : Utils.escapeHTML(q);
						}
					}
				).join(" ");
			} else {
				questions = `[${item.questions.length / 2} hidden questions]`;
			}
			return `<tr><td>${removeButton}${startButton}&nbsp;${unratedText}${hosts}${queuedBy}</td><td><div style="overflow:auto; max-width: 100vw; max-height: 30vh">${questions}</div></td></tr>`;
		}).join("");
	} else {
		buffer = `<tr><td colspan=3>The scavenger queue is currently empty.</td></tr>`;
	}
	let template = `<div class="ladder"><table style="width: 100%;table-layout:fixed;"><tr><th>By</th><th>Questions</th></tr>${showStaff ? buffer : buffer.replace(/<button.*?>.+?<\/button>/gi, '')}</table></div>`;
	if (showStaff) {
		template += `<table style="width: 100%"><tr><td style="text-align: left;">Auto Timer Duration: ${timerDuration} minutes</td><td>Auto Dequeue: <button class="button${!queueDisabled ?
			'" name="send" value="/scav disablequeue"' :
			' disabled" style="font-weight:bold; color:#575757; font-weight:bold; background-color:#d3d3d3;"'}>OFF</button>&nbsp;<button class="button${queueDisabled ?
			'" name="send" value="/scav enablequeue"' :
			' disabled" style="font-weight:bold; color:#575757; font-weight:bold; background-color:#d3d3d3;"'}>ON</button></td><td style="text-align: right;"><button class="button" name="send" value="/scav next 0">Start the next hunt</button></td></tr></table>`;
	}
	return template;
}

class ScavengerHuntDatabase {
	static getRecycledHuntFromDatabase() {
		// Return a random hunt from the database.
		return scavengersData.recycledHunts[Math.floor(Math.random() * scavengersData.recycledHunts.length)];
	}

	static addRecycledHuntToDatabase(hosts: FakeUser[], params: (string | string[])[]) {
		const huntSchema: { hosts: FakeUser[], questions: AnyObject[] } = {
			hosts,
			questions: [],
		};

		let questionSchema: { text: string, answers: string[], hints?: string[] } = {
			text: '',
			answers: [],
			hints: [],
		};

		for (let i = 0; i < params.length; ++i) {
			if (i % 2 === 0) {
				const questionText = params[i] as string;
				questionSchema.text = questionText;
			} else {
				const answerText = params[i] as string[];
				questionSchema.answers = answerText;
				huntSchema.questions.push(questionSchema);
				questionSchema = {
					text: '',
					answers: [],
					hints: [],
				};
			}
		}

		scavengersData.recycledHunts.push(huntSchema);
		this.updateDatabaseOnDisk();
	}

	static removeRecycledHuntFromDatabase(index: number) {
		scavengersData.recycledHunts.splice(index - 1, 1);
		this.updateDatabaseOnDisk();
	}

	static addHintToRecycledHunt(huntNumber: number, questionNumber: number, hint: string) {
		scavengersData.recycledHunts[huntNumber - 1].questions[questionNumber - 1].hints.push(hint);
		this.updateDatabaseOnDisk();
	}

	static removeHintToRecycledHunt(huntNumber: number, questionNumber: number, hintNumber: number) {
		scavengersData.recycledHunts[huntNumber - 1].questions[questionNumber - 1].hints.splice(hintNumber - 1);
		this.updateDatabaseOnDisk();
	}

	static updateDatabaseOnDisk() {
		FS(DATABASE_FILE).writeUpdate(() => JSON.stringify(scavengersData));
	}

	static isEmpty() {
		return scavengersData.recycledHunts.length === 0;
	}

	static hasHunt(hunt_number: number) {
		return !isNaN(hunt_number) && hunt_number > 0 && hunt_number <= scavengersData.recycledHunts.length;
	}

	static getFullTextOfHunt(
		hunt: { hosts: FakeUser[], questions: { text: string, answers: string[], hints?: string[] }[] }
	) {
		return `${hunt.hosts.map(host => host.name).join(',')} | ${hunt.questions.map(question => `${question.text} | ${question.answers.join(';')}`).join(' | ')}`;
	}
}
export class ScavengerHunt extends Rooms.RoomGame<ScavengerHuntPlayer> {
	override readonly gameid = 'scavengerhunt' as ID;
	gameType: GameTypes;
	joinedIps: string[];
	startTime: number;
	questions: { hint: string, answer: string[], spoilers: string[] }[];
	completed: AnyObject[];
	leftHunt: { [userid: string]: 1 | undefined };
	hosts: FakeUser[];
	isHTML: boolean;
	modsList: string[];
	mods: { [k: string]: ModEvent[] };
	staffHostId: string;
	staffHostName: string;
	scavGame: true;
	timerEnd: number | null;
	timer: NodeJS.Timeout | null;

	readonly checkChat = true;

	[k: string]: any; // for purposes of adding new temporary properties for the purpose of twists.
	constructor({ room, staffHost, hosts, gameType, questions, isHTML, mod }:
	{
		room: Room,
		staffHost: User | FakeUser,
		hosts: FakeUser[],
		gameType: GameTypes,
		questions: (string | string[])[],
		isHTML?: boolean,
		mod?: string | string[],
	}) {
		super(room);

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

		this.joinedIps = [];

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

		this.leftHunt = {};

		this.hosts = hosts;

		this.isHTML = !!isHTML;

		this.modsList = [];
		this.mods = {};

		this.timer = null;
		this.timerEnd = null;

		this.staffHostId = staffHost.id;
		this.staffHostName = staffHost.name;
		this.cacheUserIps(staffHost); // store it in case of host subbing

		this.title = 'Scavenger Hunt';
		this.scavGame = true;

		if (this.room.scavgame) {
			this.loadMods(this.room.scavgame.mod);
		}
		if (mod) {
			this.loadMods(mod);
		} else if (this.gameType === 'official' && this.room.settings.scavSettings?.officialtwist) {
			this.loadMod(this.room.settings.scavSettings?.officialtwist);
		}

		this.runEvent('Load');
		this.onLoad(questions);
		this.runEvent('AfterLoad');
	}

	loadMods(modInformation: any) {
		if (Array.isArray(modInformation)) {
			for (const mod of modInformation) {
				this.loadMod(mod);
			}
		} else {
			this.loadMod(modInformation);
		}
	}

	loadMod(modData: string | ID | AnyObject) {
		let twist;
		if (typeof modData === 'string') {
			const modId = toID(modData) as string;
			if (!ScavMods.twists[modId]) return this.announce(`Invalid mod. Starting the hunt without the mod ${modId}.`);

			twist = ScavMods.twists[modId];
		} else {
			twist = modData;
		}
		this.modsList.push(twist.id);
		for (const key in twist) {
			if (!key.startsWith('on')) continue;
			const priority = twist[key + 'Priority'] || 0;
			if (!this.mods[key]) this.mods[key] = [];
			this.mods[key].push({ exec: twist[key], priority });
		}
		if (twist.isGameMode) {
			this.announce(`This hunt is part of an ongoing ${twist.name}.`);
		} else {
			this.announce(`This hunt uses the twist ${twist.name}.`);
		}
	}

	// alert new users that are joining the room about the current hunt.
	onConnect(user: User, connection: Connection) {
		// send the fact that a hunt is currently going on.
		connection.sendTo(this.room, this.getCreationMessage());
		this.runEvent('Connect', user, connection);
	}

	formatOutput(text: string): string {
		return this.isHTML ? text : Chat.formatText(text);
	}

	getCreationMessage(newHunt?: boolean): string {
		const message = this.runEvent('CreateCallback');
		if (message) return message;

		const hosts = Utils.escapeHTML(Chat.toListString(this.hosts.map(h => h.name)));
		const staffHost = this.hosts.some(h => h.id === this.staffHostId) ?
			`` :
			Utils.html` by <em>${this.staffHostName}</em>`;

		const article = ['official', 'unrated'].includes(this.gameType) && !newHunt ? 'An' : 'A';
		const huntType = `${article} ${newHunt ? 'new ' : ''}${this.gameType}`;

		return `|raw|<div class="broadcast-blue"><strong>${huntType} scavenger hunt by <em>${hosts}</em> has been started${staffHost}.</strong>` +
			`<div style="border:1px solid #CCC;padding:4px 6px;margin:4px 1px; overflow:scroll; max-height: 50vh">` +
			`<strong><em>Hint #1:</em> ${this.formatOutput(this.questions[0].hint)}</strong>` +
			`</div>` +
			`(To answer, use <kbd>/scavenge <em>ANSWER</em></kbd>)</div>`;
	}

	joinGame(user: User) {
		if (this.hosts.some(h => h.id === user.id) || user.id === this.staffHostId) {
			return user.sendTo(
				this.room,
				"You cannot join your own hunt! If you wish to view your questions, use /viewhunt instead!"
			);
		}
		if (!Config.noipchecks && user.ips.some(ip => this.joinedIps.includes(ip))) {
			return user.sendTo(this.room, "You already have one alt in the hunt.");
		}
		if (this.runEvent('Join', user)) return false;
		if (this.addPlayer(user)) {
			this.cacheUserIps(user);
			delete this.leftHunt[user.id];
			user.sendTo(this.room, "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;
	}

	cacheUserIps(user: User | FakeUser) {
		// limit to 1 IP in every game.
		if (!('ips' in user)) return; // ghost user object cached from queue
		for (const ip of user.ips) {
			this.joinedIps.push(ip);
		}
	}

	leaveGame(user: User) {
		const player = this.playerTable[user.id];

		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.runEvent('Leave', player);
		this.joinedIps = this.joinedIps.filter(ip => !player.joinIps.includes(ip));
		this.removePlayer(player);
		this.leftHunt[user.id] = 1;
		user.sendTo(this.room, "You have left the scavenger hunt.");
	}

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

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

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

		const message = this.getCreationMessage(true);
		this.room.add(message).update();
	}

	// returns whether or not the next action should be stopped
	runEvent(event_id: string, ...args: any[]) {
		const events = this.mods['on' + event_id];
		if (!events) return;

		Utils.sortBy(events, event => -event.priority);
		let result = undefined;

		for (const event of events) {
			const subResult = event.exec.call(this, ...args) as any;
			if (subResult === true) return true;
			result = subResult;
		}

		return result === false ? true : result;
	}

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

		let answer: string[] = [];
		if (question_answer === 'answer') {
			answer = value.split(';').map(p => p.trim());
		}

		if (!questionNumber || questionNumber < 1 || questionNumber > this.questions.length || (!answer && !value)) {
			return false;
		}

		questionNumber--; // indexOf starts at 0

		if (question_answer === 'answer') {
			this.questions[questionNumber].answer = answer;
		} else {
			this.questions[questionNumber].hint = value;
		}

		this.announce(`The ${question_answer} for question ${questionNumber + 1} has been edited.`);
		if (question_answer === 'hint') {
			for (const p in this.playerTable) {
				this.playerTable[p].onNotifyChange(questionNumber);
			}
		}
		return true;
	}

	setTimer(minutes: number) {
		if (this.timer) {
			clearTimeout(this.timer);
			this.timer = null;
			this.timerEnd = null;
		}

		if (minutes === 0) {
			return 'off';
		}
		if (minutes > 24 * 60) { // 24 hours
			throw new Chat.ErrorMessage(`Time limit must be under 24 hours (you asked for ${Chat.toDurationString(minutes * 60000)}).`);
		}
		if (minutes && minutes > 0) {
			this.timer = setTimeout(() => this.onEnd(), minutes * 60000);
			this.timerEnd = Date.now() + minutes * 60000;
		}

		return minutes;
	}

	choose(user: User, originalValue: string) {
		if (!(user.id in this.playerTable)) {
			if (!this.joinGame(user)) return false;
		}
		const value = sanitizeAnswer(originalValue);

		const player = this.playerTable[user.id];

		if (this.runEvent('AnySubmit', player, value, originalValue)) return;
		if (player.completed) return false;

		this.validatePlayer(player);
		player.lastGuess = Date.now();

		if (this.runEvent('Submit', player, value, originalValue)) return false;

		if (player.verifyAnswer(value)) {
			if (this.runEvent('CorrectAnswer', player, value)) return;
			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 {
			if (this.runEvent('IncorrectAnswer', player, value)) return;
			throw new Chat.ErrorMessage("That is not the answer - try again!");
		}
	}

	getQuestion(question: number, showHints?: boolean) {
		const current = {
			question: this.questions[question - 1],
			number: question,
		};
		const finalHint = current.number === this.questions.length ? "Final " : "";

		return `|raw|<div class="ladder"><table style="width:100%;table-layout:fixed;"><tr>` +
			`<td style="width: 20%;"><strong style="white-space: nowrap">${finalHint}Hint #${current.number}:</strong></td>` +
			`<td><div style="overflow:auto; max-width: 100vw; max-height: 50vh">${
				this.formatOutput(current.question.hint) +
				(showHints && current.question.spoilers.length ?
					`<details><summary>Extra Hints:</summary>${
						current.question.spoilers.map(p => `- ${p}`).join('<br />')
					}</details>` :
					``)
			}</div></td>` +
			`</tr></table></div>`;
	}

	onSendQuestion(user: User | ScavengerHuntPlayer, showHints?: boolean) {
		if (!(user.id in this.playerTable) || this.hosts.some(h => h.id === user.id)) return false;

		const player = this.playerTable[user.id];
		if (player.completed) return false;

		if (this.runEvent('SendQuestion', player, showHints)) return;

		const questionDisplay = this.getQuestion(player.getCurrentQuestion().number, showHints);

		player.sendRoom(questionDisplay);
		return true;
	}

	onViewHunt(user: User) {
		if (this.runEvent('ViewHunt', user)) return;

		let qLimit = 1;
		if (this.hosts.some(h => h.id === user.id) || user.id === this.staffHostId)	{
			qLimit = this.questions.length + 1;
		} else if (user.id in this.playerTable) {
			const player = this.playerTable[user.id];
			qLimit = player.currentQuestion + 1;
		}

		user.sendTo(
			this.room,
			`|raw|<div class="ladder"><table style="width: 100%;table-layout:fixed;">` +
			`<tr><th style="width: 10%;">#</th><th style="width: 70%;">Hint</th><th style="width: 20%;">Answer</th></tr>` +
			this.questions.slice(0, qLimit).map((q, i) => (
				`<tr><td>${
					i + 1
				}</td><td><div style="overflow:auto; max-width: 100vw; max-height: 30vh">${
					this.formatOutput(q.hint) +
					(q.spoilers.length ?
						`<details><summary>Extra Hints:</summary>${
							q.spoilers.map(s => `- ${s}`).join('<br />')
						}</details>` :
						``)
				}</div></td><td>${
					i + 1 >= qLimit ?
						`` :
						Utils.escapeHTMLForceWrap(q.answer.join(' ; '))
				}</td></tr>`
			)).join("") +
			`</table><div>`
		);
	}

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

		const now = Date.now();
		const time = Chat.toDurationString(now - this.startTime, { hhmmss: true });
		const canBlitz = this.completed.length < 3;

		const blitz = now - this.startTime <= 60000 && canBlitz &&
			(this.room.settings.scavSettings?.blitzPoints?.[this.gameType] || DEFAULT_BLITZ_POINTS[this.gameType]);

		player.completed = true;
		let result = this.runEvent('Complete', player, time, blitz);
		if (result === true) return;
		result = result || { name: player.name, time, blitz };
		this.completed.push(result);
		const place = Utils.formatOrder(this.completed.length);

		const completionMessage = this.runEvent('ConfirmCompletion', player, time, blitz, place, result);
		this.announce(
			completionMessage ||
			Utils.html`<em>${result.name}</em> has finished the hunt in ${place} place! (${time}${(blitz ? " - BLITZ" : "")})`
		);

		player.destroy(); // remove from user.games;
	}

	onShowEndBoard(endedBy?: User) {
		const sliceIndex = this.gameType === 'official' ? 5 : 3;
		const hosts = Chat.toListString(this.hosts.map(h => `<em>${Utils.escapeHTML(h.name)}</em>`));

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

	onEnd(reset?: boolean, endedBy?: User) {
		if (!endedBy && (this.preCompleted ? this.preCompleted.length : this.completed.length) === 0) {
			reset = true;
		}

		this.runEvent('End', reset);
		if (!ScavengerHuntDatabase.isEmpty() && this.room.settings.scavSettings?.addRecycledHuntsToQueueAutomatically) {
			if (!this.room.settings.scavQueue) this.room.settings.scavQueue = [];

			const next = ScavengerHuntDatabase.getRecycledHuntFromDatabase();
			const correctlyFormattedQuestions = next.questions.flatMap((question: AnyObject) => [question.text, question.answers]);
			this.room.settings.scavQueue.push({
				hosts: next.hosts,
				questions: correctlyFormattedQuestions,
				staffHostId: 'scavengermanager',
				isHTML: next.isHTML,
				staffHostName: 'Scavenger Manager',
				gameType: 'unrated',
			});
		}
		if (!reset) {
			// Display the finishers' board
			if (!this.runEvent('ShowEndBoard', endedBy)) this.onShowEndBoard(endedBy);

			// give points for winning and blitzes in official games
			if (!this.runEvent('GivePoints')) {
				const winPoints = this.room.settings.scavSettings?.winPoints?.[this.gameType] ||
					DEFAULT_POINTS[this.gameType];
				const blitzPoints = this.room.settings.scavSettings?.blitzPoints?.[this.gameType] ||
					DEFAULT_BLITZ_POINTS[this.gameType];
				// only regular hunts give host points
				let hostPoints;
				if (this.gameType === 'regular') {
					hostPoints = this.room.settings.scavSettings?.hostPoints ?
						this.room.settings.scavSettings?.hostPoints :
						DEFAULT_HOST_POINTS;
				}

				let didSomething = false;
				if (winPoints || blitzPoints) {
					for (const [i, completed] of this.completed.entries()) {
						if (!completed.blitz && i >= winPoints.length) break; // there won't be any more need to keep going
						const name = completed.name;
						if (winPoints[i]) Leaderboard.addPoints(name, 'points', winPoints[i]);
						if (blitzPoints && completed.blitz) Leaderboard.addPoints(name, 'points', blitzPoints);
					}
					didSomething = true;
				}
				if (hostPoints) {
					if (this.hosts.length === 1) {
						Leaderboard.addPoints(this.hosts[0].name, 'points', hostPoints, this.hosts[0].noUpdate);
						didSomething = true;
					} else {
						this.room.sendMods('|notify|A scavenger hunt with multiple hosts needs points!');
						this.room.sendMods('(A scavenger hunt with multiple hosts has ended.)');
					}
				}
				if (didSomething) Leaderboard.write();
			}

			this.onTallyLeaderboard();

			this.tryRunQueue(this.room.roomid);
		} 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.roomid);
		}
		this.runEvent('AfterEnd', reset);
		this.destroy();
	}

	onTallyLeaderboard() {
		// update player leaderboard with the statistics
		for (const p in this.playerTable) {
			const player = this.playerTable[p];
			PlayerLeaderboard.addPoints(player.name, 'join', 1);
			if (player.completed) PlayerLeaderboard.addPoints(player.name, 'finish', 1);
		}
		for (const id in this.leftHunt) {
			if (id in this.playerTable) continue; // this should never happen, but just in case;

			PlayerLeaderboard.addPoints(id, 'join', 1, true);
		}
		if (this.gameType !== 'practice') {
			for (const host of this.hosts) {
				HostLeaderboard.addPoints(host.name, 'points', 1, host.noUpdate).write();
			}
		}
		PlayerLeaderboard.write();
	}

	tryRunQueue(roomid: RoomID) {
		if (this.room.scavgame || this.room.settings.scavSettings?.scavQueueDisabled) {
			return; // don't run the queue for child games
		}
		// prepare the next queue'd game
		if (this.room.settings.scavQueue?.length) {
			setTimeout(() => {
				const room = Rooms.get(roomid) as ChatRoom;
				if (!room || room.game || !room.settings.scavQueue?.length || room.settings.scavSettings?.scavQueueDisabled) return;

				const next = room.settings.scavQueue.shift()!;
				const duration = room.settings.scavSettings?.defaultScavTimer || DEFAULT_TIMER_DURATION;
				room.game = new ScavengerHunt(
					{
						room,
						staffHost: { id: next.staffHostId, name: next.staffHostName },
						hosts: next.hosts,
						gameType: next.gameType,
						questions: next.questions,
						isHTML: next.isHTML,
					}
				);
				const game = room.getGame(ScavengerHunt);
				if (game) {
					game.setTimer(duration); // auto timer for queue'd games.
					room.add(`|c|~|[ScavengerManager] A scavenger hunt by ${Chat.toListString(next.hosts.map(h => h.name))} has been automatically started. It will automatically end in ${duration} minutes.`).update(); // highlight the users with "hunt by"
					room.modlog({ action: 'SCAV NEW', note: `${game.gameType.toUpperCase()}: creators - ${game.hosts.map(h => h.id)}` });
				}

				// update the saved queue.
				room.saveSettings();
			}, 2 * 60000); // 2 minute cooldown
		}
	}

	// modify destroy to get rid of any timers in the current roomgame.
	destroy() {
		if (this.timer) {
			clearTimeout(this.timer);
		}
		for (const i in this.playerTable) {
			this.playerTable[i].destroy();
		}
		// destroy this game
		this.room.game = null;
	}

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

	validatePlayer(player: ScavengerHuntPlayer) {
		if (player.infracted) return false;
		if (this.hosts.some(h => h.id === player.id) || player.id === this.staffHostId) {
			// someone joining on an alt then going back to their original userid
			player.sendRoom("You have been caught for doing your own hunt; staff has been notified.");

			// notify staff
			const staffMsg = `(${player.name} has been caught trying to do their own hunt.)`;
			this.room.sendMods(staffMsg);
			this.room.roomlog(staffMsg);
			this.room.modlog({
				action: 'SCAV CHEATER',
				userid: player.id,
				note: 'caught trying to do their own hunt',
			});

			PlayerLeaderboard.addPoints(player.name, 'infraction', 1);
			player.infracted = true;
		}

		const uniqueConnections = this.getUniqueConnections(player.id);
		if (uniqueConnections > 1 && this.room.settings.scavSettings?.scavmod?.ipcheck) {
			// multiple users on one alt
			player.sendRoom("You have been caught for attempting a hunt with multiple connections on your account.  Staff has been notified.");

			// notify staff
			const staffMsg = `(${player.name} has been caught attempting a hunt with ${uniqueConnections} connections on the account. The user has also been given 1 infraction point on the player leaderboard.)`;

			this.room.sendMods(staffMsg);
			this.room.roomlog(staffMsg);
			this.room.modlog({
				action: 'SCAV CHEATER',
				userid: player.id,
				note: `caught attempting a hunt with ${uniqueConnections} connections on the account; has also been given 1 infraction point on the player leaderboard`,
			});

			PlayerLeaderboard.addPoints(player.name, 'infraction', 1);
			player.infracted = true;
		}
	}

	eliminate(userid: string) {
		if (!(userid in this.playerTable)) return false;
		const player = this.playerTable[userid];

		// do not remove players that have completed - they should still get to see the answers
		if (player.completed) return true;

		this.removePlayer(player);
		return true;
	}

	onUpdateConnection() {}

	onChatMessage(msg: string) {
		let msgId = toID(msg) as string;

		// identify if there is a bot/dt command that failed
		// remove it and then match the rest of the post for leaks.
		const commandMatch = ACCIDENTAL_LEAKS.exec(msg);
		if (commandMatch) msgId = msgId.slice(toID(commandMatch[0]).length);

		const filtered = this.questions.some(q => q.answer.some(a => {
			a = toID(a);
			const md = Math.ceil((a.length - 5) / FILTER_LENIENCY);
			if (Utils.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.";
	}

	hasFinished(user: User) {
		return this.playerTable[user.id]?.completed;
	}

	getUniqueConnections(userid: string) {
		const user = Users.get(userid);
		if (!user) return 1;

		const ips = user.connections.map(c => c.ip);
		return ips.filter((ip, index) => ips.indexOf(ip) === index).length;
	}

	static parseHosts(hostArray: string[], room: Room, allowOffline?: boolean) {
		const hosts = [];
		for (const u of hostArray) {
			const id = toID(u);
			const user = Users.getExact(id);
			if (!allowOffline && (!user?.connected || !(user.id in room.users))) continue;

			if (!user) {
				// simply stick the ID's in there - don't keep any benign symbols passed by the hunt maker
				hosts.push({ name: id, id, noUpdate: true });
				continue;
			}

			hosts.push({ id: `${user.id}`, name: `${user.name}` });
		}
		return hosts;
	}

	static parseQuestions(questionArray: string[], force = false): AnyObject {
		if (questionArray.length % 2 === 1 && !force) return { err: "Your final question is missing an answer" };
		if (questionArray.length < 6 && !force) return { err: "You must have at least 3 hints and answers" };

		const formattedQuestions = [];

		for (let [i, question] of questionArray.entries()) {
			if (i % 2) {
				const answers = question.split(';').map(p => p.trim());
				formattedQuestions[i] = answers;
				if (!force && (!answers.length || answers.some(a => !toID(a)))) {
					return { err: "Empty answer - only alphanumeric characters will count in answers." };
				}
			} else {
				// Skip last question if there is no answer
				if (i + 1 === questionArray.length) continue;

				question = question.trim();
				formattedQuestions[i] = question;
				if (!question && !force) return { err: "Empty question." };
			}
		}

		return { result: formattedQuestions };
	}
}

export class ScavengerHuntPlayer extends Rooms.RoomGamePlayer<ScavengerHunt> {
	lastGuess: number;
	completed: boolean;
	joinIps: string[];
	currentQuestion: number;

	[k: string]: any; // for purposes of adding new temporary properties for the purpose of twists.
	constructor(user: User, game: ScavengerHunt) {
		super(user, game);

		this.joinIps = user.ips.slice();

		this.currentQuestion = 0;
		this.completed = false;
		this.lastGuess = 0;
	}

	getCurrentQuestion() {
		return {
			question: this.game.questions[this.currentQuestion],
			number: this.currentQuestion + 1,
		};
	}

	verifyAnswer(value: string) {
		const answer = this.getCurrentQuestion().question.answer;
		value = sanitizeAnswer(value);

		return answer.some((a: string) => sanitizeAnswer(a) === value);
	}

	onNotifyChange(num: number) {
		this.game.runEvent('NotifyChange', this, num);
		if (num === this.currentQuestion) {
			this.sendRoom(`|raw|<div style="overflow:scroll; max-height: 50vh"><strong>The hint has been changed to:</strong> ${this.game.formatOutput(this.game.questions[num].hint)}</div>`);
		}
	}

	destroy() {
		const user = Users.getExact(this.id);
		if (user) {
			user.games.delete(this.game.roomid);
			user.updateSearch();
		}
	}
}

const ScavengerCommands: Chat.ChatCommands = {
	/**
	 * Player commands
	 */
	""() {
		return this.parse("/join scavengers");
	},

	guess(target, room, user) {
		return this.parse(`/choose ${target}`);
	},

	join(target, room, user) {
		room = this.requireRoom();
		const game = room.getGame(ScavengerHunt);
		if (!game) return this.errorReply("There is no scavenger hunt currently running.");
		this.checkChat();

		game.joinGame(user);
	},

	leave(target, room, user) {
		room = this.requireRoom();
		const game = room.getGame(ScavengerHunt);
		if (!game) return this.errorReply("There is no scavenger hunt currently running.");
		game.leaveGame(user);
	},

	/**
	 * Scavenger Games
	 * --------------
	 * Individual game commands for each Scavenger Game
	 */
	game: 'games',
	games: {
		/**
		 * General game commands
		 */
		create: 'start',
		new: 'start',
		start(target, room, user) {
			room = this.requireRoom();
			this.checkCan('mute', null, room);
			if (room.scavgame) return this.errorReply('There is already a scavenger game running.');
			if (room.getGame(ScavengerHunt)) {
				return this.errorReply('You cannot start a scavenger game where there is already a scavenger hunt in the room.');
			}

			target = toID(target);
			const game = ScavMods.LoadGame(room, target);

			if (!game) return this.errorReply('Invalid game mode.');

			room.scavgame = game;

			this.privateModAction(`A ${game.name} has been created by ${user.name}.`);
			this.modlog('SCAVENGER', null, 'ended the scavenger game');

			game.announce(`A game of ${game.name} has been started!`);
		},

		end(target, room, user) {
			room = this.requireRoom();
			this.checkCan('mute', null, room);
			if (!room.scavgame) return this.errorReply(`There is no scavenger game currently running.`);

			this.privateModAction(`The ${room.scavgame.name} has been forcibly ended by ${user.name}.`);
			this.modlog('SCAVENGER', null, 'ended the scavenger game');
			room.scavgame.announce(`The ${room.scavgame.name} has been forcibly ended.`);
			room.scavgame.destroy(true);
		},

		kick(target, room, user) {
			room = this.requireRoom();
			this.checkCan('mute', null, room);
			if (!room.scavgame) return this.errorReply(`There is no scavenger game currently running.`);

			const targetId = toID(target);
			if (targetId === 'constructor' || !targetId) return this.errorReply("Invalid player.");

			const success = room.scavgame.eliminate(targetId);
			if (success) {
				this.addModAction(`User '${targetId}' has been kicked from the ${room.scavgame.name}.`);
				this.modlog('SCAVENGERS', target, `kicked from the ${room.scavgame.name}`);
				const game = room.getGame(ScavengerHunt);
				if (game) {
					game.eliminate(targetId); // remove player from current hunt as well.
				}
			} else {
				this.errorReply(`Unable to kick user '${targetId}'.`);
			}
		},

		points: 'leaderboard',
		score: 'leaderboard',
		scoreboard: 'leaderboard',
		async leaderboard(target, room, user) {
			room = this.requireRoom();
			if (!room.scavgame) return this.errorReply(`There is no scavenger game currently running.`);
			if (!room.scavgame.leaderboard) return this.errorReply("This scavenger game does not have a leaderboard.");
			if (!this.runBroadcast()) return false;

			const html = await room.scavgame.leaderboard.htmlLadder();
			this.sendReply(`|raw|${html}`);
		},

		async rank(target, room, user) {
			room = this.requireRoom();
			if (!room.scavgame) return this.errorReply(`There is no scavenger game currently running.`);
			if (!room.scavgame.leaderboard) return this.errorReply("This scavenger game does not have a leaderboard.");
			if (!this.runBroadcast()) return false;

			const targetId = toID(target) || user.id;

			const rank = await room.scavgame.leaderboard.visualize('points', targetId) as AnyObject;

			if (!rank) {
				this.sendReplyBox(`User '${targetId}' does not have any points on the scavenger games leaderboard.`);
			} else {
				this.sendReplyBox(Utils.html`User '${rank.name}' is #${rank.rank} on the scavenger games leaderboard with ${rank.points} points.`);
			}
		},
	},
	teamscavs: {
		addteam: 'createteam',
		createteam(target, room, user) {
			room = this.requireRoom();
			this.checkCan('mute', null, room);
			// if (room.getGame(ScavengerHunt)) return this.errorReply('Teams cannot be modified after the hunt starts.');

			const game = room.scavgame;
			if (!game || game.id !== 'teamscavs') return this.errorReply('There is currently no game of Team Scavs going on.');

			let [teamName, leader] = target.split(',');
			teamName = teamName.trim();
			if (game.teams[teamName]) return this.errorReply(`The team ${teamName} already exists.`);

			const leaderUser = Users.get(leader);
			if (!leaderUser) return this.errorReply('The user you specified is currently not online');
			if (game.getPlayerTeam(leaderUser)) return this.errorReply('The user is already a member of another team.');

			game.teams[teamName] = { name: teamName, answers: [], players: [leaderUser.id], question: 1, completed: false };
			game.announce(Utils.html`A new team "${teamName}" has been created with ${leaderUser.name} as the leader.`);
		},

		deleteteam: 'removeteam',
		removeteam(target, room, user) {
			room = this.requireRoom();
			this.checkCan('mute', null, room);
			// if (room.getGame(ScavengerHunt)) return this.errorReply('Teams cannot be modified after the hunt starts.');

			const game = room.scavgame;
			if (!game || game.id !== 'teamscavs') return this.errorReply('There is currently no game of Team Scavs going on.');

			if (!game.teams[target]) return this.errorReply(`The team ${target} does not exist.`);

			delete game.teams[target];
			game.announce(Utils.html`The team "${target}" has been removed.`);
		},

		addplayer(target, room, user) {
			room = this.requireRoom();
			const game = room.scavgame;
			if (!game || game.id !== 'teamscavs') return this.errorReply('There is currently no game of Team Scavs going on.');
			// if (room.getGame(ScavengerHunt)) return this.errorReply('Teams cannot be modified after the hunt starts.');

			let userTeam;

			for (const teamID in game.teams) {
				const team = game.teams[teamID];
				if (team.players[0] === user.id) {
					userTeam = team;
					break;
				}
			}
			if (!userTeam) return this.errorReply('You must be the leader of a team to add people into the team.');

			const targetUsers = target.split(',').map(id => Users.getExact(id)).filter(u => u?.connected) as User[];
			if (!targetUsers.length) return this.errorReply('Please select a user that is currently online.');

			const errors = [];
			for (const targetUser of targetUsers) {
				if (game.getPlayerTeam(targetUser)) errors.push(`${targetUser.name} is already in a team.`);
			}
			if (errors.length) return this.sendReplyBox(errors.join('<br />'));

			const playerIDs = targetUsers.map(u => u.id);
			userTeam.players.push(...playerIDs);

			for (const targetUser of targetUsers) {
				targetUser.sendTo(room, `You have joined ${userTeam.name}.`);
			}
			game.announce(Utils.html`${Chat.toListString(targetUsers.map(u => u.name))} ${targetUsers.length > 1 ? 'have' : 'has'} been added into ${userTeam.name}.`);
		},

		editplayers(target, room, user) {
			room = this.requireRoom();
			const game = room.scavgame;
			if (!game || game.id !== 'teamscavs') return this.errorReply('There is currently no game of Team Scavs going on.');
			this.checkCan('mute', null, room);
			// if (room.getGame(ScavengerHunt)) return this.errorReply('Teams cannot be modified after the hunt starts.');

			const parts = target.split(',');
			const teamName = parts[0].trim();
			const playerchanges = parts.slice(1);

			const team = game.teams[teamName];

			if (!team) return this.errorReply('Invalid team.');

			for (const entry of playerchanges) {
				const userid = toID(entry);
				if (entry.trim().startsWith('-')) {
					// remove from the team
					if (!team.players.includes(userid)) {
						this.errorReply(`User "${userid}" is not in team "${team.name}."`);
						continue;
					} else if (team.players[0] === userid) {
						this.errorReply(`You cannot remove "${userid}", who is the leader of "${team.name}".`);
						continue;
					}
					team.players = team.players.filter((u: string) => u !== userid);
					game.announce(`${userid} was removed from "${team.name}."`);
				} else {
					const targetUser = Users.getExact(userid);
					if (!targetUser?.connected) {
						this.errorReply(`User "${userid}" is not currently online.`);
						continue;
					}

					const targetUserTeam = game.getPlayerTeam(targetUser);
					if (team.players.includes(userid)) {
						this.errorReply(`User "${userid}" is already part of "${team.name}."`);
						continue;
					} else if (targetUserTeam) {
						this.errorReply(`User "${userid}" is already part of another team - "${targetUserTeam.name}".`);
						continue;
					}
					team.players.push(userid);
					game.announce(`${targetUser.name} was added to "${team.name}."`);
				}
			}
		},

		teams(target, room, user) {
			if (!this.runBroadcast()) return false;
			room = this.requireRoom();

			const game = room.scavgame;
			if (!game || game.id !== 'teamscavs') return this.errorReply('There is currently no game of Team Scavs going on.');

			const display = [];
			for (const teamID in game.teams) {
				const team = game.teams[teamID];
				display.push(Utils.html`<strong>${team.name}</strong> - <strong>${team.players[0]}</strong>${team.players.length > 1 ? ', ' + team.players.slice(1).join(', ') : ''}`);
			}

			this.sendReplyBox(display.join('<br />'));
		},

		guesses(target, room, user) {
			room = this.requireRoom();
			const game = room.scavgame;
			if (!game || game.id !== 'teamscavs') return this.errorReply('There is currently no game of Team Scavs going on.');

			const team = game.getPlayerTeam(user);
			if (!team) return this.errorReply('You are not currently part of this Team Scavs game.');

			this.sendReplyBox(Utils.html`<strong>Question #${team.question} guesses:</strong> ${team.answers.sort().join(', ')}`);
		},

		chat: 'note',
		note(target, room, user) {
			room = this.requireRoom();
			const game = room.scavgame;
			if (!game || game.id !== 'teamscavs') return this.errorReply('There is currently no game of Team Scavs going on.');

			const team = game.getPlayerTeam(user);
			if (!team) return this.errorReply('You are not currently part of this Team Scavs game.');

			if (!target) return this.errorReply('Please include a message as the note.');

			game.teamAnnounce(user, Utils.html`<strong> Note from ${user.name}:</strong> ${target}`);
		},
	},
	teamscavshelp: [
		'/tscav createteam [team name], [leader name] - creates a new team for the current Team Scavs game. (Requires: % @ * # ~)',
		'/tscav deleteteam [team name] - deletes an existing team for the current Team Scavs game. (Requires: % @ * # ~)',
		'/tscav addplayer [user] - allows a team leader to add a player onto their team.',
		'/tscav editplayers [team name], [added user | -removed user], [...] (use - preceding a user\'s name to remove a user) - Edits the players within an existing team. (Requires: % @ * # ~)',
		'/tscav teams - views the list of teams and the players on each team.',
		'/tscav guesses - views the list of guesses already submitted by your team for the current question.',
		'/tscav chat [message] - adds a message that can be seen by all of your teammates in the Team Scavs game.',
	],

	/**
	 * Creation / Moderation commands
	 */
	createtwist: 'create',
	createtwistofficial: 'create',
	createtwistmini: 'create',
	createtwistpractice: 'create',
	createtwistunrated: 'create',
	createpractice: 'create',
	createofficial: 'create',
	createunrated: 'create',
	createmini: 'create',
	forcecreate: 'create',
	forcecreateunrated: 'create',
	createrecycled: 'create',

	createhtmltwist: 'create',
	createhtmltwistofficial: 'create',
	createhtmltwistmini: 'create',
	createhtmltwistpractice: 'create',
	createhtmltwistunrated: 'create',
	createhtmlpractice: 'create',
	createhtmlofficial: 'create',
	createhtmlunrated: 'create',
	createhtmlmini: 'create',
	forcecreatehtml: 'create',
	forcecreatehtmlunrated: 'create',
	createhtmlrecycled: 'create',
	createhtml: 'create',
	create(target, room, user, connection, cmd) {
		room = this.requireRoom();
		if (!getScavsRoom(room)) {
			return this.errorReply("Scavenger hunts can only be created in the scavengers room.");
		}
		this.checkCan('mute', null, room);
		if (room.game) return this.errorReply(`There is already a game in this room - ${room.game.title}.`);
		let gameType = 'regular' as GameTypes;
		if (cmd.includes('practice')) {
			gameType = 'practice';
		} else if (cmd.includes('official')) {
			gameType = 'official';
		} else if (cmd.includes('mini')) {
			gameType = 'mini';
		} else if (cmd.includes('unrated')) {
			gameType = 'unrated';
		} else if (cmd.includes('recycled')) {
			gameType = 'recycled';
		}

		const isHTML = cmd.includes('html');

		let mod;
		let questions = target;

		if (cmd.includes('twist')) {
			const twistparts = target.split('|');
			questions = twistparts.slice(1).join('|');
			mod = twistparts[0].split(',');
		}

		// mini and officials can be started anytime
		if (
			!cmd.includes('force') && ['regular', 'unrated', 'recycled'].includes(gameType) && !mod &&
			room.settings.scavQueue?.length && !room.scavgame
		) {
			return this.errorReply(`There are currently hunts in the queue! If you would like to start the hunt anyways, use /forcestart${gameType === 'regular' ? 'hunt' : gameType}.`);
		}

		if (gameType === 'recycled') {
			if (ScavengerHuntDatabase.isEmpty()) {
				return this.errorReply("There are no hunts in the database.");
			}

			let hunt;
			if (questions) {
				const huntNumber = parseInt(questions);
				if (!ScavengerHuntDatabase.hasHunt(huntNumber)) return this.errorReply("You specified an invalid hunt number.");
				hunt = scavengersData.recycledHunts[huntNumber - 1];
			} else {
				hunt = ScavengerHuntDatabase.getRecycledHuntFromDatabase();
			}

			questions = ScavengerHuntDatabase.getFullTextOfHunt(hunt);
		}

		let [hostsArray, ...params] = questions.split('|');
		// A recycled hunt should list both its original creator and the staff who started it as its host.
		if (gameType === 'recycled') {
			hostsArray += `,${user.name}`;
		}
		const hosts = ScavengerHunt.parseHosts(
			hostsArray.split(/[,;]/),
			room,
			gameType === 'official' || gameType === 'recycled'
		);
		if (!hosts.length) {
			return this.errorReply("The user(s) you specified as the host is not online, or is not in the room.");
		}

		const res = ScavengerHunt.parseQuestions(params);
		if (res.err) return this.errorReply(res.err);

		room.game = new ScavengerHunt({
			room,
			staffHost: user,
			hosts,
			gameType,
			questions: res.result,
			isHTML,
			mod,
		});

		this.privateModAction(`A new scavenger hunt was created by ${user.name}.`);
		this.modlog('SCAV NEW', null, `${gameType.toUpperCase()}: creators - ${hosts.map(h => h.id)}`);
	},

	status(target, room, user) {
		room = this.requireRoom();
		const game = room.getGame(ScavengerHunt);
		if (!game) return this.errorReply(`There is no scavenger hunt currently running.`);

		const elapsedMsg = Chat.toDurationString(Date.now() - game.startTime, { hhmmss: true });
		const gameTypeMsg = game.gameType ? `<em>${game.gameType}</em> ` : '';
		const hostersMsg = Utils.escapeHTML(Chat.toListString(game.hosts.map(h => h.name)));
		const hostMsg = game.hosts.some(h => h.id === game.staffHostId) ?
			'' : Utils.html` (started by - ${game.staffHostName})`;
		const finishers = Utils.html`${game.completed.map(u => u.name).join(', ')}`;
		let buffer = `<div class="infobox" style="margin-top: 0px;">The current ${gameTypeMsg}scavenger hunt by <em>${hostersMsg}${hostMsg}</em> has been up for: ${elapsedMsg}<br />${!game.timerEnd ? 'The timer is currently off.' : `The hunt ends in: ${Chat.toDurationString(game.timerEnd - Date.now(), { hhmmss: true })}`}<br />Completed (${game.completed.length}): ${finishers}</div>`;
		if (game.modsList.includes('timetrial')) {
			const finisher = game.completed.find(player => player.id === user.id);
			const timeTrialMsg = finisher ?
				`You finished the hunt in: ${finisher.time}.` :
				(game.startTimes?.[user.id] ?
					`You joined the hunt ${Chat.toDurationString(Date.now() - game.startTimes[user.id], { hhmmss: true })} ago.` :
					'You have not joined the hunt.');
			buffer = `<div class="infobox" style="margin-top: 0px;">The current ${gameTypeMsg}scavenger hunt by <em>${hostersMsg}${hostMsg}</em> has been up for: ${elapsedMsg}<br />${timeTrialMsg}<br />${!game.timerEnd ? 'The timer is currently off.' : `The hunt ends in: ${Chat.toDurationString(game.timerEnd - Date.now(), { hhmmss: true })}`}<br />Completed (${game.completed.length}): ${finishers}</div>`;
		}

		if (game.hosts.some(h => h.id === user.id) || game.staffHostId === user.id) {
			let str = `<div class="ladder" style="overflow-y: scroll; max-height: 300px;"><table style="width: 100%"><th><b>Question</b></th><th><b>Users on this Question</b></th>`;
			for (let i = 0; i < game.questions.length; i++) {
				const questionNum = i + 1;
				const players = Object.values(game.playerTable).filter(player => player.currentQuestion === i && !player.completed);
				if (!players.length) {
					str += `<tr><td>${questionNum}</td><td>None</td>`;
				} else {
					str += `<tr><td>${questionNum}</td><td>`;
					str += players.map(
						pl => pl.lastGuess > Date.now() - 1000 * 300 ?
							Utils.html`<strong>${pl.name}</strong>` :
							Utils.escapeHTML(pl.name)
					).join(", ");
				}
			}
			const completed: AnyObject[] = game.preCompleted ? game.preCompleted : game.completed;
			str += Utils.html`<tr><td>Completed</td><td>${completed.length ? completed.map(pl => pl.name).join(", ") : 'None'}`;
			return this.sendReply(`|raw|${str}</table></div>${buffer}`);
		}
		this.sendReply(`|raw|${buffer}`);
	},

	hint(target, room, user) {
		room = this.requireRoom();
		const game = room.getGame(ScavengerHunt);
		if (!game) return this.errorReply(`There is no scavenger hunt currently running.`);
		if (!game.onSendQuestion(user, true)) this.errorReply("You are not currently participating in the hunt.");
	},

	timer(target, room, user) {
		room = this.requireRoom();
		this.checkCan('mute', null, room);
		const game = room.getGame(ScavengerHunt);
		if (!game) return this.errorReply(`There is no scavenger hunt currently running.`);

		const minutes = (toID(target) === 'off' ? 0 : parseFloat(target));
		if (isNaN(minutes) || minutes < 0 || (minutes * 60 * 1000) > Chat.MAX_TIMEOUT_DURATION) {
			throw new Chat.ErrorMessage(`You must specify a timer length that is a postive number.`);
		}

		const result = game.setTimer(minutes);
		const message = `The scavenger timer has been ${(result === 'off' ? "turned off" : `set to ${result} minutes`)}`;

		room.add(message + '.');
		this.privateModAction(`${message} by ${user.name}.`);
		this.modlog('SCAV TIMER', null, (result === 'off' ? 'OFF' : `${result} minutes`));
	},

	inherit(target, room, user) {
		room = this.requireRoom();
		this.checkCan('mute', null, room);
		const game = room.getGame(ScavengerHunt);
		if (!game) return this.errorReply(`There is no scavenger hunt currently running.`);

		if (game.staffHostId === user.id) return this.errorReply('You already have staff permissions for this hunt.');

		game.staffHostId = `${user.id}`;
		game.staffHostName = `${user.name}`;

		// clear user's game progress and prevent user from ever entering again
		game.eliminate(user.id);
		game.cacheUserIps(user);

		this.privateModAction(`${user.name} has inherited staff permissions for the current hunt.`);
		this.modlog('SCAV INHERIT');
	},

	reset(target, room, user) {
		room = this.requireRoom();
		this.checkCan('mute', null, room);
		const game = room.getGame(ScavengerHunt);
		if (!game) return this.errorReply(`There is no scavenger hunt currently running.`);

		game.onEnd(true, user);
		this.privateModAction(`${user.name} has reset the scavenger hunt.`);
		this.modlog('SCAV RESET');
	},

	resettoqueue(target, room, user) {
		room = this.requireRoom();
		this.checkCan('mute', null, room);
		const game = room.getGame(ScavengerHunt);
		if (!game) return this.errorReply(`There is no scavenger hunt currently running.`);

		const hunt: QueuedHunt = {
			hosts: game.hosts,
			questions: [],
			isHTML: game.isHTML,
			staffHostId: game.staffHostId,
			staffHostName: game.StaffHostName,
			gameType: game.gameType,
		};
		for (const entry of game.questions) {
			hunt.questions.push(...[entry.hint, entry.answer]);
		}
		if (!room.settings.scavQueue) room.settings.scavQueue = [];
		room.settings.scavQueue.push(hunt);

		game.onEnd(true, user);
		this.privateModAction(`${user.name} has reset the scavenger hunt, and placed it in the queue.`);
		this.modlog('SCAV RESETTOQUEUE');
	},

	forceend: 'end',
	end(target, room, user) {
		room = this.requireRoom();
		this.checkCan('mute', null, room);
		if (!room.game && room.scavgame) return this.parse('/scav games end');
		const game = room.getGame(ScavengerHunt);
		if (!game) return this.errorReply(`There is no scavenger hunt currently running.`);

		const completed = game.preCompleted ? game.preCompleted : game.completed;

		if (!this.cmd.includes('force')) {
			if (!completed.length) {
				return this.errorReply('No one has finished the hunt yet.  Use /forceendhunt if you want to end the hunt and reveal the answers.');
			}
		} else if (completed.length) {
			return this.errorReply(`This hunt has ${Chat.count(completed, "finishers")}; use /endhunt`);
		}

		game.onEnd(false, user);
		this.privateModAction(`${user.name} has ended the scavenger hunt.`);
		this.modlog('SCAV END');
	},

	viewhunt(target, room, user) {
		room = this.requireRoom();
		const game = room.getGame(ScavengerHunt);
		if (!game) return this.errorReply(`There is no scavenger hunt currently running.`);

		if (!('onViewHunt' in game)) return this.errorReply('There is currently no hunt to be viewed.');

		game.onViewHunt(user);
	},

	edithunt(target, room, user) {
		room = this.requireRoom();
		const game = room.getGame(ScavengerHunt);
		if (!game) return this.errorReply(`There is no scavenger hunt currently running.`);
		if (
			(!game.hosts.some(h => h.id === user.id) || !user.can('show', null, room)) &&
			game.staffHostId !== user.id
		) {
			return this.errorReply("You cannot edit the hints and answers if you are not the host.");
		}

		const [question, type, ...value] = target.split(',');
		if (!game.onEditQuestion(parseInt(question), toID(type), value.join(',').trim())) {
			return this.sendReply("/scavengers edithunt [question number], [hint | answer], [value] - edits the current scavenger hunt.");
		}
	},

	addhint: 'spoiler',
	spoiler(target, room, user) {
		room = this.requireRoom();
		const game = room.getGame(ScavengerHunt);
		if (!game) return this.errorReply(`There is no scavenger hunt currently running.`);
		if (
			(!game.hosts.some(h => h.id === user.id) || !user.can('show', null, room)) &&
			game.staffHostId !== user.id
		) {
			return this.errorReply("You cannot add more hints if you are not the host.");
		}
		const parts = target.split(',');
		const question = parseInt(parts[0]) - 1;
		const hint = parts.slice(1).join(',');

		if (!game.questions[question]) return this.errorReply(`Invalid question number.`);
		if (!hint) return this.errorReply('The hint cannot be left empty.');
		game.questions[question].spoilers.push(hint);

		room.addByUser(user, `Question #${question + 1} hint - spoiler: ${hint}`);
		const playersOnQ = game.players.filter(player => player.currentQuestion === question && !player.completed);
		const notif = `|notify|Scavenger hint for Q${question + 1}`;
		for (const player of playersOnQ) {
			const playerObj = Users.get(player.id);
			if (!playerObj?.connected) continue;
			room.sendUser(playerObj, notif);
		}
	},

	deletehint: 'removehint',
	removehint(target, room, user) {
		room = this.requireRoom();
		const game = room.getGame(ScavengerHunt);
		if (!game) return this.errorReply(`There is no scavenger hunt currently running.`);
		if (
			(!game.hosts.some(h => h.id === user.id) || !user.can('show', null, room)) &&
			game.staffHostId !== user.id
		) {
			return this.errorReply("You cannot remove hints if you are not the host.");
		}

		const parts = target.split(',');
		const question = parseInt(parts[0]) - 1;
		const hint = parseInt(parts[1]) - 1;

		if (!game.questions[question]) return this.errorReply(`Invalid question number.`);
		if (!game.questions[question].spoilers[hint]) return this.errorReply('Invalid hint number.');
		game.questions[question].spoilers.splice(hint, 1);

		return this.sendReply("Hint has been removed.");
	},

	modifyhint: 'edithint',
	edithint(target, room, user) {
		room = this.requireRoom();
		const game = room.getGame(ScavengerHunt);
		if (!game) return this.errorReply(`There is no scavenger hunt currently running.`);
		if (
			(!game.hosts.some(h => h.id === user.id) || !user.can('show', null, room)) &&
			game.staffHostId !== user.id
		) {
			return this.errorReply("You cannot edit hints if you are not the host.");
		}

		const parts = target.split(',');
		const question = parseInt(parts[0]) - 1;
		const hint = parseInt(parts[1]) - 1;
		const value = parts.slice(2).join(',');

		if (!game.questions[question]) return this.errorReply(`Invalid question number.`);
		if (!game.questions[question].spoilers[hint]) return this.errorReply('Invalid hint number.');
		if (!value) return this.errorReply('The hint cannot be left empty.');
		game.questions[question].spoilers[hint] = value;

		room.addByUser(user, `Question #${question + 1} hint - spoiler: ${value}`);
		const playersOnQ = game.players.filter(player => player.currentQuestion === question && !player.completed);
		const notif = `|notify|Scavenger hint for Q${question + 1}`;
		for (const player of playersOnQ) {
			const playerObj = Users.get(player.id);
			if (!playerObj?.connected) continue;
			room.sendUser(playerObj, notif);
		}
		return this.sendReply("Hint has been modified.");
	},

	kick(target, room, user) {
		room = this.requireRoom();
		const game = room.getGame(ScavengerHunt);
		if (!game) return this.errorReply(`There is no scavenger hunt currently running.`);

		const targetId = toID(target);
		if (targetId === 'constructor' || !targetId) return this.errorReply("Invalid player.");

		const success = game.eliminate(targetId);
		if (success) {
			this.modlog('SCAV KICK', targetId);
			return this.privateModAction(`${user.name} has kicked '${targetId}' from the scavenger hunt.`);
		}
		this.errorReply(`Unable to kick '${targetId}' from the scavenger hunt.`);
	},

	/**
	 * Hunt queuing
	 */
	queueunrated: 'queue',
	queuerated: 'queue',
	queuerecycled: 'queue',
	queuehtmlunrated: 'queue',
	queuehtmlrated: 'queue',
	queuehtmlrecycled: 'queue',
	queuehtml: 'queue',
	forcequeueunrated: 'queue',
	forcequeuerated: 'queue',
	forcequeuerecycled: 'queue',
	forcequeuehtmlunrated: 'queue',
	forcequeuehtmlrated: 'queue',
	forcequeuehtmlrecycled: 'queue',
	forcequeuehtml: 'queue',
	forcequeue: 'queue',
	queue(target, room, user) {
		room = this.requireRoom();
		if (!getScavsRoom(room)) {
			return this.errorReply("This command can only be used in the scavengers room.");
		}
		if (!target && this.cmd !== 'queuerecycled') {
			if (this.cmd === 'queue') {
				this.runBroadcast();
				const commandHandler = ScavengerCommands.viewqueue as ChatHandler;
				commandHandler.call(this, target, room, user, this.connection, this.cmd, this.message);
				return;
			}
			return this.parse('/scavhelp staff');
		}

		const isHTML = this.cmd.includes('html');

		this.checkCan('mute', null, room);

		if (this.cmd === 'queuerecycled') {
			if (ScavengerHuntDatabase.isEmpty()) {
				return this.errorReply(`There are no hunts in the database.`);
			}
			if (!room.settings.scavQueue) {
				room.settings.scavQueue = [];
			}

			let next;
			if (target) {
				const huntNumber = parseInt(target);
				if (!ScavengerHuntDatabase.hasHunt(huntNumber)) return this.errorReply("You specified an invalid hunt number.");
				next = scavengersData.recycledHunts[huntNumber - 1];
			} else {
				next = ScavengerHuntDatabase.getRecycledHuntFromDatabase();
			}
			const correctlyFormattedQuestions = next.questions.flatMap((question: AnyObject) => [question.text, question.answers]);
			room.settings.scavQueue.push({
				hosts: next.hosts,
				questions: correctlyFormattedQuestions,
				isHTML,
				staffHostId: 'scavengermanager',
				staffHostName: 'Scavenger Manager',
				gameType: 'unrated',
			});
		} else {
			const [hostsArray, ...params] = target.split('|');
			const hosts = ScavengerHunt.parseHosts(hostsArray.split(/[,;]/), room);
			if (!hosts.length) {
				return this.errorReply("The user(s) you specified as the host is not online, or is not in the room.");
			}

			const results = ScavengerHunt.parseQuestions(params, this.cmd.includes('force'));
			if (results.err) return this.errorReply(results.err);

			if (!room.settings.scavQueue) room.settings.scavQueue = [];

			room.settings.scavQueue.push({
				hosts,
				questions: results.result,
				isHTML,
				staffHostId: user.id,
				staffHostName: user.name,
				gameType: (this.cmd.includes('unrated') ? 'unrated' : 'regular'),
			});
		}
		this.privateModAction(`${user.name} has added a scavenger hunt to the queue.`);

		room.saveSettings();
	},

	dequeue(target, room, user) {
		room = this.requireRoom();
		if (!getScavsRoom(room)) {
			return this.errorReply("This command can only be used in the scavengers room.");
		}
		this.checkCan('mute', null, room);
		const id = parseInt(target);

		// this command should be using the display to manage anyways, so no error message is needed
		if (!room.settings.scavQueue || isNaN(id) || id < 0 || id >= room.settings.scavQueue.length) return false;

		const removed = room.settings.scavQueue.splice(id, 1)[0];
		this.privateModAction(`${user.name} has removed a scavenger hunt created by [${removed.hosts.map(u => u.id).join(", ")}] from the queue.`);
		this.sendReply(`|uhtmlchange|scav-queue|${formatQueue(room.settings.scavQueue, user, room)}`);

		room.saveSettings();
	},

	viewqueue(target, room, user) {
		room = this.requireRoom();
		if (!getScavsRoom(room)) {
			return this.errorReply("This command can only be used in the scavengers room.");
		}
		if (!this.runBroadcast()) return false;

		this.sendReply(`|uhtml|scav-queue|${formatQueue(room.settings.scavQueue, user, room, this.broadcasting)}`);
	},

	next(target, room, user) {
		room = this.requireRoom();
		if (!getScavsRoom(room)) {
			return this.errorReply("This command can only be used in the scavengers room.");
		}
		this.checkCan('mute', null, room);

		if (!room.settings.scavQueue?.length) {
			return this.errorReply("The scavenger hunt queue is currently empty.");
		}
		if (room.game) return this.errorReply(`There is already a game in this room - ${room.game.title}.`);

		const huntId = parseInt(target) || 0;

		if (!room.settings.scavQueue[huntId]) return false; // no need for an error reply - this is done via UI anyways

		const next = room.settings.scavQueue.splice(huntId, 1)[0];
		room.game = new ScavengerHunt(
			{
				room,
				staffHost: { id: next.staffHostId, name: next.staffHostName },
				hosts: next.hosts,
				gameType: next.gameType,
				questions: next.questions,
				isHTML: next.isHTML,
			}
		);

		if (huntId) this.sendReply(`|uhtmlchange|scav-queue|${formatQueue(room.settings.scavQueue, user, room)}`);
		this.modlog('SCAV NEW', null, `from queue: creators - ${next.hosts.map(h => h.id)}`);

		// update the saved queue.
		room.saveSettings();
	},

	enablequeue: 'disablequeue',
	disablequeue(target, room, user) {
		room = this.requireRoom();
		if (!getScavsRoom(room)) {
			return this.errorReply("This command can only be used in the scavengers room.");
		}
		this.checkCan('mute', null, room);

		if (!room.settings.scavSettings) room.settings.scavSettings = {};
		const state = this.cmd === 'disablequeue';
		if ((room.settings.scavSettings.scavQueueDisabled || false) === state) {
			return this.errorReply(`The queue is already ${state ? 'disabled' : 'enabled'}.`);
		}

		room.settings.scavSettings.scavQueueDisabled = state;
		room.saveSettings();

		this.sendReply(`|uhtmlchange|scav-queue|${formatQueue(room.settings.scavQueue, user, room)}`);
		this.privateModAction(`The queue has been ${state ? 'disabled' : 'enabled'} by ${user.name}.`);
		this.modlog('SCAV QUEUE', null, (state ? 'disabled' : 'enabled'));
	},

	defaulttimer(target, room, user) {
		room = this.requireRoom();
		if (!getScavsRoom(room)) {
			return this.errorReply("This command can only be used in the scavengers room.");
		}
		this.checkCan('declare', null, room);

		if (!room.settings.scavSettings) room.settings.scavSettings = {};
		if (!target) {
			const duration_string = room.settings.scavSettings.defaultScavTimer || DEFAULT_TIMER_DURATION;
			return this.sendReply(`The default scavenger timer is currently set at: ${duration_string} minutes.`);
		}
		const duration = parseInt(target);

		if (!duration || duration < 0) {
			return this.errorReply('The default timer must be an integer greater than zero, in minutes.');
		}

		room.settings.scavSettings.defaultScavTimer = duration;
		room.saveSettings();
		this.privateModAction(`The default scavenger timer has been set to ${duration} minutes by ${user.name}.`);
		this.modlog('SCAV DEFAULT TIMER', null, `${duration} minutes`);
	},

	/**
	 * Leaderboard Commands
	 */
	addpoints(target, room, user) {
		room = this.requireRoom('scavengers' as RoomID);
		this.checkCan('mute', null, room);

		const parts = target.split(',');
		const targetId = toID(parts[0]);
		const points = parseInt(parts[1]);

		if (!targetId || targetId === 'constructor' || targetId.length > 18) return this.errorReply("Invalid username.");
		if (!points || points < 0 || points > 1000) return this.errorReply("Points must be an integer between 1 and 1000.");

		Leaderboard.addPoints(targetId, 'points', points, true).write();

		this.privateModAction(`${targetId} was given ${points} points on the current scavengers ladder by ${user.name}.`);
		this.modlog('SCAV ADDPOINTS', targetId, `${points}`);
	},

	removepoints(target, room, user) {
		room = this.requireRoom('scavengers' as RoomID);
		this.checkCan('mute', null, room);

		const parts = target.split(',');
		const targetId = toID(parts[0]);
		const points = parseInt(parts[1]);

		if (!targetId || targetId === 'constructor' || targetId.length > 18) return this.errorReply("Invalid username.");
		if (!points || points < 0 || points > 1000) return this.errorReply("Points must be an integer between 1 and 1000.");

		Leaderboard.addPoints(targetId, 'points', -points, true).write();

		this.privateModAction(`${user.name} has taken ${points} points from ${targetId} on the current scavengers ladder.`);
		this.modlog('SCAV REMOVEPOINTS', targetId, `${points}`);
	},

	resetladder(target, room, user) {
		room = this.requireRoom('scavengers' as RoomID);
		this.checkCan('declare', null, room);

		Leaderboard.reset().write();

		this.privateModAction(`${user.name} has reset the current scavengers ladder.`);
		this.modlog('SCAV RESETLADDER');
	},
	top: 'ladder',
	async ladder(target, room, user) {
		room = this.requireRoom();
		if (!getScavsRoom(room)) {
			return this.errorReply("This command can only be used in the scavengers room.");
		}
		if (!this.runBroadcast()) return false;

		const isChange = (!this.broadcasting && target);
		const hideStaff = (!this.broadcasting && this.meansNo(target));

		const ladder = await Leaderboard.visualize('points') as AnyObject[];
		this.sendReply(
			`|uhtml${isChange ? 'change' : ''}|scavladder|<div class="ladder" style="overflow-y: scroll; max-height: 300px;"><table style="width: 100%"><tr><th>Rank</th><th>Name</th><th>Points</th></tr>${ladder.map(entry => {
				const roomRank = room.auth.getDirect(toID(entry.name));
				const isStaff = Users.Auth.atLeast(roomRank, '+');
				if (isStaff && hideStaff) return '';
				return `<tr><td>${entry.rank}</td><td>${(isStaff ? `<em>${Utils.escapeHTML(entry.name)}</em>` : (entry.rank <= 5 ? `<strong>${Utils.escapeHTML(entry.name)}</strong>` : Utils.escapeHTML(entry.name)))}</td><td>${entry.points}</td></tr>`;
			}).join('')}</table></div>` +
			`<div style="text-align: center"><button class="button" name="send" value="/scav top ${hideStaff ?
				'yes' :
				'no'}">${hideStaff ?
				"Show" :
				"Hide"} Auth</button></div>`
		);
	},

	async rank(target, room, user) {
		room = this.requireRoom();
		if (!getScavsRoom(room)) {
			return this.errorReply("This command can only be used in the scavengers room.");
		}
		if (!this.runBroadcast()) return false;

		const targetId = toID(target) || user.id;

		const rank = await Leaderboard.visualize('points', targetId) as AnyObject;
		if (!rank) {
			this.sendReplyBox(`User '${targetId}' does not have any points on the scavengers leaderboard.`);
		} else {
			this.sendReplyBox(Utils.html`User '${rank.name}' is #${rank.rank} on the scavengers leaderboard with ${rank.points} points.`);
		}
	},

	/**
	 * Leaderboard Point Distribution Editing
	 */
	setblitz(target, room, user) {
		room = this.requireRoom();
		const scavsRoom = getScavsRoom(room);
		if (!scavsRoom) {
			return this.errorReply("This command can only be used in the scavengers room.");
		}
		this.checkCan('mute', null, room); // perms for viewing only

		if (!room.settings.scavSettings) room.settings.scavSettings = {};
		if (!target) {
			const points = [];
			const source = Object.entries(Object.assign(DEFAULT_BLITZ_POINTS, room.settings.scavSettings.blitzPoints || {}));
			for (const entry of source) {
				points.push(`${entry[0]}: ${entry[1]}`);
			}
			return this.sendReplyBox(`The points rewarded for winning hunts within a minute is:<br />${points.join('<br />')}`);
		}

		this.checkCan('declare', null, room); // perms for editing

		const parts = target.split(',');
		const blitzPoints = parseInt(parts[1]);
		const gameType = toID(parts[0]) as GameTypes;
		if (!RATED_TYPES.includes(gameType)) return this.errorReply(`You cannot set blitz points for ${gameType} hunts.`);

		if (isNaN(blitzPoints) || blitzPoints < 0 || blitzPoints > 1000) {
			return this.errorReply("The points value awarded for blitz must be an integer bewteen 0 and 1000.");
		}
		if (!room.settings.scavSettings.blitzPoints) room.settings.scavSettings.blitzPoints = {};
		room.settings.scavSettings.blitzPoints[gameType] = blitzPoints;

		room.saveSettings();
		this.privateModAction(`${user.name} has set the points awarded for blitz for ${gameType} hunts to ${blitzPoints}.`);
		this.modlog('SCAV BLITZ', null, `${gameType}: ${blitzPoints}`);

		// double modnote in scavs room if it is a subroomgroupchat
		if (room.parent && !room.persist && scavsRoom) {
			scavsRoom.modlog({
				action: 'SCAV BLITZ',
				loggedBy: user.id,
				note: `${gameType}: ${blitzPoints}`,
			});
			scavsRoom.sendMods(`(${user.name} has set the points awarded for blitz for ${gameType} hunts to ${blitzPoints} in <<${room.roomid}>>.)`);
			scavsRoom.roomlog(`(${user.name} has set the points awarded for blitz for ${gameType} hunts to ${blitzPoints} in <<${room.roomid}>>.)`);
		}
	},

	sethostpoints(target, room, user) {
		room = this.requireRoom();
		const scavsRoom = getScavsRoom(room);
		if (!scavsRoom) {
			return this.errorReply("This command can only be used in the scavengers room.");
		}
		this.checkCan('mute', null, room); // perms for viewing only
		if (!room.settings.scavSettings) room.settings.scavSettings = {};
		if (!target) {
			const pointSetting = Object.hasOwnProperty.call(room.settings.scavSettings, 'hostPoints') ?
				room.settings.scavSettings.hostPoints : DEFAULT_HOST_POINTS;
			return this.sendReply(`The points rewarded for hosting a regular hunt is ${pointSetting}.`);
		}

		this.checkCan('declare', null, room); // perms for editting
		const points = parseInt(target);
		if (isNaN(points)) return this.errorReply(`${target} is not a valid number of points.`);

		room.settings.scavSettings.hostPoints = points;
		room.saveSettings();
		this.privateModAction(`${user.name} has set the points awarded for hosting regular scavenger hunts to ${points}`);
		this.modlog('SCAV SETHOSTPOINTS', null, `${points}`);

		// double modnote in scavs room if it is a subroomgroupchat
		if (room.parent && !room.persist) {
			scavsRoom.modlog({
				action: 'SCAV SETHOSTPOINTS',
				loggedBy: user.id,
				note: `${points} [room: ${room.roomid}]`,
			});
			scavsRoom.sendMods(`(${user.name} has set the points awarded for hosting regular scavenger hunts to - ${points} in <<${room.roomid}>>)`);
			scavsRoom.roomlog(`(${user.name} has set the points awarded for hosting regular scavenger hunts to - ${points} in <<${room.roomid}>>)`);
		}
	},
	setpoints(target, room, user) {
		room = this.requireRoom();
		const scavsRoom = getScavsRoom(room);
		if (!scavsRoom) {
			return this.errorReply("This command can only be used in the scavengers room.");
		}
		this.checkCan('mute', null, room); // perms for viewing only
		if (!room.settings.scavSettings) room.settings.scavSettings = {};
		if (!target) {
			const points = [];
			const source = Object.entries({
				...DEFAULT_POINTS,
				...(room.settings.scavSettings.winPoints as typeof DEFAULT_POINTS || {}),
			});

			for (const entry of source) {
				points.push(`${entry[0]}: ${entry[1].map((p: number, i: number) => `(${(i + 1)}) ${p}`).join(', ')}`);
			}
			return this.sendReplyBox(`The points rewarded for winning hunts is:<br />${points.join('<br />')}`);
		}

		this.checkCan('declare', null, room); // perms for editting

		let [type, ...pointsSet] = target.split(',');
		type = toID(type) as GameTypes;
		if (!RATED_TYPES.includes(type)) return this.errorReply(`You cannot set win points for ${type} hunts.`);
		const winPoints = pointsSet.map(p => parseInt(p));

		if (winPoints.some(p => isNaN(p) || p < 0 || p > 1000) || !winPoints.length) {
			return this.errorReply("The points value awarded for winning a scavenger hunt must be an integer between 0 and 1000.");
		}

		if (!room.settings.scavSettings.winPoints) room.settings.scavSettings.winPoints = {};
		room.settings.scavSettings.winPoints[type] = winPoints;

		room.saveSettings();
		const pointsDisplay = winPoints.map((p, i) => `(${(i + 1)}) ${p}`).join(', ');
		this.privateModAction(`${user.name} has set the points awarded for winning ${type} scavenger hunts to - ${pointsDisplay}`);
		this.modlog('SCAV SETPOINTS', null, `${type}: ${pointsDisplay}`);

		// double modnote in scavs room if it is a subroomgroupchat
		if (room.parent && !room.persist) {
			scavsRoom.modlog({
				action: 'SCAV SETPOINTS',
				loggedBy: user.id,
				note: `${pointsDisplay} [room: ${room.roomid}]`,
			});
			scavsRoom.sendMods(`(${user.name} has set the points awarded for winning ${type} scavenger hunts to - ${pointsDisplay} in <<${room.roomid}>>)`);
			scavsRoom.roomlog(`(${user.name} has set the points awarded for winning ${type} scavenger hunts to - ${pointsDisplay} in <<${room.roomid}>>)`);
		}
	},

	resettwist: 'settwist',
	settwist(target, room, user) {
		room = this.requireRoom();
		const scavsRoom = getScavsRoom(room);
		if (!scavsRoom) {
			return this.errorReply("This command can only be used in the scavengers room.");
		}
		if (this.cmd.includes('reset')) target = 'RESET';

		if (!room.settings.scavSettings) room.settings.scavSettings = {};
		if (!target) {
			const twist = room.settings.scavSettings.officialtwist || 'none';
			return this.sendReplyBox(`The current official twist is: ${twist}`);
		}
		this.checkCan('declare', null, room);
		if (target === 'RESET') {
			room.settings.scavSettings.officialtwist = null;
		} else {
			const twist = toID(target);
			if (!ScavMods.twists[twist] || twist === 'constructor') return this.errorReply('Invalid twist.');

			room.settings.scavSettings.officialtwist = twist;
			room.saveSettings();
		}

		if (room.settings.scavSettings.officialtwist) {
			this.privateModAction(`${user.name} has set the official twist to ${room.settings.scavSettings.officialtwist}`);
		} else {
			this.privateModAction(`${user.name} has removed the official twist.`);
		}
		this.modlog('SCAV TWIST', null, room.settings.scavSettings.officialtwist);

		// double modnote in scavs room if it is a subroomgroupchat
		if (room.parent && !room.persist) {
			if (room.settings.scavSettings.officialtwist) {
				scavsRoom.modlog({
					action: 'SCAV TWIST',
					loggedBy: user.id,
					note: `${room.settings.scavSettings.officialtwist} [room: ${room.roomid}]`,
				});
				scavsRoom.sendMods(`(${user.name} has set the official twist to - ${room.settings.scavSettings.officialtwist} in <<${room.roomid}>>)`);
				scavsRoom.roomlog(`(${user.name} has set the official twist to  - ${room.settings.scavSettings.officialtwist} in <<${room.roomid}>>)`);
			} else {
				scavsRoom.sendMods(`(${user.name} has reset the official twist in <<${room.roomid}>>)`);
				scavsRoom.roomlog(`(${user.name} has reset the official twist in <<${room.roomid}>>)`);
			}
		}
	},

	twists(target, room, user) {
		room = this.requireRoom();
		if (!getScavsRoom(room)) {
			return this.errorReply("This command can only be used in the scavengers room.");
		}
		this.checkCan('mute', null, room);
		if (!this.runBroadcast()) return false;

		let buffer = `<table><tr><th>Twist</th><th>Description</th></tr>`;
		buffer += Object.values(ScavMods.twists).map(twist => (
			Utils.html`<tr><td style="padding: 5px;">${twist.name}</td><td style="padding: 5px;">${twist.desc}</td></tr>`
		)).join('');
		buffer += `</table>`;

		this.sendReply(`|raw|<div class="ladder infobox-limited">${buffer}</div>`);
	},

	/**
	 * Scavenger statistic tracking
	 */
	huntcount: 'huntlogs',
	async huntlogs(target, room, user) {
		room = this.requireRoom('scavengers' as RoomID);
		this.checkCan('mute', null, room);

		if (target === 'RESET') {
			this.checkCan('declare', null, room);
			await HostLeaderboard.softReset();
			HostLeaderboard.write();
			this.privateModAction(`${user.name} has reset the host log leaderboard into the next month.`);
			this.modlog('SCAV HUNTLOGS', null, 'RESET');
			return;
		} else if (target === 'HARD RESET') {
			this.checkCan('declare', null, room);
			HostLeaderboard.hardReset().write();
			this.privateModAction(`${user.name} has hard reset the host log leaderboard.`);
			this.modlog('SCAV HUNTLOGS', null, 'HARD RESET');
			return;
		}

		let [sortMethod, isUhtmlChange] = target.split(',');

		const sortingFields = ['points', 'cumulative-points'];

		if (!sortingFields.includes(sortMethod)) sortMethod = 'points'; // default sort method

		const data = await HostLeaderboard.visualize(sortMethod) as AnyObject[];
		this.sendReply(
			`|${isUhtmlChange ? 'uhtmlchange' : 'uhtml'}|scav-huntlogs|<div class="ladder" style="overflow-y: scroll; max-height: 300px;"><table style="width: 100%"><tr><th>Rank</th><th>Name</th><th>Hunts Created</th><th>Total Hunts Created</th><th>History</th></tr>${
				data.map(entry => {
					const auth = room.auth.get(toID(entry.name)).trim();
					const color = auth ? 'inherit' : 'gray';
					return `<tr><td>${entry.rank}</td><td><span style="color: ${color}">${auth || '&nbsp;'}</span>${Utils.escapeHTML(entry.name)}</td>` +
						`<td style="text-align: right;">${(entry.points || 0)}</td>` +
						`<td style="text-align: right;">${(entry['cumulative-points'] || 0)}</td>` +
						`<td style="text-align: left;">${entry['history-points'] ? `<span style="color: gray">{ ${entry['history-points'].join(', ')} }</span>` : ''}</td>` +
						`</tr>`;
				}).join('')
			}</table></div><div style="text-align: center">${
				sortingFields.map(
					f => `<button class="button${f === sortMethod ? ' disabled' : ''}" name="send" value="/scav huntlogs ${f}, 1">${f}</button>`
				).join(' ')
			}</div>`
		);
	},

	async playlogs(target, room, user) {
		room = this.requireRoom('scavengers' as RoomID);
		this.checkCan('mute', null, room);

		if (target === 'RESET') {
			this.checkCan('declare', null, room);
			await PlayerLeaderboard.softReset();
			PlayerLeaderboard.write();
			this.privateModAction(`${user.name} has reset the player log leaderboard into the next month.`);
			this.modlog('SCAV PLAYLOGS', null, 'RESET');
			return;
		} else if (target === 'HARD RESET') {
			this.checkCan('declare', null, room);
			PlayerLeaderboard.hardReset().write();
			this.privateModAction(`${user.name} has hard reset the player log leaderboard.`);
			this.modlog('SCAV PLAYLOGS', null, 'HARD RESET');
			return;
		}

		let [sortMethod, isUhtmlChange] = target.split(',');

		const sortingFields = ['join', 'cumulative-join', 'finish', 'cumulative-finish', 'infraction', 'cumulative-infraction'];

		if (!sortingFields.includes(sortMethod)) sortMethod = 'finish'; // default sort method

		const data = await PlayerLeaderboard.visualize(sortMethod) as AnyObject[];
		const formattedData = data.map(d => {
			// always have at least one for join to get a value of 0 if both are 0 or non-existent
			d.ratio = (((d.finish || 0) / (d.join || 1)) * 100).toFixed(2);
			d['cumulative-ratio'] = (((d['cumulative-finish'] || 0) / (d['cumulative-join'] || 1)) * 100).toFixed(2);
			return d;
		});

		this.sendReply(
			`|${isUhtmlChange ? 'uhtmlchange' : 'uhtml'}|scav-playlogs|<div class="ladder" style="overflow-y: scroll; max-height: 300px;"><table style="width: 100%"><tr><th>Rank</th><th>Name</th><th>Finished Hunts</th><th>Joined Hunts</th><th>Ratio</th><th>Infractions</th></tr>${
				formattedData.map(entry => {
					const auth = room.auth.get(toID(entry.name)).trim();
					const color = auth ? 'inherit' : 'gray';

					return `<tr><td>${entry.rank}</td><td><span style="color: ${color}">${auth || '&nbsp;'}</span>${Utils.escapeHTML(entry.name)}</td>` +
						`<td style="text-align: right;">${(entry.finish || 0)} <span style="color: blue">(${(entry['cumulative-finish'] || 0)})</span>${(entry['history-finish'] ? `<br /><span style="color: gray">(History: ${entry['history-finish'].join(', ')})</span>` : '')}</td>` +
						`<td style="text-align: right;">${(entry.join || 0)} <span style="color: blue">(${(entry['cumulative-join'] || 0)})</span>${(entry['history-join'] ? `<br /><span style="color: gray">(History: ${entry['history-join'].join(', ')})</span>` : '')}</td>` +
						`<td style="text-align: right;">${entry.ratio}%<br /><span style="color: blue">(${(entry['cumulative-ratio'] || "0.00")}%)</span></td>` +
						`<td style="text-align: right;">${(entry.infraction || 0)} <span style="color: blue">(${(entry['cumulative-infraction'] || 0)})</span>${(entry['history-infraction'] ? `<br /><span style="color: gray">(History: ${entry['history-infraction'].join(', ')})</span>` : '')}</td>` +
						`</tr>`;
				}).join('')
			}</table></div><div style="text-align: center">${
				sortingFields.map(
					f => `<button class="button${f === sortMethod ? ' disabled' : ''}" name="send" value="/scav playlogs ${f}, 1">${f}</button>`
				).join(' ')
			}</div>`
		);
	},

	uninfract: "infract",
	infract(target, room, user) {
		room = this.requireRoom('scavengers' as RoomID);
		this.checkCan('mute', null, room);

		const targetId = toID(target);
		if (!targetId) return this.errorReply(`Please include the name of the user to ${this.cmd}.`);
		const change = this.cmd === 'infract' ? 1 : -1;

		PlayerLeaderboard.addPoints(targetId, 'infraction', change, true).write();

		this.privateModAction(`${user.name} has ${(change > 0 ? 'given' : 'taken')} one infraction point ${(change > 0 ? 'to' : 'from')} '${targetId}'.`);
		this.modlog(`SCAV ${this.cmd.toUpperCase()}`, user);
	},

	modsettings: {
		'': 'update',
		'update'(target, room, user) {
			room = this.requireRoom();
			if (!getScavsRoom(room)) return false;
			this.checkCan('declare', null, room);
			const settings = room.settings.scavSettings?.scavmod || {};

			this.sendReply(`|uhtml${this.cmd === 'update' ? 'change' : ''}|scav-modsettings|<div class=infobox><strong>Scavenger Moderation Settings:</strong><br /><br />` +
				`<button name=send value='/scav modsettings ipcheck toggle'><i class="fa fa-power-off"></i></button> Multiple connection verification: ${settings.ipcheck ? 'ON' : 'OFF'}` +
				`</div>`);
		},

		'ipcheck'(target, room, user) {
			room = this.requireRoom();
			if (!getScavsRoom(room)) return false;
			this.checkCan('declare', null, room);

			if (!room.settings.scavSettings) room.settings.scavSettings = {};
			const settings = room.settings.scavSettings.scavmod || {};
			target = toID(target);

			const setting: { [k: string]: boolean } = {
				'on': true,
				'off': false,
				'toggle': !settings.ipcheck,
			};

			if (!(target in setting)) return this.sendReply('Invalid setting - ON, OFF, TOGGLE');

			settings.ipcheck = setting[target];
			room.settings.scavSettings.scavmod = settings;

			room.saveSettings();

			this.privateModAction(`${user.name} has set multiple connections verification to ${setting[target] ? 'ON' : 'OFF'}.`);
			this.modlog('SCAV MODSETTINGS IPCHECK', null, setting[target] ? 'ON' : 'OFF');

			return this.parse('/scav modsettings update');
		},
	},

	/**
	 * Database Commands
	 */
	recycledhunts(target, room, user) {
		room = this.requireRoom();
		this.checkCan('mute', null, room);
		if (!getScavsRoom(room)) {
			return this.errorReply("Scavenger Hunts can only be added to the database in the scavengers room.");
		}

		let cmd;
		[cmd, target] = Utils.splitFirst(target, ' ');
		cmd = toID(cmd);

		if (!['addhunt', 'list', 'removehunt', 'addhint', 'removehint', 'autostart'].includes(cmd)) {
			return this.parse(`/recycledhuntshelp`);
		}

		if (cmd === 'addhunt') {
			if (!target) return this.errorReply(`Usage: ${cmd} Hunt Text`);

			const [hostsArray, ...questions] = target.split('|');
			const hosts = ScavengerHunt.parseHosts(hostsArray.split(/[,;]/), room, true);
			if (!hosts.length) {
				return this.errorReply("You need to specify a host.");
			}

			const result = ScavengerHunt.parseQuestions(questions);
			if (result.err) return this.errorReply(result.err);

			ScavengerHuntDatabase.addRecycledHuntToDatabase(hosts, result.result);
			return this.privateModAction(`A recycled hunt has been added to the database.`);
		}

		// The rest of the commands depend on there already being hunts in the database.
		if (ScavengerHuntDatabase.isEmpty()) return this.errorReply("There are no hunts in the database.");

		if (cmd === 'list') {
			return this.parse(`/join view-recycledHunts-${room}`);
		}

		const params = target.split(',').map(param => param.trim()).filter(param => param !== '');

		const usageMessages: { [k: string]: string } = {
			'removehunt': 'Usage: removehunt hunt_number',
			'addhint': 'Usage: addhint hunt number, question number, hint text',
			'removehint': 'Usage: removehint hunt number, question number, hint text',
			'autostart': 'Usage: autostart on/off',
		};
		if (!params) return this.errorReply(usageMessages[cmd]);

		const numberOfRequiredParameters: { [k: string]: number } = {
			'removehunt': 1,
			'addhint': 3,
			'removehint': 3,
			'autostart': 1,
		};
		if (params.length < numberOfRequiredParameters[cmd]) return this.errorReply(usageMessages[cmd]);

		const [huntNumber, questionNumber, hintNumber] = params.map(param => parseInt(param));
		const cmdsNeedingHuntNumber = ['removehunt', 'removehint', 'addhint'];
		if (cmdsNeedingHuntNumber.includes(cmd)) {
			if (!ScavengerHuntDatabase.hasHunt(huntNumber)) return this.errorReply("You specified an invalid hunt number.");
		}

		const cmdsNeedingQuestionNumber = ['addhint', 'removehint'];
		if (cmdsNeedingQuestionNumber.includes(cmd)) {
			if (
				isNaN(questionNumber) ||
				questionNumber <= 0 ||
				questionNumber > scavengersData.recycledHunts[huntNumber - 1].questions.length
			) {
				return this.errorReply("You specified an invalid question number.");
			}
		}

		const cmdsNeedingHintNumber = ['removehint'];
		if (cmdsNeedingHintNumber.includes(cmd)) {
			const numQuestions = scavengersData.recycledHunts[huntNumber - 1].questions.length;
			if (isNaN(questionNumber) || questionNumber <= 0 || questionNumber > numQuestions) {
				return this.errorReply("You specified an invalid hint number.");
			}
		}

		if (cmd === 'removehunt') {
			ScavengerHuntDatabase.removeRecycledHuntFromDatabase(huntNumber);
			return this.privateModAction(`Recycled hunt #${huntNumber} was removed from the database.`);
		} else if (cmd === 'addhint') {
			const hintText = params[2];
			ScavengerHuntDatabase.addHintToRecycledHunt(huntNumber, questionNumber, hintText);
			return this.privateModAction(`Hint added to Recycled hunt #${huntNumber} question #${questionNumber}: ${hintText}.`);
		} else if (cmd === 'removehint') {
			ScavengerHuntDatabase.removeHintToRecycledHunt(huntNumber, questionNumber, hintNumber);
			return this.privateModAction(`Hint #${hintNumber} was removed from Recycled hunt #${huntNumber} question #${questionNumber}.`);
		} else if (cmd === 'autostart') {
			if (!room.settings.scavSettings) room.settings.scavSettings = {};
			if (params[0] !== 'on' && params[0] !== 'off') return this.errorReply(usageMessages[cmd]);
			if ((params[0] === 'on') === !!room.settings.scavSettings.addRecycledHuntsToQueueAutomatically) {
				return this.errorReply(`Autostarting recycled hunts is already ${room.settings.scavSettings.addRecycledHuntsToQueueAutomatically ? 'on' : 'off'}.`);
			}
			room.settings.scavSettings.addRecycledHuntsToQueueAutomatically =
				!room.settings.scavSettings.addRecycledHuntsToQueueAutomatically;
			this.privateModAction(`Automatically adding recycled hunts to the queue is now ${room.settings.scavSettings.addRecycledHuntsToQueueAutomatically ? 'on' : 'off'}`);
			if (params[0] === 'on') {
				return this.parse("/scav queuerecycled");
			}
		}
	},

	recycledhuntshelp() {
		if (!this.runBroadcast()) return;
		this.sendReplyBox([
			"<b>Help for Recycled Hunts</b>",
			"- addhunt &lt;Hunt Text>: Adds a hunt to the database of recycled hunts.",
			"- removehunt&lt;Hunt Number>: Removes a hunt form the database of recycled hunts.",
			"- list: Shows a list of hunts in the database along with their questions and hints.",
			"- addhint &lt;Hunt Number, Question Number, Hint Text>: Adds a hint to the specified question in the specified hunt.",
			"- removehint &lt;Hunt Number, Question Number, Hint Number>: Removes the specified hint from the specified question in the specified hunt.",
			"- autostart &lt;on/off>: Sets whether or not recycled hunts are automatically added to the queue when a hunt ends.",
		].join('<br/>'));
	},
};

export const pages: Chat.PageTable = {
	recycledHunts(query, user, connection) {
		this.title = 'Recycled Hunts';
		const room = this.requireRoom();

		let buf = "";
		if (!user.named) return Rooms.RETRY_AFTER_LOGIN;
		if (!room.persist) return;
		this.checkCan('mute', null, room);
		buf += `<div class="pad"><h2>List of recycled Scavenger hunts</h2>`;
		buf += `<ol style="width: 90%;">`;
		for (const hunt of scavengersData.recycledHunts) {
			buf += `<li>`;
			buf += `<h4>By ${hunt.hosts.map((host: AnyObject) => host.name).join(', ')}</h4>`;
			for (const question of hunt.questions) {
				buf += `<details>`;
				buf += `<summary>${question.text}</summary>`;
				buf += `<dl>`;
				buf += `<dt>Answers:</dt>`;
				for (const answer of question.answers) {
					buf += `<dd>${answer}</dd>`;
				}
				buf += `</dl>`;

				if (question.hints.length) {
					buf += `<dl>`;
					buf += `<dt>Hints:</dt>`;
					for (const hint of question.hints) {
						buf += `<dd>${hint}</dd>`;
					}
					buf += `</dl>`;
				}
				buf += `</details>`;
			}
			buf += `</li>`;
		}
		buf += `</ol>`;
		buf += `</div>`;
		return buf;
	},
};

export const commands: Chat.ChatCommands = {
	// general
	scav: 'scavengers',
	scavengers: ScavengerCommands,
	tscav: 'teamscavs',
	teamscavs: ScavengerCommands.teamscavs,
	teamscavshelp: ScavengerCommands.teamscavshelp,

	// old game aliases
	scavenge: ScavengerCommands.guess,
	startpracticehunt: 'starthunt',
	startofficial: 'starthunt',
	startofficialhunt: 'starthunt',
	startminihunt: 'starthunt',
	startunratedhunt: 'starthunt',
	startrecycledhunt: 'starthunt',
	starttwisthunt: 'starthunt',
	starttwistofficial: 'starthunt',
	starttwistpractice: 'starthunt',
	starttwistmini: 'starthunt',
	starttwistunrated: 'starthunt',

	forcestarthunt: 'starthunt',
	forcestartunrated: 'starthunt',
	forcestartpractice: 'starthunt',

	starthtmlpracticehunt: 'starthunt',
	starthtmlofficial: 'starthunt',
	starthtmlofficialhunt: 'starthunt',
	starthtmlminihunt: 'starthunt',
	starthtmlunratedhunt: 'starthunt',
	starthtmlrecycledhunt: 'starthunt',
	starthtmltwisthunt: 'starthunt',
	starthtmltwistofficial: 'starthunt',
	starthtmltwistpractice: 'starthunt',
	starthtmltwistmini: 'starthunt',
	starthtmltwistunrated: 'starthunt',

	forcehtmlstarthunt: 'starthunt',
	forcehtmlstartunrated: 'starthunt',
	forcehtmlstartpractice: 'starthunt',

	starthtmlhunt: 'starthunt',

	starthunt: ScavengerCommands.create,
	joinhunt: ScavengerCommands.join,
	leavehunt: ScavengerCommands.leave,
	resethunt: ScavengerCommands.reset,
	resethunttoqueue: ScavengerCommands.resettoqueue,
	forceendhunt: 'endhunt',
	endhunt: ScavengerCommands.end,
	edithunt: ScavengerCommands.edithunt,
	viewhunt: ScavengerCommands.viewhunt,
	inherithunt: ScavengerCommands.inherit,
	scavengerstatus: ScavengerCommands.status,
	scavengerhint: ScavengerCommands.hint,

	nexthunt: ScavengerCommands.next,

	// point aliases
	scavaddpoints: 'scavengeraddpoints',
	scavengersaddpoints: ScavengerCommands.addpoints,

	scavrmpoints: 'scavengersremovepoints',
	scavengersrmpoints: 'scavengersremovepoints',
	scavremovepoints: 'scavengersremovepoints',
	scavengersremovepoints: ScavengerCommands.addpoints,

	scavresetlb: 'scavengersresetlb',
	scavengersresetlb: ScavengerCommands.resetladder,

	recycledhunts: ScavengerCommands.recycledhunts,
	recycledhuntshelp: ScavengerCommands.recycledhuntshelp,

	scavrank: ScavengerCommands.rank,
	scavladder: 'scavtop',
	scavtop: ScavengerCommands.ladder,
	scavengerhelp: 'scavengershelp',
	scavhelp: 'scavengershelp',
	scavengershelp(target, room, user) {
		if (!room || !getScavsRoom(room)) {
			return this.errorReply("This command can only be used in the scavengers room.");
		}
		if (!this.runBroadcast()) return false;

		const userCommands = [
			"<strong>Player commands:</strong>",
			"- /scavengers: Join the scavengers room.",
			"- /joinhunt: Join the current scavenger hunt.",
			"- /leavehunt: Leave the current scavenger hunt. Also resets your progress.",
			"- /viewhunt: Show the ongoing hunt up to where you solved it.",
			"- /scavenge <em>[guess]</em>: Submit your answer to the current hint.",
			"- /scavengerstatus  (or /scav status): Check your status in the current hunt.",
			"- /scavengers queue (or /scav queue): Showcase the hunts currently in queue, with the answers hidden for any hunt that is not yours.",
			"- /scavengerhint (or /scav hint): View your latest hint in the current game.",
			"- /scavladder (or /scav top): View the current scavengers leaderboard.",
			"- /scavrank <em>[user]</em>: View the rank of the user on the current scavenger leaderboard. Defaults to the user if no name is provided.",
			"For a more in-depth overview, use /scavhelp staff.",
		].join('<br />');
		const staffCommands = [
			"<strong>Staff and auth commands:</strong>",
			"As a <strong>room voice (+)</strong>, you can use the following Scavengers commands, on top of the regular commands (see /scavhelp):",
			"- /scav edithunt <em>[question number]</em>, <em>[hint | answer]</em>, <em>[value]</em>: Edit the ongoing scavenger hunt. Only the host(s) can edit the hunt.",
			"- /scav addhint <em>[question number]</em>, <em>[value]</em>: Add a hint to a question in the ongoing scavenger hunt. Only the host(s) can add a hint.",
			"- /scav edithint <em>[question number]</em>, <em>[hint number]</em>, <em>[value]</em>: Edit a hint to a question in the ongoing scavenger hunt. Only the host(s) can edit a hint.",
			"- /scav removehint <em>[question number]</em>, <em>[hint number]<e/m> (or /scav deletehint): Remove a hint from a question in the current scavenger hunt. Only the host(s) can remove a hint.",
			"- /teamscavshelp: Explains the team scavs plugin.",
			"<br />As a <strong>room driver (%)</strong>, you can also use the following Scavengers commands:",
			"- /scav queue (unrated) <em>[host(s)]</em> | <em>[hint]</em> | <em>[answer]</em> | <em>[hint]</em> | <em>[answer]</em> | <em>[hint]</em> | <em>[answer]</em> | ...: Queue a scavenger hunt to be started after the current hunt is finished.",
			"- /scav queuehtml (unrated) <em>[host(s)]</em> | <em>[hint]</em> | <em>[answer]</em> | <em>[hint]</em> | <em>[answer]</em> | <em>[hint]</em> | <em>[answer]</em> | ...: Queue a scavenger hunt that uses HTML to be started after the current hunt is finished.",
			"- /start(official/practice/mini/unrated)hunt <em>[host]</em> | <em>[hint]</em> | <em>[answer]</em> | <em>[hint]</em> | <em>[answer]</em> | <em>[hint]</em> | </em>[answer]</em> | ...: Create a new (official/practice/mini/unrated) scavenger hunt and start it immediately.",
			"- /starthtml(official/practice/mini/unrated)hunt <em>[host]</em> | <em>[hint]</em> | <em>[answer]</em> | <em>[hint]</em> | <em>[answer]</em> | <em>[hint]</em> | </em>[answer]</em> | ...: Create a new (official/practice/mini/unrated) scavenger hunt that uses HTML and start it immediately.",
			"- /scav viewqueue (or /scav queue): Look at the list of queued scavenger hunts. Now also includes the option to remove hunts from the queue.",
			"- /resethunt: Reset the current scavenger hunt without revealing the hints and answers, nor giving out points.",
			"- /resethunttoqueue: Reset the ongoing scavenger hunt without revealing the hints and answers, nor giving out points. Then, add it directly to the queue.",
			"- /scav timer <em>[minutes]</em>: Set a timer to automatically end the current hunt. Setting [minutes] to 0 turns off the timer.",
			"- /endhunt: End the current scavenger hunt immediately and announce the winners and the answers.",
			"- /nexthunt: Start the next hunt in the queue.",
			"- /viewhunt: View the ongoing scavenger hunt. As a host, you can also view the hunt in its entirety.",
			"- /inherithunt: Become the staff host, gaining staff permissions to the current hunt.",
			"- /scav games create <em>[game mode]</em>: start a game of the given mode.",
			"&nbsp;&nbsp;&nbsp;&nbsp;Game modes include: Jump Start, Point Rally, KO games, Scav games and team scavs.",
			"- /scav games end: End the game of the given type.",
			"- /starttwist(hunt / practice / official / mini /unrated) <em>[twist]</em> | <em>[host]</em> | <em>[hint]</em> | <em>[answer]</em> | <em>[hint]</em> | <em>[answer]</em> | <em>[hint]</em> | <em>[answer]</em> | … : Create and start a new scavenger hunt that uses a specified twist mode. This can be used inside a scavenger game mode.",
			"- /scav twists: Show a list of all the twists that are available on the server.",
			"- /scav settwist: View the current default official hunt twist that is in use.",
			"- /scav setpoints: Show the current point distribution for officials, minis and regular hunts.",
			"- /scav sethostpoints: Show the current points awarded for hosting hunts.",
			"- /scav setblitz: Show the current points awarded for Blitzing an official, mini or regular hunt.",
			"- /scav defaulttimer: Show the default timer applied to hunts started automatically from the queue.",
			"- /scav addpoints <em>[user]</em>, <em>[amount]</em>: Give the user the specified amount of points towards the current ladder.",
			"- /scav removepoints <em>[user]</em>, <em>[amount]</em>: Remove the specified amount of points from the user towards the current ladder.",
			"- /recycledhunts: Modify the database of recycled hunts and enable/disable autoqueing them.",
			"- /scav queuerecycled <em>[number]</em>: Queue a recycled hunt from the database. If <em>[number]</em> is left blank, then a random hunt is queued.",
			"- /recycledhuntshelp: give more info about the recycled hunts.",
			"<br />As a <strong>room owner (#)</strong>, you can also use the following scavengers commands:",
			"- /scav resetladder: Reset the current scavenger leaderboard.",
			"- /scav setpoints <em>[1st place]</em>, <em>[2nd place]</em>, <em>[3rd place]</em>, <em>[4th place]</em>, <em>[5th place]</em>, ...: Set the point values for wins of officials, minis and regular hunts.",
			"- /scav sethostpoints <em>[value]</em>: Set the point value for hosting regular hunts.",
			"- /scav defaulttimer <em>[value]</em>: Set the default timer applied to automatically started hunts from the queue.",
			"- /scav setblitz <em>[value]</em> ...: Set the blitz award to the given value.",
			"- /scav settwist <em>[twist name]</em>: Set the default twist mode for all official hunts.",
			"- /scav resettwist: Reset the default twist mode for all official hunts to nothing.",
			"- /scav modsettings: Allow or disallow miscellaneous room settings",
		].join('<br />');

		const gamesCommands = [
			"<strong>Game commands:</strong>",
			"- /scav game create <em>[kogames | pointrally | scavengergames | jumpstart | teamscavs]</em>: Start a new scripted scavenger game. (Requires: % @ * # ~)",
			"- /scav game end: End the current scavenger game. (Requires: % @ * # ~)",
			"- /scav game kick <em>[user]</em>: Kick the user from the current scavenger game. (Requires: % @ * # ~)",
			"- /scav game score: Show the current scoreboard for any game with a leaderboard.",
			"- /scav game rank <em>[user]</em>: Show a user's rank in the current scavenger game leaderboard.",
		].join('<br />');

		target = toID(target);

		const display = target === 'all' ?
			`${userCommands}<br /><br />${staffCommands}<br /><br />${gamesCommands}` :
			(
				target === 'staff' || target === 'auth' ? staffCommands :
				target === 'games' || target === 'game' ? gamesCommands : userCommands
			);

		this.sendReplyBox(display);
	},
};