diff --git a/src/games/letters.js b/src/games/letters.js index 65ab802..5341d91 100755 --- a/src/games/letters.js +++ b/src/games/letters.js @@ -85,14 +85,14 @@ async function play(context) { game.state = 'words'; game.counts = countLetters(game.word); - context.sendMessage(`${getBoard(context)} Let's start!`, context.room.id); + context.sendMessage(`${getBoard(context)} You have ${style.bold(style.green(settings.timeout))}, let's start!`, context.room.id); try { await timers.setTimeout((settings.timeout / 3) * 1000, null, { signal: game.ac.signal }); - context.sendMessage(`${getBoard(context)} ${style.bold(style.green(`${Math.round((settings.timeout / 3) * 2)} seconds`))} left`, context.room.id); + 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)} ${style.bold(style.green(`${Math.round(settings.timeout / 3)} seconds`))} left`, context.room.id); + 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 }); context.sendMessage(`${style.bold('Time\'s up!')} Best players: ${getLeaders(game.points)}`, context.room.id); diff --git a/src/games/numbers.js b/src/games/numbers.js index 72a5863..365ba18 100644 --- a/src/games/numbers.js +++ b/src/games/numbers.js @@ -6,6 +6,7 @@ const timers = require('timers/promises'); const { create, + parse, addDependencies, divideDependencies, evaluateDependencies, @@ -16,15 +17,35 @@ const pickRandom = require('../utils/pick-random'); const style = require('../utils/style'); const { solveAll } = require('../utils/numbers-solver'); -const games = new Map(); - -const math = create({ +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) { @@ -68,6 +89,39 @@ 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); @@ -87,19 +141,15 @@ function stop(context, aborted) { ? `The game was stopped by ${style.cyan(`${config.usernamePrefix}${context.user.username}`)}.` : style.bold('Time\'s up!'); - const winnerText = game.winner - ? `The winner is ${style.bold(game.winner.prefixedUsername)}, who gets ${style.bold(game.points[game.winner.username])} points.` - : 'No one found a solution.'; + 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 solution = game.solution.answer === game.target - ? `The target ${getTarget(game)} ${game.points[game.winner?.username] === 10 ? 'was' : 'could be'} solved as follows: ${style.bold(style.code(game.winner?.solution || game.solution.solution))}.` - : `The target ${getTarget(game)} was impossible, the closest answer is ${style.bold(game.solution.answer)} as follows: ${style.bold(style.code(game.solution.solution))}.`; + const solutionText = getSolutionText(game); - context.sendMessage([wrapText, winnerText, leaders, solution].filter(Boolean).join(' '), context.room.id); + context.sendMessage([wrapText, winnerText, leaders, solutionText].filter(Boolean).join(' '), context.room.id); } async function play(context) { @@ -118,10 +168,10 @@ async function play(context) { try { await timers.setTimeout((settings.timeout / 3) * 1000, null, { signal: game.ac.signal }); - context.sendMessage(`${getBoard(context)} ${style.bold(style.green(`${Math.round((settings.timeout / 3) * 2)} seconds`))} left`, context.room.id); + 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)} ${style.bold(style.green(`${Math.round(settings.timeout / 3)} seconds`))} left`, context.room.id); + 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 }); @@ -156,7 +206,7 @@ function pickNumbers(type, context) { 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' ? pickBig() : pickSmall()); + game.numbers = game.numbers.concat(typeKey === 'b' || typeKey === 'l' ? pickBig() : pickSmall()); }); } @@ -172,7 +222,7 @@ function playSolution(solution, context) { const game = games.get(context.room.id); try { - const parsed = math.parse(solution.replace(/`/g, '')); // backticks may be used to prevent the expression from being expanded by Markdown in SChat + 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]); @@ -196,7 +246,7 @@ function playSolution(solution, context) { 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 ${style.bold(game.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; @@ -227,7 +277,12 @@ function playSolution(solution, context) { context.sendMessage(`${summary} for ${style.bold('no')} points, ${context.user.prefixedUsername}`, context.room.id); } catch (error) { - // invalid answer + // 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); } } @@ -255,12 +310,28 @@ function start(context, numbers) { 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; @@ -276,7 +347,7 @@ function onMessage(message, context) { const body = message.originalBody || message.body; // * gets resolved to if (game?.state === 'pick') { - const multi = body.match(/\b[bs]{2,}\b/i)?.[0]; + const multi = body.match(/\b[bls]{2,}\b/i)?.[0]; if (multi) { pickNumbers(multi.toLowerCase(), context); @@ -302,5 +373,6 @@ function onMessage(message, context) { module.exports = { onCommand, onMessage, - commands: ['numsgo', 'numgo'], + 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 };