schat2-clive/src/games/numbers.js

460 lines
16 KiB
JavaScript
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.

/* eslint-disable max-classes-per-file */
'use strict';
const config = require('config');
const crypto = require('crypto');
const timers = require('timers/promises');
const math = require('mathjs');
const getLeaders = require('../utils/get-leaders');
const pickRandom = require('../utils/pick-random');
const style = require('../utils/style');
const { solveAll } = require('../utils/numbers-solver');
const limitedMath = math.create({
addDependencies: math.addDependencies,
divideDependencies: math.divideDependencies,
evaluateDependencies: math.evaluateDependencies,
});
class RuleError extends Error {
constructor(message) {
super(message);
this.name = 'RuleError';
}
}
function divide(a, b) {
const result = math.divide(a, b);
if (result % 1) {
throw new RuleError(`The division ${style.bold(style.code(`${a} / ${b}`))} results in a fraction, which is not allowed`);
}
return result;
}
limitedMath.import({
divide,
}, { override: true });
const settings = { ...config.numbers };
const games = new Map();
/* eslint-disable no-irregular-whitespace */
function padNumber(number) {
if (!number) {
return '';
}
const string = String(number);
if (string.length === 3) {
return string;
}
if (string.length === 2) {
return `${string}`;
}
return `${string}`;
}
function getTarget(game) {
return `${style.grey(style.code('['))}${style.bold(style.code(game.target))}${style.grey(style.code(']'))}`;
}
function getBoardNumbers(game) {
// return `\`[\`${game.numbers.concat(Array.from({ length: config.numbers.length - game.numbers.length })).map((number) => style.bold(`\`${padNumber(number)}\``)).join('`|`')}\`]\``;
return `${style.grey(style.code('['))}${game.numbers.concat(Array.from({ length: config.numbers.length - game.numbers.length })).map((number) => style.bold(style.code(padNumber(number)))).join(style.grey(style.code('|')))}${style.grey(style.code(']'))}`;
}
function getBoard(context) {
const game = games.get(context.room.id);
if (game.target) {
return `${getBoardNumbers(game)} and the target is ${getTarget(game)}`;
}
return getBoardNumbers(game);
}
function countNumbers(numbers) {
return numbers.reduce((acc, number) => ({ ...acc, [number]: (acc[number] || 0) + 1 }), {});
}
function setTimeout(timeout, context) {
if (!timeout) {
context.sendMessage(`Timeout is set to ${style.bold(settings.timeout)}`, context.room.id);
return;
}
const parsedTimeout = Number(timeout);
if (Number.isNaN(parsedTimeout) || parsedTimeout < 10 || parsedTimeout > 300) {
context.sendMessage('Timeout must be a number between 10 and 300', context.room.id);
return;
}
settings.timeout = parsedTimeout;
context.sendMessage(`Timeout set to ${style.bold(settings.timeout)} by ${context.user.prefixedUsername}`, context.room.id);
}
function getWinnerText(game) {
if (game.state === 'pick') {
return null;
}
if (game.winner) {
return `The winner is ${style.bold(game.winner.prefixedUsername)}, who gets ${style.bold(game.points[game.winner.username])} points.`;
}
return 'No one found a solution.';
}
function getSolutionText(game) {
if (game.points[game.winner?.username] === config.numbers.points[0] && game.solution?.answer === game.target) {
return `The target ${getTarget(game)} was solved as follows: ${style.bold(style.code(game.winner.solution))}. My solution was ${style.bold(style.code(game.solution.solution))}.`;
}
if (game.points[game.winner?.username] === config.numbers.points[0]) {
// the winner somehow found a solution the solver did not
return `The target ${getTarget(game)} was solved as follows: ${style.bold(style.code(game.winner.solution))}. Even I couldn't get that, well done!`;
}
if (game.solution?.answer === game.target) {
return `The target ${getTarget(game)} could be solved as follows: ${style.bold(style.code(game.solution.solution))}.`;
}
if (game.solution) {
return `The target ${getTarget(game)} was impossible, the closest answer is ${style.bold(game.solution.answer)} as follows: ${style.bold(style.code(game.solution.solution))}.`;
}
return null;
}
function stop(context, aborted) {
const game = games.get(context.room.id);
if (!game) {
context.sendMessage(`There is no numbers game going on, ${context.user.prefixedUsername}. You can start one with ${config.prefix}numbers!`, context.room.id);
return;
}
game.ac.abort();
games.delete(context.room.id);
if (game.winner) {
context.setPoints(game.winner, game.points[game.winner.username]);
}
const wrapText = aborted
? `The game was stopped by ${style.cyan(`${config.usernamePrefix}${context.user.username}`)}.`
: style.bold('Time\'s up!');
const winnerText = getWinnerText(game);
const leaders = Object.keys(game.points).length > 1
? `Honorary mentions: ${getLeaders(game.points, context.user, { skip: [game.winner.username] })}.`
: null;
const solutionText = getSolutionText(game);
context.sendMessage([wrapText, winnerText, leaders, solutionText].filter(Boolean).join(' '), context.room.id);
}
async function play(context) {
const game = games.get(context.room.id);
game.counts = countNumbers(game.numbers);
game.target = crypto.randomInt(100, 999);
game.solutions = await solveAll(game.numbers, game.target, 3);
game.solution = game.solutions.reduce((closest, solution) => (!closest || Math.abs(game.target - solution.answer) < Math.abs(game.target - closest.answer) ? solution : closest), null);
game.state = 'solutions';
context.sendMessage(`${getBoard(context)}. You have ${style.bold(style.green(settings.timeout))} seconds, let's start!`, context.room.id);
context.logger.info(`Numbers game started by ${context.user.username}, numbers ${game.numbers.join('|')}, target ${game.target}, ${game.solution.answer === game.target ? 'exact' : 'closest'} solution for ${game.solution.answer} is: ${game.solution.solution}`);
try {
await timers.setTimeout((settings.timeout / 3) * 1000, null, { signal: game.ac.signal });
context.sendMessage(`${getBoard(context)}. You have ${style.bold(style.green(`${Math.round((settings.timeout / 3) * 2)} seconds`))} left`, context.room.id);
await timers.setTimeout((settings.timeout / 3) * 1000, null, { signal: game.ac.signal });
context.sendMessage(`${getBoard(context)}. You have ${style.bold(style.green(`${Math.round(settings.timeout / 3)} seconds`))} left`, context.room.id);
await timers.setTimeout((settings.timeout / 3) * 1000, null, { signal: game.ac.signal });
stop(context);
} catch (error) {
// abort expected, probably not an error
}
}
function pickLarge() {
return pickRandom([100, 75, 50, 25]);
}
function pickSmall() {
return pickRandom([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
}
function pickNumbers(type, context) {
const game = games.get(context.room.id);
if (!game) {
return;
}
if (type === 'large') {
game.numbers = game.numbers.concat(pickLarge());
}
if (type === 'small') {
game.numbers = game.numbers.concat(pickSmall());
}
if (type !== 'small' && type !== 'large') {
type.toLowerCase().slice(0, config.numbers.length - game.numbers.length).split('').forEach((typeKey) => {
game.numbers = game.numbers.concat(['b', 'l', 't'].includes(typeKey) ? pickLarge() : pickSmall());
});
}
if (game.numbers.length === config.numbers.length) {
play(context);
return;
}
context.sendMessage(`${getBoard(context)} Would you like a large number or a small one?`, context.room.id);
}
function playSolution(solution, context) {
const game = games.get(context.room.id);
try {
const parsed = limitedMath.parse(solution.replace(/`/g, '')); // backticks may be used to prevent the expression from being expanded by Markdown in SChat
const numbers = parsed.filter((node) => {
if (node.type === 'FunctionNode' && !['add', 'subtract', 'multiply', 'divide'].includes(node.fn.name)) {
throw new RuleError(`The ${style.bold(node.name)} function is not allowed`);
}
if (node.op && !['add', 'subtract', 'multiply', 'divide'].includes(node.fn)) {
throw new RuleError(`The ${style.bold(node.op)} operator is not allowed`);
}
return node.type === 'ConstantNode';
}).map((node) => node.value);
const counts = countNumbers(numbers);
const imagined = Object.keys(counts).filter((number) => !game.counts[number]);
const overused = Object.entries(counts).filter(([number, count]) => count > game.counts[number]).map(([number]) => number);
if (imagined.length > 0) {
context.sendMessage(`You are using ${imagined.map((number) => style.bold(number)).join(' and ')} not on the board, ${context.user.prefixedUsername}`, context.room.id);
return;
}
if (overused.length > 0) {
context.sendMessage(`You are using ${overused.map((number) => style.bold(number)).join(' and ')} too often, ${context.user.prefixedUsername}`, context.room.id);
return;
}
const answer = parsed.evaluate();
const distance = Math.abs(game.target - answer);
const points = config.numbers.points[distance];
const lastScore = game.points[context.user.username] || 0;
const highestScore = Object.values(game.points).reduce((acc, userPoints) => (userPoints > acc ? userPoints : acc), 0);
const summary = distance === 0
? `Your solution ${style.bold(style.code(parsed))} results in ${style.bold(answer)}, which is ${style.bold('exactly on target')}`
: `Your solution ${style.bold(style.code(parsed))} results in ${style.bold(answer)}, which is ${style.bold(distance)} away from target ${getTarget(game)}`;
if (points && points > lastScore) {
game.points[context.user.username] = points;
}
if (points && points > highestScore) {
game.winner = {
...context.user,
solution: parsed,
};
context.sendMessage(`${summary} for ${style.bold(points)} points, the current winner, ${context.user.prefixedUsername}`, context.room.id);
return;
}
if (points && points > lastScore) {
context.sendMessage(`${summary} for ${style.bold(points)} points, your personal best, but not beating the winning ${style.bold(highestScore)} points, ${context.user.prefixedUsername}`, context.room.id);
return;
}
if (points) {
context.sendMessage(`${summary} for ${style.bold(points)} points, but does not beat your personal best of ${style.bold(lastScore)} points, ${context.user.prefixedUsername}`, context.room.id);
return;
}
context.sendMessage(`${summary} for ${style.bold('no')} points, ${context.user.prefixedUsername}`, context.room.id);
} catch (error) {
// invalid solution
if (error.name === 'RuleError') {
context.sendMessage(`${error.message}, ${context.user.prefixedUsername}`, context.room.id);
return;
}
context.logger.error(error);
}
}
function start(context, numbers) {
if (games.has(context.room.id)) {
context.sendMessage(`${getBoard(context)} is the current board. Use ${config.prefix}numbers:stop to reset.`, context.room.id);
return;
}
games.set(context.room.id, {
state: 'pick',
numbers: [],
target: null,
points: {},
winner: null,
ac: new AbortController(), // eslint-disable-line no-undef
});
if (numbers) {
pickNumbers(numbers, context);
return;
}
context.sendMessage('Let\'s play the numbers! Would you like a large number or a small one?', context.room.id);
}
function calc(calculation, context) {
try {
const parsed = math.parse(calculation.replace(/`/g, '')); // backticks may be used to prevent the expression from being expanded by Markdown in SChat
const answer = parsed.evaluate();
context.sendMessage(`${style.bold(style.code(parsed))} = ${style.bold(answer)}`, context.room.id);
} catch (error) {
context.sendMessage(`Failed to solve ${style.bold(style.code(calculation))}: ${error.message}`, context.room.id);
}
}
async function solve(equation, context) {
const [numberString, targetString] = equation.split('=');
const numbers = numberString?.split(' ').map((string) => Number(string)).filter(Boolean);
const target = Number(targetString);
if (!numberString || !target) {
context.sendMessage('The input should be in the form 1 2 3 4 5 6 7 = 10', context.room.id);
return;
}
if (numbers.length < 2 || numbers.length > 7) {
context.sendMessage('The selection should contain at least 2 and at most 7 numbers', context.room.id);
return;
}
if (target < 100 || target > 999) {
context.sendMessage('The target must be a number between 100 and 999', context.room.id);
return;
}
if (Array.from(games.entries()).some(([roomId, game]) => roomId === context.room.id || game.target === target)) {
context.sendMessage('Nice try! Please wait for this numbers round to end :)', context.room.id);
return;
}
try {
const solutions = await solveAll(numbers, target, 3);
const bestSolution = solutions.reduce((closest, solution) => (!closest || Math.abs(target - solution.answer) < Math.abs(target - closest.answer) ? solution : closest), null);
if (!bestSolution) {
context.sendMessage(`I could not find a solution for ${numbers.join(' ')} = ${target} :(`, context.room.id);
return;
}
if (bestSolution.answer === target) {
context.sendMessage(`My best solution for ${numbers.join(' ')} = ${target} is: ${style.bold(bestSolution.solution)}`, context.room.id);
return;
}
context.sendMessage(`I could not find an exact solution for ${numbers.join(' ')} = ${target}. My closest solution is: ${style.bold(bestSolution.solution)} = ${style.italic(bestSolution.answer)}`, context.room.id);
} catch (error) {
console.log(error);
}
}
function onCommand(args, context) {
if (context.subcommand === 'stop') {
stop(context, true);
return;
}
if (context.subcommand === 'timeout') {
setTimeout(args[0], context);
return;
}
if (['calculate', 'calc'].includes(context.subcommand || context.command)) {
calc(args.join(' '), context); // two from the top, four from the bottom, please Rachel
return;
}
if (['solve'].includes(context.subcommand || context.command)) {
solve(args.join(' '), context); // two from the top, four from the bottom, please Rachel
return;
}
if (['numsgo', 'numgo'].includes(context.command) || ['start', 'go', 'auto', 'random'].includes(context.subcommand)) {
start(context, 'bbssss'); // two from the top, four from the bottom, please Rachel
return;
}
if (!args.subcommand) {
start(context);
}
}
function onMessage(message, context) {
const game = games.get(context.room?.id);
const body = message.originalBody || message.body; // * gets resolved to <em>
if (game?.state === 'pick') {
const multi = body.match(/\b[blts]{2,}$/i)?.[0];
if (multi) {
pickNumbers(multi.toLowerCase(), context);
return;
}
if (/large|big|top/i.test(body)) {
pickNumbers('large', context);
return;
}
if (/small|bottom/i.test(body)) {
pickNumbers('small', context);
return;
}
}
if (game?.state === 'solutions' && body.match(/\d+/g)?.length > 1) {
playSolution(body, context);
}
}
module.exports = {
onCommand,
onMessage,
commands: ['nums', 'numsgo', 'numgo', 'calculate', 'calc', 'solve'],
help: `Reach the target number using only the ${config.numbers.length} numbers on the board. You can use each number only once, but do not have to use all of them. You may use addition, subtraction, multiplication and division, but each intermediate calculation must result in a whole number. You can score ${Array.from(new Set(config.numbers.points)).slice(0, -1).join(', ')} or ${config.numbers.points.at(-1)} points for solutions up to ${config.numbers.points.length - 1} away, but only the first best solution gets the points. Quick-start with ${config.prefix}numsgo, or use ${config.prefix}numbers to fill the board by manually selecting a random *large* or *big* number (100, 75, 50, 25), a random *small* number (1-10), or multiple at once like LLSSSS.`, // eslint-disable-line max-len
};