'use strict'; const config = require('config'); const crypto = require('crypto'); const timers = require('timers/promises'); const { create, parse, addDependencies, divideDependencies, evaluateDependencies, } = 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 { parse: limitedParse, divide: mathDivide, import: mathImport } = create({ addDependencies, divideDependencies, evaluateDependencies, }); class FractionError extends Error { constructor(message) { super(message); this.name = 'FractionError'; } } function divide(a, b) { const result = mathDivide(a, b); if (result % 1) { throw new FractionError(`${style.bold(style.code(`${a} / ${b}`))} results in a fraction`); } return result; } mathImport({ 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 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] === 10 && 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] === 10) { // 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 pickBig() { 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 === 'big') { game.numbers = game.numbers.concat(pickBig()); } if (type === 'small') { game.numbers = game.numbers.concat(pickSmall()); } if (type !== 'small' && type !== 'big') { type.toLowerCase().slice(0, config.numbers.length - game.numbers.length).split('').forEach((typeKey) => { game.numbers = game.numbers.concat(typeKey === 'b' || typeKey === 'l' ? pickBig() : pickSmall()); }); } if (game.numbers.length === config.numbers.length) { play(context); return; } context.sendMessage(`${getBoard(context)} Would you like a big number or a small one?`, context.room.id); } function playSolution(solution, context) { const game = games.get(context.room.id); try { const parsed = limitedParse(solution.replace(/`/g, '')); // backticks may be used to prevent the expression from being expanded by Markdown in SChat const numbers = parsed.filter((node) => 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 === 'FractionError') { context.sendMessage(`${error.message}, which is not allowed, ${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 big number or a small one?', context.room.id); } function solve(calculation, context) { try { const parsed = 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); } } function onCommand(args, context) { if (context.subcommand === 'stop') { stop(context, true); return; } if (['calculate', 'calc', '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 if (game?.state === 'pick') { const multi = body.match(/\b[bls]{2,}\b/i)?.[0]; if (multi) { pickNumbers(multi.toLowerCase(), context); return; } if (/big/i.test(body)) { pickNumbers('big', context); return; } if (/small/i.test(body)) { pickNumbers('small', context); return; } } if (game?.state === 'solutions') { playSolution(body, context); } } module.exports = { onCommand, onMessage, commands: ['numsgo', 'numgo', 'calculate', 'calc', 'solve'], help: `Reach the target number using only the ${config.numbers.length} numbers on the board; you do not have to use all of them. You may use addition, subtraction, multiplication and divisions, but each calculation must result in a whole number. You can score points for solutions up to ${config.numbers.points.length} away from the target, 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 };