rx-irc/bot-quiz/lib/quiz-game.js
// NPM Dependencies
const assert = require('assert');
const colors = require('irc-colors');
// Local Dependencies
const QuizPlayers = require('./quiz-players');
const QuizQuestion = require('./quiz-question');
const QuizQuestions = require('./quiz-questions');
const QuizHelp = require('./quiz-help');
/**
 * @type {Object}
 * @property {string} prefix='[Qz]'
 * @property {number} startDelay=10000
 * @property {number} questionDelay=5000
 * @property {number} hintInterval=5000
 * @property {boolean} moderated=true
 * @property {boolean} hintsUseCache=false
 * @property {string} logLevel='error'
 */
let defaults = {
	prefix: '[Qz]',
	startDelay: 10000,
	questionDelay: 5000,
	hintInterval: 5000,
	moderated: true,
	hintsUseCache: false,
	logLevel: 'error',
};
module.exports = class QuizGame {
	/**
	 * @param {Client} client
	 * @param {object} options
 	 * @returns {void}
	 */
	constructor(client, options) {
		assert.strictEqual(typeof options.channel, 'string');
		/** @type {Client} */
		this.client = client;
		/** @type {object} */
		this.settings = { ...defaults, ...options };
		/** @type {QuizPlayers} */
		this.players = new QuizPlayers();
		/** @type {QuizQuestions} */
		this.questions = new QuizQuestions();
		/** @type {QuizHelp} */
		this.help = new QuizHelp({ useCache: this.settings.hintsUseCache });
		/** @type {boolean} */
		this.gameActive = false;
		/** @type {number} */
		this.startTimeoutId = null;
		/** @type {QuizQuestion} */
		this.currentChallenge = null;
		/** @type {number} */
		this.challengeTimeoutId = null;
		/** @type {number} */
		this.hintIntervalId = null;
	}
	/**
	 * @param {string} nick
 	 * @returns {void}
	 */
	addPlayer(nick) {
		if (!this.players.isPlayer(nick)) {
			if (this.gameActive) {
				this.notify(nick, `Game is already running.`);
			} else {
				if (this.settings.moderated) {
					this.sendModes(this.settings.channel, '+v', nick);
				}
				this.tellToChannel(`Player ${nick} has joined the game.`);
				this.players.addPlayer(nick);
				if (this.players.nickList.length === 2) {
					this.tellToChannel('Enough players found for new game.');
					this.beginGame();
				} else if (this.players.nickList.length > 2) {
					this.tellToChannel('Additional player joined the game.');
					this.beginGame();
				}
			}
		}
	}
	/**
	 * @param {string} nick
	 * @returns {void}
	 */
	removePlayer(nick) {
		if (this.players.isPlayer(nick)) {
			if (this.settings.moderated) {
				this.sendModes(this.settings.channel, '-v', nick);
			}
			this.tellToChannel(`Player ${nick} has quit the game.`);
			this.players.removePlayer(nick);
			if (this.players.nickList.length === 1) {
				clearTimeout(this.startTimeoutId);
				if (this.gameActive) {
					this.finishChallenge();
					this.endGame(`Last man standing.`);
				}
			}
		}
	}
	/**
	 * @param {string} oldnick
	 * @param {string} newnick
	 * @returns {void}
	 */
	updatePlayer(oldnick, newnick) {
		if (this.players.isPlayer(oldnick)) {
			this.players.updatePlayer(oldnick, newnick);
		}
	}
	/**
	 * @returns {void}
	 */
	beginGame() {
		this.tellToChannel(`Game starting in ${this.settings.startDelay / 1000} seconds.`);
		clearTimeout(this.startTimeoutId);
		this.startTimeoutId = setTimeout(() => {
			let title = colors.inverse(` GAME STARTS `);
			this.gameActive = true;
			this.questions.load('de-quake', 10);
			if (this.settings.moderated) {
				this.sendModes(this.settings.channel, '+m');
			}
			this.tellToChannel(title);
			this.tellToChannel(
				'Total questions: ' + this.questions.total + '; ' +
				'Using: ' + this.questions.current.length + '; ' +
				'Remaining: ' + this.questions.remaining.length
			);
			this.tellToChannel('Players: ' + this.players.nickList.join(', '));
			this.nextChallenge();
		}, this.settings.startDelay);
	}
	/**
	 * @param {string} reason
	 * @returns {void}
	 */
	endGame(reason) {
		let title = colors.inverse(` GAME OVER `);
		clearTimeout(this.startTimeoutId);
		clearInterval(this.hintIntervalId);
		if (this.gameActive) {
			this.tellToChannel(`${title} ${reason}`);
			this.tellScore();
			this.gameActive = false;
			this.tellToChannel(`Thank you for playing.`);
			if (this.settings.moderated) {
				this.sendModes(this.settings.channel, '-m');
				this.players.nickList.forEach(nick => {
					this.sendModes(this.settings.channel, '-v', nick);
				});
			}
			this.players.removeAllPlayers();
		}
	}
	//   ____ _           _ _
	//  / ___| |__   __ _| | | ___ _ __   __ _  ___
	// | |   | '_ \ / _` | | |/ _ \ '_ \ / _` |/ _ \
	// | |___| | | | (_| | | |  __/ | | | (_| |  __/
	//  \____|_| |_|\__,_|_|_|\___|_| |_|\__, |\___|
	//                                   |___/
	//
	/**
	 * @returns {void}
	 */
	nextChallenge() {
		if (this.gameActive) {
			if (this.currentChallenge !== null) {
				// Finish current challenge first.
			} else if (this.challengeTimeoutId !== null) {
				// Next challenge already initialized.
			} else if (this.questions.current.length === 0) {
				this.endGame(`No more questions.`);
			} else {
				this.challengeTimeoutId = setTimeout(() => {
					let title = colors.inverse(` NEW QUESTION `);
					this.challengeTimeoutId = null;
					this.currentChallenge = new QuizQuestion(this.questions.pop());
					this.tellToChannel(`${title} ${this.questions.currentLimit - this.questions.current.length} / ${this.questions.currentLimit}`);
					this.tellToChannel(this.currentChallenge.question);
					this.tellToChannel(this.currentChallenge.hintPlaceholder);
					clearInterval(this.hintIntervalId);
					this.hintIntervalId = setInterval(
						() => this.giveHint(),
						this.settings.hintInterval
					);
				}, this.settings.questionDelay);
			}
		}
	}
	/**
	 * @param {string} winner
	 * @returns {void}
	 */
	finishChallenge(winner) {
		let title = colors.inverse(` QUESTION END `);
		clearInterval(this.hintIntervalId);
		this.players.resetRevoltees();
		let message;
		if (winner) {
			this.players.increaseScore(winner);
			message = `${winner} had the correct answer!`;
		} else {
			message = `The question went unanswered.`;
		}
		this.tellToChannel(`${title} ${message}`);
		this.tellToChannel(`Question: ${this.currentChallenge.question}`);
		this.tellToChannel(`Answer: ${this.currentChallenge.hintString}`);
		this.tellScore();
		this.currentChallenge = null;
	}
	/**
	 * @param {string} nick
	 * @returns {void}
	 */
	handleRevolt(nick) {
		if (this.currentChallenge !== null) {
			if (this.players.isRevolting(nick)) {
				this.notify(nick, `You are already revolting.`);
			} else if (this.currentChallenge.hintsGiven < 1) {
				this.tellToChannel(`Revolting is only possible after the first hint.`);
			} else {
				this.players.setRevoltee(nick);
				this.tellToChannel(`${nick} is revolting!`);
				if (this.players.getRevoltees().length > this.players.nickList.length / 2) {
					this.tellToChannel(`Revolt successful. The quiz master gives in.`);
					this.finishChallenge();
					this.nextChallenge();
				} else {
					this.tellToChannel(`The mob is negligible. The quiz master is not impressed.`)
				}
			}
		}
	}
	/**
	 * @param {string} nick
	 * @param {string} guess
	 * @returns {void}
	 */
	handleGuess(nick, guess) {
		if (this.currentChallenge !== null) {
			if (this.currentChallenge.checkGuess(guess)) {
				this.finishChallenge(nick);
				this.nextChallenge();
			}
		}
	}
	/**
	 * @returns {void}
	 */
	giveHint() {
		if (this.currentChallenge === null) {
		} else if (this.currentChallenge.hintArray.length < 2) {
			this.finishChallenge();
			this.nextChallenge();
		} else {
			this.currentChallenge.addHint();
			this.tellToChannel(this.currentChallenge.question);
			this.tellToChannel(this.currentChallenge.hintPlaceholder);
		}
	}
	//  _____     _ _
	// |_   _|_ _| | | __
	//   | |/ _` | | |/ /
	//   | | (_| | |   <
	//   |_|\__,_|_|_|\_\
	//
	/**
	 * @returns {void}
	 */
	sendModes() {
		let args = Array.from(arguments);
		args.unshift('MODE');
		this.client.lib.send.apply(this.client.lib, args);
	}
	/**
	 * @param {string} message
	 * @returns {void}
	 */
	tellToChannel(message) {
		this.client.tell(this.settings.channel, message, this.settings.prefix);
	}
	/**
	* @param {string} target
	* @param {string} message
	 * @returns {void}
	 */
	notify(target, message) {
		this.client.notify(target, message, this.settings.prefix);
	}
	/**
	 * @param {string[]} multiline
	 * @returns {void}
	 */
	tellScore(multiline) {
		let title = colors.inverse(` SCORE `);
		let output = [];
		if (this.gameActive) {
			let ranking = this.players.getRanking().map(player => {
				return `${player.position}. ${player.nick} (${player.score})`;
			});
			if (multiline) {
				output.push(`${title}`);
				output.concat(ranking);
			} else {
				output.push(`${title} ${ranking.join(', ')}`);
			}
		} else {
			output.push(`${title} No active game.`);
		}
		this.tellToChannel(output);
	}
	/**
	 * @param {string} target
	 * @param {string} category
	 * @returns {void}
	 */
	tellHelp(target, category) {
		this.notify(target, this.help.getArray(category));
	}
};