schat2-clive/src/games/trivia.js

264 lines
9.2 KiB
JavaScript
Executable File
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use strict';
const config = require('config');
const timers = require('timers/promises');
const { decode } = require('html-entities');
const questions = require('../../assets/jeopardy.json');
const shuffle = require('../utils/shuffle');
const style = require('../utils/style');
const getLeaders = require('../utils/get-leaders');
const settings = { ...config.trivia };
const help = {
mode: '\'first\' or \'timeout\'',
rounds: 'rounds per game as a number',
timeout: 'seconds as a number',
};
// let game = null;
const games = new Map();
function scoreRound(context, round) {
const game = games.get(context.room.id);
if (game.answers.size === 0) {
return `No one scored in round ${round + 1}, better luck next time!`;
}
return Array.from(game.answers.values()).map(({ user }) => {
if (user) {
context.setPoints(user, 1);
game.points[user.username] = (game.points[user.username] || 0) + 1;
return `${style.bold(style.cyan(`${config.usernamePrefix}${user.username}`))} gets a point`;
}
return null;
}).filter(Boolean).join(', ');
}
async function playRound(context, round = 0) {
const game = games.get(context.room.id);
const now = new Date();
game.answers = new Map();
game.round = round;
game.skipped = null;
game.ac = new AbortController(); // eslint-disable-line no-undef
const question = game.questions[round];
// ASK QUESTION
context.sendMessage(`${style.bold(style.pink(`Question ${round + 1}/${game.questions.length}`))} ${style.grey(`(${question.category})`)}: ${question.question}`, context.room.id);
context.logger.info(`Trivia asked "${question.question}" with answer: ${question.answer}`);
try {
// SEND HINTS
await timers.setTimeout((game.timeout / 3) * 1000, null, {
signal: game.ac.signal,
});
// replace space with U+2003 Em Space to separate words, since a single space separates the placeholders, and double spaces are removed during Markdown render
if (question.answer.length >= 3) {
context.sendMessage(`${style.bold(style.green(`${Math.floor(game.timeout / 3) * 2} seconds`))} left, first hint for ${style.bold(style.pink(`question ${round + 1}/${game.questions.length}`))}: ${style.bold(`${question.answer.slice(0, 1)} ${question.answer.slice(1).replace(/\s/g, '').replace(/[a-zA-Z0-9]/g, '_ ').trim()}`)}`, context.room.id);
} else {
// giving the first letter gives too much away, only give the placeholders
context.sendMessage(`${style.bold(style.green(`${Math.floor(game.timeout / 3) * 2} seconds`))} left, first hint for ${style.bold(style.pink(`question ${round + 1}/${game.questions.length}`))}: ${style.bold(`${question.answer.replace(/\s/g, '').replace(/[^a-zA-Z0-9]/g, '_ ').trim()}`)}`, context.room.id);
}
await timers.setTimeout((game.timeout / 3) * 1000, null, {
signal: game.ac.signal,
});
if (question.answer.length >= 4) {
context.sendMessage(`${style.bold(style.green(`${Math.floor(game.timeout / 3)} seconds`))} left, second hint for ${style.bold(style.pink(`question ${round + 1}/${game.questions.length}`))}: ${style.bold(`${question.answer.slice(0, 1)} ${question.answer.slice(1, -1).replace(/\s/g, '').replace(/[a-zA-Z]/g, '_ ')}${question.answer.slice(-1)}`)}`, context.room.id);
}
await timers.setTimeout((game.timeout / 3) * 1000, null, {
signal: game.ac.signal,
});
} catch (error) {
// abort expected, probably not an error
}
/* not sure why this was deemed necessary, the timeouts should be either finalized or aborted already
if (!ac.signal.aborted) {
ac.abort();
}
*/
// EVALUATE RESULTS
if (game.stopped) {
context.sendMessage(`The game was stopped by ${style.cyan(game.stopped)}. The answer to the last question was: ${style.bold(question.answer)}. Best players: ${getLeaders(game.points)}`, context.room.id);
games.delete(context.room.id);
return;
}
if (game.skipped) {
context.sendMessage(`The question was skipped by ${style.cyan(game.skipped)}. The answer was: ${style.bold(question.answer)}.`, context.room.id);
} else if (game.answers.size === 0) {
context.sendMessage(`${style.bold(style.red('TIME\'S UP!'))} No one guessed the answer: ${style.bold(question.answer)}`, context.room.id);
} else {
const scores = scoreRound(context, round);
if (game.mode === 'first') {
context.sendMessage(`${style.bold(style.yellow(question.fullAnswer || question.answer))} is the right answer, played in ${style.bold(style.green(`${((new Date() - now) / 1000).toFixed(3)}s`))}! ${scores}`, context.room.id);
}
if (game.mode === 'timeout') {
context.sendMessage(`${style.bold(style.red('STOP!'))} The correct answer is ${style.bold(style.green(question.fullAnswer || question.answer))}. ${scores}`, context.room.id);
}
}
// COOL DOWN AND RESTART
if (round < game.questions.length - 1) {
// present abort controller is expended if the question was answered or skipped
game.ac = new AbortController(); // eslint-disable-line no-undef
try {
await timers.setTimeout(5000, null, {
signal: game.ac.signal,
});
} catch (error) {
// abort expected, probably not an error
}
if (game.stopped) {
// stop was used between questions
context.sendMessage(`The game was stopped by ${style.cyan(game.stopped)}. The answer to the last question was: ${style.bold(question.answer)}. Best players: ${getLeaders(game.points)}`, context.room.id);
games.delete(context.room.id);
return;
}
playRound(context, round + 1);
return;
}
await timers.setTimeout(2000);
context.sendMessage(`That's the end of the game! Best players: ${getLeaders(game.points)}`, context.room.id);
games.delete(context.room.id);
}
async function start(context) {
const roundQuestions = shuffle(questions, settings.rounds);
games.set(context.room.id, {
round: 0,
questions: roundQuestions,
answers: new Map(),
points: {},
...settings,
});
playRound(context, 0);
}
async function stop(context) {
const game = games.get(context.room.id);
game.stopped = context.user.prefixedUsername;
game.ac.abort();
}
async function skip(context) {
const game = games.get(context.room.id);
// game.stopped = context.user;
game.skipped = context.user.prefixedUsername;
game.ac.abort();
}
function onCommand(args, context) {
const game = games.get(context.room.id);
if (!context.subcommand && !game) {
start(context);
return;
}
if (!context.subcommand && game) {
context.sendMessage(`There is already a game going on! Use ${config.prefix}trivia:stop to reset. The current question for round ${game.round + 1} is: ${game.questions[game.round].question}`, context.room.id);
return;
}
if (context.subcommand === 'stop' && game) {
stop(context);
return;
}
if (context.subcommand === 'skip' && game) {
skip(context);
return;
}
if (['stop', 'skip'].includes(context.subcommand) && !game) {
context.sendMessage(`There is no game going on at the moment. Start one with ${config.prefix}trivia!`, context.room.id);
return;
}
const subcommand = context.subcommand?.toLowerCase();
if (subcommand && settings[subcommand]) {
if (args[0]) {
const bounds = config.trivia.bounds[subcommand];
const curatedSetting = typeof settings[subcommand] === 'number' ? Number(args[0]) : args[0];
if (Number.isNaN(curatedSetting)) {
context.sendMessage(`${subcommand} must be a valid number`, context.room.id);
}
if (Array.isArray(bounds) && typeof settings[subcommand] === 'number' && (curatedSetting < bounds[0] || curatedSetting > bounds[1])) {
context.sendMessage(`${subcommand} must be between ${bounds[0]} and ${bounds[1]}`, context.room.id);
return;
}
settings[subcommand] = curatedSetting;
context.sendMessage(`${subcommand} set to ${settings[subcommand]}`, context.room.id);
} else if (help[subcommand]) {
context.sendMessage(`Please give ${help[subcommand]}`, context.room.id);
}
}
}
async function onMessage(message, context) {
const game = games.get(context.room?.id);
if (!game || context.user?.id === config.user?.id) {
return;
}
const { answer, fullAnswer } = game.questions[game.round];
if (new RegExp(answer, 'i').test(decode(message.originalBody || message.body)) && !game.answers.has(context.user.id)) { // resolve HTML entities in case original body is not available, such as &amp; to &
game.answers.set(context.user.id, {
user: context.user,
answer: message.body,
});
if (settings.mode === 'first') {
game.ac.abort();
}
if (settings.mode === 'timeout' && !game.ac.signal.aborted) {
if (message.type === 'message') {
context.sendMessage(`${style.bold(fullAnswer || answer)} is the correct answer! You might want to ${style.bold('/whisper')} the answer to me instead, so others can't leech off your impeccable knowledge. You will receive a point at the end of the round.`, context.room.id, null, context.user.username);
} else {
context.sendMessage(`${style.bold(fullAnswer || answer)} is the correct answer! You will receive a point at the end of the round.`, context.room.id, null, context.user.username);
}
}
}
}
module.exports = {
name: 'Trivia',
onCommand,
onMessage,
help: `Boast your pointless knowledge! Start a game with ${config.prefix}trivia. Available subcommands are :stop, :mode [first|timeout], :rounds [${config.trivia.bounds.rounds[0]}-${config.trivia.bounds.rounds[1]}] and :timeout [${config.trivia.bounds.timeout[0]}-${config.trivia.bounds.timeout[1]}]`,
};