schat2-clive/src/games/letters.js

214 lines
7.1 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 style = require('../utils/style');
const getLeaders = require('../utils/get-leaders');
const getWordKey = require('../utils/get-word-key');
const pickRandom = require('../utils/pick-random');
const words = require('../../assets/mash-words.json');
// http://www.thecountdownpage.com/letters.htm
const availableLetters = {
vowel: Object.entries(config.letters.vowels).flatMap(([vowel, repeat]) => Array.from({ length: repeat }, () => vowel)),
consonant: Object.entries(config.letters.consonants).flatMap(([consonant, repeat]) => Array.from({ length: repeat }, () => consonant)),
};
const types = { v: 'vowel', c: 'consonant' };
const settings = { ...config.letters };
const games = new Map();
function getBoard(context) {
const game = games.get(context.room.id);
return `${style.grey(style.code('['))}${game.word.split('').concat(Array.from({ length: config.letters.length - game.word.length })).map((letter) => style.bold(style.code(letter?.toUpperCase() || ''))).join(style.grey(style.code('|')))}${style.grey(style.code(']'))}`; // eslint-disable-line no-irregular-whitespace
}
function countLetters(word) {
return word.split('').reduce((counts, letter) => ({ ...counts, [letter]: (counts[letter] || 0) + 1 }), {});
}
function playWord(rawWord, context) {
const game = games.get(context.room.id);
const word = rawWord.trim().toLowerCase();
if (!word || word.length < 3 || 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 stop(context, aborted) {
const game = games.get(context.room.id);
if (!game) {
context.sendMessage(`There is no letters game going on, ${context.user.prefixedUsername}. You can start one with ${config.prefix}letters!`, context.room.id);
return;
}
game.ac.abort();
games.delete(context.room.id);
const wrap = aborted
? `The game was stopped by ${style.cyan(`${config.usernamePrefix}${context.user.username}`)}.`
: style.bold('Time\'s up!');
context.sendMessage(`${wrap} 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)} You have ${style.bold(style.green(settings.timeout))} seconds, let's start!`, context.room.id);
try {
await timers.setTimeout((settings.timeout / 5) * 1000, null, { signal: game.ac.signal });
context.sendMessage(`${getBoard(context)} You have ${style.bold(style.green(`${Math.round((settings.timeout / 5) * 4)} seconds`))} left`, context.room.id);
await timers.setTimeout((settings.timeout / 5) * 1000, null, { signal: game.ac.signal });
context.sendMessage(`${getBoard(context)} You have ${style.bold(style.green(`${Math.round((settings.timeout / 5) * 3)} seconds`))} left`, context.room.id);
await timers.setTimeout((settings.timeout / 5) * 1000, null, { signal: game.ac.signal });
context.sendMessage(`${getBoard(context)} You have ${style.bold(style.green(`${Math.round((settings.timeout / 5) * 2)} seconds`))} left`, context.room.id);
await timers.setTimeout((settings.timeout / 5) * 1000, null, { signal: game.ac.signal });
context.sendMessage(`${getBoard(context)} You have ${style.bold(style.green(`${Math.round(settings.timeout / 5)} seconds`))} left`, context.room.id);
await timers.setTimeout((settings.timeout / 5) * 1000, null, { signal: game.ac.signal });
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;
}
if (type === 'consonant' || type === 'vowel') {
game.word = `${game.word}${pickRandom(availableLetters[type])}`;
} else {
type.toLowerCase().slice(0, config.letters.length - game.word.length).split('').forEach((typeKey) => {
game.word = `${game.word}${pickRandom(availableLetters[types[typeKey]])}`;
});
}
if (game.word.length === config.letters.length) {
play(context);
return;
}
context.sendMessage(`${getBoard(context)} Would you like a consonant or a vowel?`, context.room.id);
}
function start(context, letters) {
if (games.has(context.room.id)) {
context.sendMessage(`${getBoard(context)} is the current board. Use ${config.prefix}letters:stop to reset.`, context.room.id);
return;
}
games.set(context.room.id, {
state: 'letters',
word: '',
played: new Set(),
points: {},
ac: new AbortController(), // eslint-disable-line no-undef
});
if (letters) {
pickLetters(letters.toLowerCase(), context);
return;
}
context.sendMessage('Let\'s play the letters! Would you like a consonant or a vowel?', context.room.id);
}
function onCommand(args, context) {
if (context.subcommand === 'stop') {
stop(context, true);
return;
}
if (context.command === 'letsgo' || ['start', 'go', 'auto', 'random'].includes(context.subcommand)) {
start(context, Array.from({ length: config.letters.length }, () => (Math.random() < config.letters.consonantBias ? 'c' : 'v')).join('')); // a slight bias towards consonants seems to give better boards
return;
}
if (!context.subcommand) {
start(context);
}
}
function onMessage(message, context) {
const game = games.get(context.room?.id);
if (message.type !== 'message' || context.user?.id === config.user?.id) { // stop listening to yourself
return;
}
if (game?.state === 'letters') {
const multi = message.body.match(/\b[vc]{2,}$/i)?.[0];
if (multi) {
pickLetters(multi.toLowerCase(), context);
return;
}
if (/(^|\b)cons?(onant)?($|\b)/i.test(message.body)) {
pickLetters('consonant', context);
return;
}
if (/(^|\b)vow(el)?($|\b)/i.test(message.body)) {
pickLetters('vowel', context);
return;
}
}
if (game?.state === 'words') {
playWord(message.body, context);
}
}
module.exports = {
name: 'Letters',
help: `Make the longest word from the ${config.letters.length} letters on the board. Quick-start with ${config.prefix}letsgo, or use ${config.prefix}letters to fill the board by manually selecting a vow(el), a con(sonant), or multiple at once like CCVVCCVVC.`,
commands: ['letsgo'],
onCommand,
onMessage,
};