From 55343d5de7bbf6f8f657b35b696717128009b238 Mon Sep 17 00:00:00 2001 From: Niels Simenon Date: Fri, 21 Oct 2022 05:07:32 +0200 Subject: [PATCH] Added playable round to letters game. --- config/default.js | 1 + src/games/duck.js | 2 +- src/games/letters.js | 140 ++++++++++++++++++++++++++++++++------ src/games/mash.js | 13 ++-- src/games/trivia.js | 29 +++----- src/utils/get-leaders.js | 16 +++++ src/utils/get-word-key.js | 7 ++ 7 files changed, 160 insertions(+), 48 deletions(-) create mode 100644 src/utils/get-leaders.js create mode 100644 src/utils/get-word-key.js diff --git a/config/default.js b/config/default.js index e611f3b..2ea7932 100644 --- a/config/default.js +++ b/config/default.js @@ -35,5 +35,6 @@ module.exports = { }, letters: { length: 9, + timeout: 30, }, }; diff --git a/src/games/duck.js b/src/games/duck.js index 60a2758..8ba770e 100644 --- a/src/games/duck.js +++ b/src/games/duck.js @@ -50,7 +50,7 @@ function onCommand(args, context) { } const messages = [ - `How could you miss *that*, ${config.usernamePrefix}${context.user.username}...?!`, + `How could you miss ${style.italic('that')}, ${config.usernamePrefix}${context.user.username}...?!`, `That's a miss! Better luck next time, ${config.usernamePrefix}${context.user.username}.`, `The duck outsmarted you, ${config.usernamePrefix}${context.user.username}`, `Channeling Gareth Southgate, ${config.usernamePrefix}${context.user.username}? You missed!`, diff --git a/src/games/letters.js b/src/games/letters.js index e61053f..2475315 100644 --- a/src/games/letters.js +++ b/src/games/letters.js @@ -1,13 +1,19 @@ 'use strict'; const config = require('config'); +const timers = require('timers/promises'); const shuffle = require('../utils/shuffle'); const style = require('../utils/style'); +const getLeaders = require('../utils/get-leaders'); +const getWordKey = require('../utils/get-word-key'); +const words = require('../../assets/mash-words.json'); const availableVowels = ['a', 'e', 'i', 'o', 'u']; const availableConsonants = ['b', 'c', 'd', 'f', 'g', 'h', 'j', 'k', 'l', 'm', 'n', 'p', 'q', 'r', 's', 't', 'v', 'w', 'x', 'y', 'z']; // Countdown regards y as a consonant +const types = { v: 'vowels', c: 'consonants' }; +const settings = { ...config.letters }; const games = new Map(); function getBoard(context) { @@ -16,6 +22,10 @@ function getBoard(context) { return `${game.word.split('').map((letter) => `${style.grey('[')}${letter.toUpperCase()}${style.grey(']')}`).join('')}${`${style.silver('[')} ${style.silver(']')}`.repeat(config.letters.length - game.word.length)}`; } +function countLetters(word) { + return word.split('').reduce((counts, letter) => ({ ...counts, [letter]: (counts[letter] || 0) + 1 }), {}); +} + function shuffleLetters(letters, acc = []) { const shuffled = shuffle(letters, config.letters.length); @@ -26,21 +36,93 @@ function shuffleLetters(letters, acc = []) { return [...acc, ...shuffled].slice(0, config.letters.length); } -function playRound(context) { - context.sendMessage(`${getBoard(context)} Let's start!`, context.room.id); +function playWord(rawWord, context) { + const game = games.get(context.room.id); + const word = rawWord.trim().toLowerCase(); + + if (!word || word.length > game.word.length) { + return; + } + + if (game.played.has(word)) { + context.logger.debug(`${context.user.username} played a word that was already played: ${word}`); + return; + } + + if (!words[word.length]?.[getWordKey(word)]?.some((definition) => definition.word === word)) { + context.logger.debug(`${context.user.username} played word not in the dictionary: ${word}`); + return; + } + + const counts = countLetters(word); + const invalid = Object.entries(counts).filter(([letter, count]) => !game.counts[letter] || game.counts[letter] < count).map(([letter]) => letter); + + if (invalid.length > 0) { + context.logger.debug(`${context.user.username} played '${word}' containing letters that are not on the board: ${invalid.join(' ')}`); + return; + } + + context.setPoints(context.user, word.length); + game.points[context.user.username] = (game.points[context.user.username] || 0) + word.length; + + game.played.add(word); + + context.sendMessage(`${context.user.username} played ${style.bold(style.pink(word))} for ${style.bold(word.length)} points!`, context.room.id); } -function pickLetter(type, context) { +function stop(context, aborted) { + const game = games.get(context.room.id); + + game.ac.abort(); + games.delete(context.room.id); + + if (aborted) { + context.sendMessage(`The game was stopped by ${style.cyan(`${config.usernamePrefix}${context.user.username}`)}. Best players: ${getLeaders(game.points)}`, context.room.id); + } +} + +async function play(context) { + const game = games.get(context.room.id); + + game.state = 'words'; + game.counts = countLetters(game.word); + + context.sendMessage(`${getBoard(context)} 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); + + 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); + + await timers.setTimeout((settings.timeout / 3) * 1000, null, { signal: game.ac.signal }); + context.sendMessage(`Time's up! Best players: ${getLeaders(game.points)}`, context.room.id); + + stop(context); + } catch (error) { + // abort expected, probably not an error + } +} + +function pickLetters(type, context) { const game = games.get(context.room.id); if (!game || game.word.length === config.letters.length) { return; } - game.word = `${game.word}${game[type].pop()}`; + if (type === 'consonants' || type === 'vowels') { + game.word = `${game.word}${game[type].pop()}`; + } else { + type.toLowerCase().slice(0, config.letters.length - game.word.length).split('').forEach((typeKey) => { + game.word = `${game.word}${game[types[typeKey]].pop()}`; + }); + } if (game.word.length === config.letters.length) { - playRound(context); + play(context); + return; } @@ -54,43 +136,63 @@ function start(context) { } games.set(context.room.id, { + state: 'letters', word: '', vowels: shuffleLetters(availableVowels), consonants: shuffleLetters(availableConsonants), + played: new Set(), + points: {}, + ac: new AbortController(), // eslint-disable-line no-undef }); context.sendMessage('Let\'s play the letters! Would you like a consonant or a vowel?', context.room.id); } -function stop(context) { - games.delete(context.room.id); - - context.sendMessage('The game is stopped and reset', context.room.id); -} - function onCommand(args, context) { - if (args.subcommand === 'stop') { - stop(context); + if (context.subcommand === 'stop') { + stop(context, true); return; } - if (!args.subcommand) { + if (['help', 'commands'].includes(context.subcommand)) { + context.sendMessage('Make the longest word using the available letters. To pick the letters, say con(sonant), vow(el) or supply multiple: CCCCCVVVV. Available subcommands: :stop', context.room.id); + return; + } + + if (!context.subcommand) { start(context); } } function onMessage(message, context) { - if (/(^|\b)cons?(onant)?($|\b)/i.test(message.body)) { - pickLetter('consonants', context); - return; + const game = games.get(context.room.id); + + if (game?.state === 'letters') { + const multi = message.body.match(/\b[vc]{2,}\b/i)?.[0]; + + if (multi) { + pickLetters(multi.toLowerCase(), context); + return; + } + + if (/(^|\b)cons?(onant)?($|\b)/i.test(message.body)) { + pickLetters('consonants', context); + return; + } + + if (/(^|\b)vow(el)?($|\b)/i.test(message.body)) { + pickLetters('vowels', context); + return; + } } - if (/(^|\b)vow(el)?($|\b)/i.test(message.body)) { - pickLetter('vowels', context); + if (game?.state === 'words') { + playWord(message.body, context); } } module.exports = { + name: 'Letters', onCommand, onMessage, }; diff --git a/src/games/mash.js b/src/games/mash.js index 2ce1e7a..5cd3b85 100644 --- a/src/games/mash.js +++ b/src/games/mash.js @@ -3,6 +3,7 @@ const config = require('config'); const style = require('../utils/style'); +const getWordKey = require('../utils/get-word-key'); const words = require('../../assets/mash-words.json'); const mashes = new Map(); @@ -10,10 +11,6 @@ const mashes = new Map(); const defineCommands = ['define', 'dict', 'dictionary']; const resolveCommands = ['solve', 'resolve', 'lookup']; -function getWordKey(word) { - return word.split('').sort().join(''); -} - function start(length, context, attempt = 0) { const mash = mashes.get(context.room.id); const lengthWords = words[length]; @@ -51,13 +48,13 @@ function start(length, context, attempt = 0) { const newMash = mashes.get(context.room.id); - context.sendMessage(`Stomp stomp, here's your mash: ${style.bold(style.purple(newMash.anagram))}`, context.room.id); + context.sendMessage(`Stomp stomp, here's your mash: ${style.bold(style.pink(newMash.anagram))}`, context.room.id); context.logger.info(`Mash started, '${anagram}' with answers ${answers.map((answer) => `'${answer.word}'`).join(', ')}`); } function play(rawWord, context, shouted) { const mash = mashes.get(context.room.id); - const word = rawWord.toLowerCase(); + const word = rawWord.trim().toLowerCase(); const key = getWordKey(word); const answer = mash.answers.find((answerX) => answerX.word === word); @@ -159,7 +156,7 @@ function hint(context) { } if (mash.anagram.length === 4) { - context.sendMessage(`Hints for ${style.bold(style.purple(mash.anagram))}, ${config.usernamePrefix}${context.user.username}: ${mash.answers.map((answer) => `${style.bold(`${answer.word.slice(0, 1)} ${'_ '.repeat(answer.word.length - 1).trim()}`)} (${answer.definitions[0]})`).join(', ')}`, context.room.id); + context.sendMessage(`Hints for ${style.bold(style.pink(mash.anagram))}, ${config.usernamePrefix}${context.user.username}: ${mash.answers.map((answer) => `${style.bold(`${answer.word.slice(0, 1)} ${'_ '.repeat(answer.word.length - 1).trim()}`)} (${answer.definitions[0]})`).join(', ')}`, context.room.id); return; } @@ -192,7 +189,7 @@ function onCommand(args, context) { } if (!word && mash) { - context.sendMessage(`The current mash is: ${style.bold(style.purple(mash.anagram))}`, context.room.id); + context.sendMessage(`The current mash is: ${style.bold(style.pink(mash.anagram))}`, context.room.id); return; } diff --git a/src/games/trivia.js b/src/games/trivia.js index 824a622..05ccebd 100644 --- a/src/games/trivia.js +++ b/src/games/trivia.js @@ -7,6 +7,7 @@ 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 = { @@ -37,18 +38,6 @@ function scoreRound(context, round) { }).filter(Boolean).join(', '); } -function getLeaders(context) { - const game = games.get(context.room.id); - - return Object.entries(game.points).sort(([, scoreA], [, scoreB]) => scoreB - scoreA).map(([username, score], index) => { - if (index === 0) { - return `${style.bold(`${config.usernamePrefix}${username}`)} with ${style.bold(`${score}`)} points`; - } - - return `${style.bold(style.cyan(`${config.usernamePrefix}${username}`))} with ${style.bold(`${score}`)} points`; - }).join(', '); -} - async function playRound(context, round = 0) { const game = games.get(context.room.id); const ac = new AbortController(); // eslint-disable-line no-undef @@ -60,7 +49,7 @@ async function playRound(context, round = 0) { const question = game.questions[round]; - context.sendMessage(`${style.bold(style.purple(`Question ${round + 1}/${game.questions.length}`))} ${style.silver(`(${question.category})`)}: ${question.question}`, context.room.id); + context.sendMessage(`${style.bold(style.pink(`Question ${round + 1}/${game.questions.length}`))} ${style.silver(`(${question.category})`)}: ${question.question}`, context.room.id); context.logger.info(`Trivia asked "${question.question}" with answer: ${question.answer}`); try { @@ -70,10 +59,10 @@ async function playRound(context, round = 0) { // 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.purple(`question ${round + 1}/${game.questions.length}`))}: ${style.bold(`${question.answer.slice(0, 1)} ${question.answer.slice(1).replace(/\s/g, ' ').replace(/[^\s]/g, '_ ').trim()}`)}`, context.room.id); + 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(/[^\s]/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.purple(`question ${round + 1}/${game.questions.length}`))}: ${style.bold(`${question.answer.replace(/\s/g, ' ').replace(/[^\s]/g, '_ ').trim()}`)}`, context.room.id); + 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(/[^\s]/g, '_ ').trim()}`)}`, context.room.id); } await timers.setTimeout((game.timeout / 3) * 1000, null, { @@ -81,7 +70,7 @@ async function playRound(context, round = 0) { }); if (question.answer.length >= 4) { - context.sendMessage(`${style.bold(style.green(`${Math.floor(game.timeout / 3)} seconds`))} left, second hint for ${style.bold(style.purple(`question ${round + 1}/${game.questions.length}`))}: ${style.bold(`${question.answer.slice(0, 1)} ${question.answer.slice(1, -1).replace(/\s/g, ' ').replace(/[^\s]/g, '_ ')}${question.answer.slice(-1)}`)}`, context.room.id); + 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(/[^\s]/g, '_ ')}${question.answer.slice(-1)}`)}`, context.room.id); } await timers.setTimeout((game.timeout / 3) * 1000, null, { @@ -96,7 +85,7 @@ async function playRound(context, round = 0) { } if (game.stopped) { - context.sendMessage(`The game was stopped by ${style.cyan(`${config.usernamePrefix}${game.stopped.username}`)}. The answer to the last question was: ${style.bold(question.answer)}. Best players: ${getLeaders(context)}`, context.room.id); + context.sendMessage(`The game was stopped by ${style.cyan(`${config.usernamePrefix}${game.stopped.username}`)}. 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; @@ -120,7 +109,7 @@ async function playRound(context, round = 0) { await timers.setTimeout(5000); if (game.stopped) { - context.sendMessage(`The game was stopped by ${config.usernamePrefix}${game.stopped.username}. The answer to the last question was: ${style.bold(question.answer)}. Best players: ${getLeaders(context)}`, context.room.id); + context.sendMessage(`The game was stopped by ${config.usernamePrefix}${game.stopped.username}. 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; @@ -132,7 +121,7 @@ async function playRound(context, round = 0) { await timers.setTimeout(2000); - context.sendMessage(`That's the end of the game! Best players: ${getLeaders(context)}`, context.room.id); + context.sendMessage(`That's the end of the game! Best players: ${getLeaders(game.points)}`, context.room.id); games.delete(context.room.id); } @@ -143,7 +132,7 @@ async function start(context) { games.set(context.room.id, { round: 0, questions: roundQuestions, - answers: [], + answers: new Map(), points: {}, ...settings, }); diff --git a/src/utils/get-leaders.js b/src/utils/get-leaders.js new file mode 100644 index 0000000..bcea75a --- /dev/null +++ b/src/utils/get-leaders.js @@ -0,0 +1,16 @@ +'use strict'; + +const config = require('config'); +const style = require('./style'); + +function getLeaders(points) { + return Object.entries(points).sort(([, scoreA], [, scoreB]) => scoreB - scoreA).map(([username, score], index) => { + if (index === 0) { + return `${style.bold(`${config.usernamePrefix}${username}`)} with ${style.bold(`${score}`)} points`; + } + + return `${style.bold(style.cyan(`${config.usernamePrefix}${username}`))} with ${style.bold(`${score}`)} points`; + }).join(', '); +} + +module.exports = getLeaders; diff --git a/src/utils/get-word-key.js b/src/utils/get-word-key.js new file mode 100644 index 0000000..13e5a15 --- /dev/null +++ b/src/utils/get-word-key.js @@ -0,0 +1,7 @@ +'use strict'; + +function getWordKey(word) { + return word.split('').sort().join(''); +} + +module.exports = getWordKey;