Compare commits

..

137 Commits

Author SHA1 Message Date
ThePendulum
59dd7c793a 1.30.8 2025-02-18 22:13:26 +01:00
ThePendulum
a9f733227c Added weekly points back-up, writing points to dedicated directory. 2025-02-18 22:13:24 +01:00
ThePendulum
19feb9a55f 1.30.7 2024-09-04 22:28:02 +02:00
ThePendulum
df3c682ff6 Separated wordle and hardle score. 2024-09-04 22:28:00 +02:00
ThePendulum
aca9a4b597 1.30.6 2024-09-04 22:26:16 +02:00
ThePendulum
e4055ad99c Another fix for missing definition breaking mash. 2024-09-04 22:26:14 +02:00
ThePendulum
5dcb928c35 1.30.5 2024-08-24 18:01:10 +02:00
ThePendulum
8c7995340e Fixed missing definition breaking mash. 2024-08-24 18:01:08 +02:00
ThePendulum
84025d6a8b 1.30.4 2024-08-15 22:32:02 +02:00
ThePendulum
84b158cf21 Fixed crash when target is missing from message. 2024-08-15 22:32:00 +02:00
ThePendulum
7531a69904 1.30.3 2024-08-14 01:37:10 +02:00
ThePendulum
c402628161 Disable wordle bonus points for small dictionaries. 2024-08-14 01:37:08 +02:00
ThePendulum
e9df99bdcb 1.30.2 2024-07-05 00:40:55 +02:00
ThePendulum
a0f1914ce6 Amended wordle help. 2024-07-05 00:40:52 +02:00
ThePendulum
857f1816f0 1.30.1 2024-07-05 00:36:43 +02:00
ThePendulum
5ac935cc95 Fixed wordle hard hint letter background color. 2024-07-05 00:36:41 +02:00
ThePendulum
af9f15d11d 1.30.0 2024-07-05 00:30:27 +02:00
ThePendulum
7747eabce2 Regenerated dictionary. 2024-07-05 00:30:10 +02:00
ThePendulum
990e18d3da Added hard mode to wordle. 2024-07-05 00:29:40 +02:00
ThePendulum
234bbb2bbc Added word blacklist for lowercase names. Improved definition curation. 2024-07-04 22:55:54 +02:00
ThePendulum
f46d5f008b 1.29.9 2024-06-19 00:23:24 +02:00
ThePendulum
a86ef925ff Re-added morse and a few other 'name' words. Removed raw word list. 2024-06-19 00:23:21 +02:00
ThePendulum
ad26d2635c 1.29.8 2024-06-18 23:34:30 +02:00
ThePendulum
8d98581cd9 Fixed any mention of sponges being interpreted as a pong. 2024-06-18 23:34:27 +02:00
ThePendulum
c2cdd55f2d 1.29.7 2024-06-18 21:40:41 +02:00
ThePendulum
34b3269d54 Removed names from dictionary. 2024-06-18 21:40:38 +02:00
ThePendulum
5aa5b1852a 1.29.6 2024-06-10 01:24:26 +02:00
ThePendulum
c3cadc3169 Clarify when word is in dictionary without a definition. 2024-06-10 01:24:24 +02:00
ThePendulum
29cbd77e35 1.29.5 2024-06-09 23:37:19 +02:00
ThePendulum
2310352ad6 Improved dictionary processing. 2024-06-09 23:37:17 +02:00
ThePendulum
5cc3a428f3 1.29.4 2024-06-09 18:26:20 +02:00
ThePendulum
bbe573d8f3 Fixed missing word definition breaking Clive. Using em space in wordle letterboard for SChat. 2024-06-09 18:26:17 +02:00
ThePendulum
3196877c37 1.29.3 2024-06-09 18:07:55 +02:00
ThePendulum
ab25066936 Improved dictionary. 2024-06-09 18:07:52 +02:00
ThePendulum
f384d595e4 Using regular space to join wordle letterboard. 2024-06-05 22:45:04 +02:00
ThePendulum
4ada601fb2 1.29.2 2024-06-04 23:56:52 +02:00
ThePendulum
13e0bb9a8c Improved repeat letter marking in wordle. 2024-06-04 23:56:51 +02:00
ThePendulum
055440418e Added colorless hints to IRC wordle instructions. 2024-06-04 17:31:33 +02:00
ThePendulum
352efb9147 Using correct format in IRC wordle instructions. 2024-06-04 17:25:43 +02:00
ThePendulum
1212261b21 Changed symbol for 6> faced dice. 2024-06-04 01:50:51 +02:00
ThePendulum
a807cbc2aa 1.29.1 2024-06-03 22:46:19 +02:00
ThePendulum
f85cac7f2f Improved wordle readability for IRC. 2024-06-03 22:46:17 +02:00
ThePendulum
402dbc3923 Mapped IRC orange to yellow instead of brown. 2024-06-03 02:17:41 +02:00
ThePendulum
54968f3fb4 Mapped IRC orange to brown. 2024-06-03 02:16:36 +02:00
ThePendulum
1228928592 Added guess count to wordle finish. 2024-06-03 02:05:02 +02:00
ThePendulum
6a35049609 Fixed orange highlight overwriting green highlight in wordle game. 2024-06-03 01:57:44 +02:00
ThePendulum
a29eba70f0 Added instructions to wordle start message. 2024-06-03 01:10:07 +02:00
ThePendulum
064b4eb0d4 1.29.0 2024-06-03 01:02:33 +02:00
ThePendulum
5e396a4abe Added/implemented wordle game. 2024-06-03 01:02:30 +02:00
ThePendulum
453a3b1b42 Fixed letters game board timeout. 2024-04-02 01:09:31 +02:00
ThePendulum
a1a9d698e2 Showing board 5 instead of 4 times in letters game. 2024-04-02 01:04:58 +02:00
ThePendulum
48cf433a5c 1.28.5 2024-04-02 00:56:21 +02:00
ThePendulum
61e935b925 Showing board 4 instead of 3 times in letters game. 2024-04-02 00:56:19 +02:00
ThePendulum
9a7ef61d93 1.28.4 2024-03-08 00:49:44 +01:00
ThePendulum
bad5dc52f7 Added user point merging utility. 2024-03-08 00:49:37 +01:00
ThePendulum
d510caa123 1.28.3 2024-02-11 17:33:21 +01:00
ThePendulum
b6cc3d716b Using cookie instead of http session so we can strip Domain attribute. 2024-02-11 17:33:19 +01:00
ThePendulum
6a36df3593 1.28.2 2024-01-13 03:55:55 +01:00
ThePendulum
69bc1c9e6e Added points merge utility. 2024-01-13 03:55:46 +01:00
ThePendulum
aeb405967b 1.28.1 2024-01-13 02:26:05 +01:00
ThePendulum
73e60b81f1 Added ping. 2024-01-13 02:26:03 +01:00
ThePendulum
c9b985f768 1.28.0 2024-01-13 01:29:14 +01:00
ThePendulum
cb5de62e1c Reverted SocketIO to ws. 2024-01-13 01:29:10 +01:00
ThePendulum
b3cab90810 1.27.3 2024-01-02 16:15:39 +01:00
ThePendulum
47484ba7e2 Preventing ~solve when numbers game is in progress. 2024-01-02 16:15:37 +01:00
ThePendulum
4017f1cd07 1.27.2 2024-01-02 02:13:14 +01:00
ThePendulum
03dfe8e437 Exposing numbers game solver through command. 2024-01-02 02:13:11 +01:00
ThePendulum
1a992a6026 1.27.1 2023-12-07 22:05:53 +01:00
ThePendulum
9f8f503f13 Removed reconnect logic, handled by SocketIO. 2023-12-07 22:05:51 +01:00
ThePendulum
d460ba13c5 1.27.0 2023-12-05 20:55:47 +01:00
ThePendulum
ed5337d828 Fixed socket close event listener. 2023-12-05 20:55:39 +01:00
ThePendulum
59424af388 Only using websockets. 2023-11-21 01:03:25 +01:00
ThePendulum
39a27f501e Refactored for SocketIO server. 2023-11-13 22:56:46 +01:00
ThePendulum
ffdfefa6d4 1.26.12 2023-08-21 23:12:06 +02:00
ThePendulum
b3ab3bf1ba Fixed kill announce for IRC.. 2023-08-21 23:12:02 +02:00
ThePendulum
2e54d38383 1.26.11 2023-08-17 22:38:45 +02:00
ThePendulum
98a1aa8fff Revealing who used kill command in all active rooms. 2023-08-17 22:38:43 +02:00
ThePendulum
6f2a5e03e9 1.26.10 2023-07-18 23:35:55 +02:00
ThePendulum
9966c79a26 Added milliseconds to Duck result. 2023-07-18 23:35:53 +02:00
ThePendulum
a46ffb431b 1.26.9 2023-07-11 16:41:40 +02:00
ThePendulum
b9be447dba Upgraded Clive to new room user ID data. 2023-07-11 16:41:38 +02:00
ThePendulum
a45d00c105 1.26.8 2023-06-26 22:56:37 +02:00
ThePendulum
00b92f445e Using distance for duck times. 2023-06-26 22:56:35 +02:00
ThePendulum
09e78a8bbb 1.26.7 2023-06-23 05:24:55 +02:00
ThePendulum
44d8304aa0 Added 23-letter words to dictionary. 2023-06-23 05:24:53 +02:00
Niels Simenon
8e16f189f5 1.26.6 2023-05-06 01:32:32 +02:00
Niels Simenon
c85ec3440b Added target user to chat. 2023-05-06 01:32:30 +02:00
Niels Simenon
650a8d2ec9 1.26.5 2023-04-12 17:17:51 +02:00
Niels Simenon
d263c42d38 Allow arithmetic functions in numbers. 2023-04-12 17:17:49 +02:00
Niels Simenon
fcd1597707 1.26.4 2023-04-12 17:13:26 +02:00
Niels Simenon
27a82b9cdd Disallowing functions and non-arithmetic operators in numbers. 2023-04-12 17:13:24 +02:00
Niels Simenon
b791147ce0 1.26.3 2023-04-11 01:31:14 +02:00
Niels Simenon
29ce536ffd Fixed IRC message target. 2023-04-11 01:31:12 +02:00
Niels Simenon
e584389453 Fixed reversed IRC recipient logic. 2023-04-11 01:09:40 +02:00
Niels Simenon
ba8e39f857 1.26.2 2023-04-11 00:51:52 +02:00
Niels Simenon
44386cf096 Added recipient to IRC message. 2023-04-11 00:51:50 +02:00
Niels Simenon
21feb37d21 Fixed logical typo. 2023-04-11 00:47:45 +02:00
Niels Simenon
85be251b3f 1.26.1 2023-04-11 00:46:42 +02:00
Niels Simenon
73117e7614 Changed conversation determination in chat. 2023-04-11 00:46:40 +02:00
Niels Simenon
750997f3cc 1.26.0 2023-04-11 00:37:05 +02:00
Niels Simenon
da9c85d90c Added PM support to chat. 2023-04-11 00:37:03 +02:00
Niels Simenon
ee50935339 1.25.5 2023-04-10 16:50:41 +02:00
Niels Simenon
2860630921 Changed default number name from big to large, added nums to short commands. 2023-04-10 16:50:39 +02:00
Niels Simenon
6c5fd6ed0a Changed back default consonant bias. 2023-04-10 16:37:32 +02:00
Niels Simenon
c43be9df13 1.25.4 2023-04-10 16:34:56 +02:00
Niels Simenon
8aef44b2d9 Moved consonant bias to config, increased slightly to 0.6. 2023-04-10 16:34:53 +02:00
Niels Simenon
77b6ebfb5c 1.25.3 2023-04-10 16:25:10 +02:00
Niels Simenon
6817f51b83 Moved available letters to config. 2023-04-10 16:25:08 +02:00
Niels Simenon
42c850ac90 1.25.2 2023-04-10 16:06:55 +02:00
Niels Simenon
91eaa9d709 Improved Letters distribution. Added conundrum to mash. 2023-04-10 16:06:53 +02:00
Niels Simenon
f4776df853 1.25.1 2023-04-10 14:26:30 +02:00
Niels Simenon
cfcb48224b Disallowing fractions in numbers game, fixed stop output, added generic calculator to numbers. 2023-04-10 14:26:28 +02:00
Niels Simenon
ea672df4c3 1.25.0 2023-04-10 05:54:30 +02:00
Niels Simenon
4fbd366bb9 Added numbers game. 2023-04-10 05:54:27 +02:00
Niels Simenon
8c34fe5013 1.24.10 2023-04-10 01:23:22 +02:00
Niels Simenon
74342ab07b Added chat token reset. WIP numbers game. 2023-04-10 01:23:19 +02:00
Niels Simenon
7321037f4f 1.24.9 2023-04-09 22:47:22 +02:00
Niels Simenon
68cefc0e2a Added word limit to chat bot. 2023-04-09 22:47:19 +02:00
Niels Simenon
f222922643 1.24.8 2023-04-09 16:04:22 +02:00
Niels Simenon
c4d328b409 Passing user identifier to OpenAI API. 2023-04-09 16:04:19 +02:00
Niels Simenon
efc45f2103 1.24.7 2023-04-09 15:59:48 +02:00
Niels Simenon
b724f4ecb7 Set default chat history to 2. 2023-04-09 15:59:46 +02:00
Niels Simenon
8960a23448 1.24.6 2023-04-09 15:54:42 +02:00
Niels Simenon
ef30c41758 Added username parameter to chat token, tracking by username. 2023-04-09 15:54:39 +02:00
Niels Simenon
4b8077f7e7 Fixed token text error. 2023-04-09 15:48:30 +02:00
Niels Simenon
d4edbfda7a 1.24.5 2023-04-09 15:45:43 +02:00
Niels Simenon
ad7f1f548e Increased default chat cut-off. 2023-04-09 15:45:41 +02:00
Niels Simenon
6469a7b660 Removed null environment from ecosystem file. 2023-04-09 02:05:03 +02:00
Niels Simenon
f084dddfce 1.24.4 2023-04-09 02:02:00 +02:00
Niels Simenon
a946e0447b Using instance name for local database. 2023-04-09 02:01:58 +02:00
Niels Simenon
e5a10d8059 Added knex module. 2023-04-09 01:54:25 +02:00
Niels Simenon
291a338eca 1.24.3 2023-04-09 01:44:25 +02:00
Niels Simenon
21a90a51cf Added token limits to OpenAI chat. 2023-04-09 01:44:22 +02:00
Niels Simenon
058ca10011 1.24.2 2023-04-09 00:20:32 +02:00
Niels Simenon
8f37ee145e Fixed chat prompt regex breaking if no username prefix is configured. 2023-04-09 00:20:29 +02:00
Niels Simenon
834ccc3ea0 1.24.1 2023-04-09 00:14:38 +02:00
Niels Simenon
3a660a0c1c Updated recommended node version. Added hostname to system uptime. 2023-04-09 00:14:35 +02:00
34 changed files with 274651 additions and 102360 deletions

View File

@@ -15,6 +15,6 @@
"no-console": 0,
"indent": ["error", "tab"],
"no-tabs": 0,
"max-len": [2, {"code": 400, "tabWidth": 4, "ignoreUrls": true}]
"max-len": 0
}
}

3
.gitignore vendored
View File

@@ -3,5 +3,6 @@ config/*
!config/default.js
*.config.js
!ecosystem.config.js
points*.json
*.sqlite
points/*
assets/mash-words.json

2
.nvmrc
View File

@@ -1 +1 @@
v17.0.1
v18.15.0

View File

@@ -1,11 +1,11 @@
'use strict';
const fs = require('fs').promises;
/*
const inflect = require('inflect');
const tensify = require('tensify');
const dictionary = require('./dictionary.json');
function getTenses(word) {
try {
const { past, past_participle: participle } = tensify(word);
@@ -15,26 +15,40 @@ function getTenses(word) {
return {};
}
}
*/
const dictionary = require('./dictionary.json');
async function init() {
/*
const formsDictionary = Object.fromEntries(Object.entries(dictionary).flatMap(([word, definition]) => {
const plural = inflect.pluralize(word);
const { past, participle } = getTenses(word);
return [
[word, definition],
...(plural && !dictionary[plural] ? [[plural, `Plural of ${word}`]] : []),
...(past && !dictionary[past] ? [[past, `Past tense of ${word}`]] : []),
...(participle && !dictionary[participle] ? [[past, `Past participle of ${word}`]] : []),
...(plural && !dictionary[plural] ? [[plural, definition]] : []),
...(past && !dictionary[past] ? [[past, definition]] : []),
...(participle && !dictionary[participle] ? [[past, definition]] : []),
];
}));
*/
const validWords = Object.entries(formsDictionary).filter(([word]) => /^[a-zA-Z]+$/.test(word));
const validWords = Object.entries(dictionary).filter(([word]) => /^[a-zA-Z]+$/.test(word));
const sortedWords = validWords.reduce((acc, [rawWord, fullDefinition]) => {
const word = rawWord.toLowerCase();
const anagram = word.split('').sort().join('');
const definitions = fullDefinition.split(/\d+\.\s+/).filter(Boolean).map((definition) => definition.split('.')[0].toLowerCase());
const definitions = fullDefinition
?.split(/\d+\.\s+/).filter(Boolean).map((definition) => {
const splitIndex = definition.indexOf('.', 16); // split after n characters to avoid splitting on e.g. abbreviated categories at the start of the definition: (Anat.)
if (splitIndex > -1) {
return definition.slice(0, splitIndex).trim().toLowerCase();
}
return definition.toLowerCase();
}) || [];
if (!acc[anagram.length]) {
acc[anagram.length] = {};

180125
assets/dictionary.json Executable file → Normal file

File diff suppressed because one or more lines are too long

102260
assets/dictionary_old.json Executable file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,31 @@
'use strict';
const fs = require('fs').promises;
const words = require('./words.json');
const wordsBlacklist = require('./words_blacklist.json');
const dictionary = require('./dictionary.json');
// mainly lowercase names
const blacklist = new Set(wordsBlacklist);
async function init() {
const notNameWords = words.filter((word) => word.charAt(0) === word.charAt(0).toLowerCase() && !blacklist.has(word)); // don't include names for places, products, people, etc.
const definitions = Object.fromEntries(notNameWords.map((word) => {
const normalizedWord = word.normalize('NFD').replace(/\p{Diacritic}/ug, '').toLowerCase().trim();
const definition = dictionary[normalizedWord];
const singular = normalizedWord.replace(/s$/, '');
const singularDefinition = dictionary[singular];
return [normalizedWord, definition || singularDefinition || null];
}));
const string = JSON.stringify(definitions, null, 4);
await fs.writeFile('./dictionary.json', string);
console.log(`Wrote ${Object.keys(definitions).length} words to ./dictionary.json`);
}
init();

89686
assets/words.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,4 @@
[
"ioctl",
"xterm"
]

View File

@@ -3,9 +3,11 @@
module.exports = {
platform: 'schat',
user: {
id: 'aisha',
username: 'Aisha',
realName: 'Aisha',
id: 'clive',
username: 'Clive',
realName: 'Clive',
avatar: null,
key: null,
},
style: {
// color: 'var(--message-56)',
@@ -13,19 +15,21 @@ module.exports = {
operators: ['admin'],
server: 'irc.libera.chat',
port: 6697,
socket: 'ws://127.0.0.1:3000/socket',
socket: 'ws://127.0.0.1:3000/socket/',
api: 'http://127.0.0.1:3000/api',
reconnectDelay: 10, // seconds
prefix: '~',
labels: true,
greeting: 'Hi, I am aisha, your game host!',
greeting: 'Hi, I am Clive, your game host!',
usernamePrefix: '@',
channels: ['GamesNight'],
games: [
'chat',
'mash',
'trivia',
'wordle',
'letters',
'numbers',
'hunt',
'8ball',
'geo',
@@ -70,6 +74,12 @@ module.exports = {
'gpt-4',
],
model: 'gpt-3.5-turbo',
userTokenLimit: 20000, // daily, roughly 100+ messages or $0.04 per user
userTokenPeriod: 24, // hours
replyTokenLimit: 1000,
replyWordLimit: 70,
replyWordLimitPublic: true,
temperature: 1,
history: 3,
rule: 'a tired game host',
rulePublic: true,
@@ -91,6 +101,49 @@ module.exports = {
letters: {
length: 9,
timeout: 60,
consonantBias: 0.56, // for auto fill
// http://www.thecountdownpage.com/letters.htm
vowels: {
a: 15,
e: 21,
i: 13,
o: 13,
u: 5,
},
consonants: {
b: 2,
c: 3,
d: 6,
f: 2,
g: 3,
h: 2,
j: 1,
k: 1,
l: 5,
m: 4,
n: 8,
p: 4,
q: 1,
r: 9,
s: 9,
t: 9,
v: 1,
w: 1,
x: 1,
y: 1,
z: 1,
},
},
wordle: {
minLength: 3,
length: 5,
bonusDictionaryThreshold: 1000, // minimum dictionary size to assign bonus points, prevent people from scoring full bonus points from 1-word dictionaries
mode: 'easy',
},
numbers: {
length: 6,
timeout: 90,
points: [3, 2, 2, 2, 2, 2, 1, 1, 1, 1, 1], // points by distance
},
riddle: {
timeout: 30,

View File

@@ -3,6 +3,6 @@ module.exports = {
script: 'src/app.js',
restart_delay: 5000,
env: {
NODE_APP_INSTANCE: null,
NODE_APP_INSTANCE: 'main',
},
};

1284
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "schat2-clive",
"version": "1.24.0",
"version": "1.30.8",
"description": "Game host for SChat 2-powered chat sites",
"main": "src/app.js",
"scripts": {
@@ -18,6 +18,7 @@
"author": "Niels Simenon",
"license": "ISC",
"dependencies": {
"better-sqlite3": "^8.3.0",
"bhttp": "^1.2.8",
"bottleneck": "^2.19.5",
"config": "^3.3.6",
@@ -28,9 +29,12 @@
"irc-colors": "^1.5.0",
"irc-upd": "^0.11.0",
"jsdom": "^18.1.0",
"knex": "^2.4.2",
"linkify-it": "^3.0.3",
"markov-strings": "^3.0.1",
"mathjs": "^11.8.0",
"simple-node-logger": "^21.8.12",
"socket.io-client": "^4.7.2",
"string-similarity": "^4.0.4",
"tensify": "^0.0.4",
"vm2": "^3.9.11",

View File

@@ -4,35 +4,105 @@ const config = require('config');
const bhttp = require('bhttp');
const style = require('../utils/style');
const knex = require('../knex');
const promptRegex = new RegExp(`^${config.usernamePrefix ? `${config.usernamePrefix}?` : ''}${config.user.username}[:,\\s]`, 'ui');
const settings = { ...config.chat };
const promptRegex = new RegExp(`^${config.usernamePrefix}?${config.user.username}[:,\\s]`, 'ui');
const history = new Map();
const processing = new Set();
const settings = config.chat;
async function onStart(context) {
const totalExists = await knex.schema.hasTable('chat_tokens');
if (!totalExists) {
await knex.schema.createTable('chat_tokens', (table) => {
table.increments('id');
table.string('user_id')
.notNullable();
table.integer('tokens')
.notNullable();
table.timestamp('created')
.notNullable();
});
context.logger.info('Created database table \'chat_tokens\'');
}
const purgeResult = await knex('chat_tokens')
.where('created', '<=', knex.raw(`datetime('now', '-${config.chat.userTokenPeriod} hour')`))
.delete();
context.logger.info(`Purged ${purgeResult} expired chat token totals from database`);
}
function setHistory(value, context) {
if (!value) {
context.sendMessage(`Chat history is set to ${style.bold(settings.history)}`, context.room.id, { label: false });
context.sendMessage(`Chat history is set to ${style.bold(settings.history)}`, context.room?.id, { label: false }, context.user.username);
return;
}
const newHistory = Number(value);
if (!Number.isNaN(newHistory)) {
if (!Number.isNaN(newHistory) && newHistory > 0 && newHistory <= 10) {
settings.history = newHistory;
context.logger.info(`Chat history set to ${newHistory} by ${context.user.username}`);
context.sendMessage(`Chat history set to ${style.bold(newHistory)} by ${context.user.prefixedUsername}`, context.room.id, { label: false });
context.sendMessage(`Chat history set to ${style.bold(newHistory)} by ${context.user.prefixedUsername}`, context.room?.id, { label: false }, context.user.username);
return;
}
context.logger.info(`Chat history must be a valid number, ${context.user.prefixedUsername}`);
context.sendMessage(`Chat history must be a valid number between 0 and 10, ${context.user.prefixedUsername}`, context.room?.id, { label: false }, context.user.username);
}
function setReplyWordLimit(value, context) {
if (!value) {
context.sendMessage(`Chat reply word limit is set to ${style.bold(settings.replyWordLimit)}`, context.room?.id, { label: false }, context.user.username);
return;
}
const newReplyWordLimit = Number(value);
if (!Number.isNaN(newReplyWordLimit) && newReplyWordLimit >= 3 && newReplyWordLimit <= 200) {
settings.replyWordLimit = newReplyWordLimit;
context.logger.info(`Chat reply word limit set to ${newReplyWordLimit} by ${context.user.username}`);
context.sendMessage(`Chat reply word limit set to ${style.bold(newReplyWordLimit)} by ${context.user.prefixedUsername}`, context.room?.id, { label: false }, context.user.username);
return;
}
context.sendMessage(`Chat reply word limit must be a valid number between 3 and 200, ${context.user.prefixedUsername}`, context.room?.id, { label: false }, context.user.username);
}
function setTemperature(value, context) {
if (!value) {
context.sendMessage(`Chat temperature is set to ${style.bold(settings.temperature)}`, context.room?.id, { label: false }, context.user.username);
return;
}
const newTemperature = Number(value);
if (!Number.isNaN(newTemperature) && newTemperature > 0 && newTemperature <= 2) {
settings.temperature = newTemperature;
context.logger.info(`Chat temperature set to ${newTemperature} by ${context.user.username}`);
context.sendMessage(`Chat temperature set to ${style.bold(newTemperature)} by ${context.user.prefixedUsername}`, context.room?.id, { label: false }, context.user.username);
return;
}
context.sendMessage(`Chat temperature must be a valid number between 0 and 2, ${context.user.prefixedUsername}`, context.room?.id, { label: false }, context.user.username);
}
function setModel(model, context) {
if (!model) {
context.sendMessage(`Chat model is set to ${style.bold(settings.model)}`, context.room.id, { label: false });
context.sendMessage(`Chat model is set to ${style.bold(settings.model)}`, context.room?.id, { label: false }, context.user.username);
return;
}
@@ -40,17 +110,17 @@ function setModel(model, context) {
settings.model = model;
context.logger.info(`Chat model set to '${model}' by ${context.user.username}`);
context.sendMessage(`Chat model set to '${style.bold(model)}' by ${context.user.prefixedUsername}`, context.room.id, { label: false });
context.sendMessage(`Chat model set to '${style.bold(model)}' by ${context.user.prefixedUsername}`, context.room?.id, { label: false }, context.user.username);
return;
}
context.logger.info(`Model '${model}' is not supported right now, ${context.user.prefixedUsername}`);
context.sendMessage(`Model '${model}' is not supported right now, ${context.user.prefixedUsername}`, context.room?.id, { label: false }, context.user.username);
}
function setRule(rule, context) {
if (!rule) {
context.sendMessage(`Chat is ${style.bold(settings.rule)}`, context.room.id, { label: false });
context.sendMessage(`Chat is ${style.bold(settings.rule)}`, context.room?.id, { label: false }, context.user.username);
return;
}
@@ -58,7 +128,7 @@ function setRule(rule, context) {
settings.rule = config.chat.rule;
context.logger.info(`Chat reset by ${context.user.username} to be ${config.chat.rule}`);
context.sendMessage(`Chat reset by ${context.user.prefixedUsername} to be ${style.bold(config.chat.rule)}`, context.room.id, { label: false });
context.sendMessage(`Chat reset by ${context.user.prefixedUsername} to be ${style.bold(config.chat.rule)}`, context.room?.id, { label: false }, context.user.username);
return;
}
@@ -67,15 +137,35 @@ function setRule(rule, context) {
settings.rule = `${rule.replace(/\.+$/, '')}`;
context.logger.info(`Chat set by ${context.user.username} to be ${rule}`);
context.sendMessage(`Chat set by ${context.user.prefixedUsername} to be ${style.bold(rule)}`, context.room.id, { label: false });
context.sendMessage(`Chat set by ${context.user.prefixedUsername} to be ${style.bold(rule)}`, context.room?.id, { label: false }, context.user.username);
return;
}
context.sendMessage(`Chat rule must be at least 3 characters long, ${context.user.prefixedUsername}`, context.room.id, { label: false });
context.sendMessage(`Chat rule must be at least 3 characters long, ${context.user.prefixedUsername}`, context.room?.id, { label: false }, context.user.username);
}
async function onCommand(args, context) {
async function getTokens(username) {
const { used_tokens: usedTokens } = await knex('chat_tokens')
.sum('tokens as used_tokens')
.where('user_id', username)
.where('created', '>', knex.raw(`datetime('now', '-${config.chat.userTokenPeriod} hour')`)) // 1 day ago
.first();
return usedTokens || 0;
}
async function resetTokens(username) {
if (!username) {
return;
}
await knex('chat_tokens')
.where('user_id', username)
.delete();
}
async function onCommand(args, context, isConversation) {
if (context.subcommand === 'history' && config.operators.includes(context.user.username)) {
setHistory(args[0], context);
return;
@@ -86,17 +176,64 @@ async function onCommand(args, context) {
return;
}
if (['rule', 'is', 'reset'].includes(context.subcommand) && (config.chat.rulePublic || config.operators.includes(context.user.username))) {
if (['temperature', 'temp'].includes(context.subcommand) && config.operators.includes(context.user.username)) {
setTemperature(Number(args[0]), context);
return;
}
if (['rule', 'is', 'be', 'ur'].includes(context.subcommand) && (config.chat.rulePublic || config.operators.includes(context.user.username))) {
setRule(context.subcommand === 'reset' ? 'reset' : args.join(' '), context);
return;
}
if (['limit', 'words'].includes(context.subcommand) && (config.chat.replyWordLimitPublic || config.operators.includes(context.user.username))) {
setReplyWordLimit(args[0], context);
return;
}
if (['tokens', 'credit'].includes(context.subcommand || context.command)) {
const username = args[0] ? args[0].replace(new RegExp(`^${config.usernamePrefix}`), '') : context.user.username;
const tokens = await getTokens(username);
context.sendMessage(`${args[0] ? `${style.bold(username)} has` : 'You have'} ${style.bold(config.chat.userTokenLimit - tokens)} chat tokens remaining. They will be returned gradually over ${config.chat.userTokenPeriod} hours.`, context.room?.id, { label: false }, context.user.username);
return;
}
if (context.subcommand === 'reset' && config.operators.includes(context.user.username)) {
const username = args[0] ? args[0].replace(new RegExp(`^${config.usernamePrefix}`), '') : context.user.username;
await resetTokens(username);
context.sendMessage(args[0] ? `Chat tokens reset for ${style.bold(username)}` : 'Chat tokens reset', context.room?.id, { label: false }, context.user.username);
return;
}
if (processing.has(context.user.username)) {
context.logger.info(`Skipped prompt from ${context.user.username} due processing lock`);
return;
}
const prompt = args.join(' ');
processing.add(context.user.username);
try {
const usedTokens = await getTokens(context.user.username);
if (usedTokens >= config.chat.userTokenLimit) {
context.logger.info(`${context.user.username} was rate limited at ${usedTokens}: ${prompt}`);
context.sendMessage(`Sorry, I love talking with you ${context.user.prefixedUsername}, but I need to take a break :( Check ${config.prefix}chat:tokens for more information.`, context.room?.id, { label: false }, context.user.username);
return;
}
const message = {
role: 'user',
content: `Answer as if you're ${settings.rule}. ${prompt}`,
content: settings.replyWordLimit
? `Using ${settings.replyWordLimit} words or fewer, answer as if you're ${settings.rule}. ${prompt}`
: `Answer as if you're ${settings.rule}. ${prompt}`,
};
const userHistory = (history.get(context.user.username) || []).concat(message);
@@ -105,7 +242,9 @@ async function onCommand(args, context) {
const res = await bhttp.post('https://api.openai.com/v1/chat/completions', JSON.stringify({
model: settings.model,
max_tokens: config.chat.replyTokenLimit,
messages: userHistory,
user: `${context.user.id}:${context.user.username}`,
}), {
headers: {
Authorization: `Bearer ${config.chat.apiKey}`,
@@ -114,26 +253,45 @@ async function onCommand(args, context) {
});
const reply = res.body.choices?.[0].message;
const target = prompt.match(/@\s*[\w-]+\s*$/)?.[0].slice(1).trim();
const curatedContent = reply.content.replace(/\n+/g, '. ');
context.logger.info(`OpenAI ${config.chat.model} replied to ${context.user.username} (${res.body.usage.total_tokens} tokens): ${reply.content}`);
context.sendMessage(`${context.user.prefixedUsername}: ${reply.content}`, context.room.id, { label: false });
context.logger.info(`OpenAI ${config.chat.model} replied to ${target || context.user.username} (${res.body.usage.total_tokens} tokens, ${(usedTokens || 0) + res.body.usage.total_tokens}/${config.chat.userTokenLimit} used): ${curatedContent}`);
if (isConversation) {
context.sendMessage(`${curatedContent}`, context.room?.id, { label: false }, context.user.username);
} else {
context.sendMessage(`${target ? `${config.usernamePrefix}${target}` : context.user.prefixedUsername}: ${curatedContent}`, context.room.id, { label: false });
}
await knex('chat_tokens').insert({
user_id: context.user.username,
tokens: res.body.usage.total_tokens,
created: knex.raw("datetime('now')"),
});
if (config.chat.history > 0) {
history.set(context.user.username, userHistory.concat(reply).slice(-settings.history));
}
} catch (error) {
context.logger.error(error.response ? `${error.response.status}: ${JSON.stringify(error.response.data)}` : error.message);
context.sendMessage('Sorry, I can\'t think right now', context.room.id, { label: false });
context.sendMessage('Sorry, I can\'t think right now', context.room?.id, { label: false }, context.user.username);
}
processing.delete(context.user.username);
}
function onMessage(message, context) {
if (promptRegex.test(message.body)) {
onCommand([message.body.replace(promptRegex, '').trim()], context);
const isConversation = message.recipient === config.user.username && message.type !== 'whisper' && !context.containsCommand;
if (promptRegex.test(message.body) || isConversation) {
onCommand([message.body.replace(promptRegex, '').trim()], context, isConversation);
}
}
module.exports = {
onCommand,
onMessage,
onStart,
commands: ['tokens'],
};

View File

@@ -37,7 +37,7 @@ function onCommand(args, context) {
return `${style.yellow(`(${coinFaces[result - 1]})`)} ${style.bold(result === 1 ? 'heads' : 'tails')}`; // eslint-disable-line no-irregular-whitespace
}
return `${style.grey(dieFaces[result - 1] || '')} ${style.bold(result)}`; // eslint-disable-line no-irregular-whitespace
return `${style.grey(dieFaces[result - 1] || '')} ${style.bold(result)}`; // eslint-disable-line no-irregular-whitespace
});
context.sendMessage(results.join(style.grey(' | ')), context.room.id, { label: type });

View File

@@ -1,6 +1,7 @@
'use strict';
const config = require('config');
const { intervalToDuration } = require('date-fns');
const style = require('../utils/style');
const pickRandom = require('../utils/pick-random');
@@ -56,11 +57,18 @@ function onCommand(args, context) {
}
const hit = Math.random() > config.duck.missRatio;
const time = ((new Date().getTime() - duck.getTime()) / 1000).toFixed(3);
const time = new Date().getTime() - duck.getTime();
const distance = time < 60 * 1000 // show digits up to one minute
? `${(time / 1000).toFixed(3)} seconds`
: `${Object.entries(intervalToDuration({ start: duck, end: new Date() }))
.filter(([, value]) => value > 0)
.map(([key, value]) => `${value} ${key}`)
.join(', ')} and ${time % 1000} milliseconds`;
if (['bang', 'shoot'].includes(context.command)) {
if (hit) {
context.sendMessage(`You shot a duck in ${style.bold(`${time} seconds`)}, ${context.user.prefixedUsername}`, context.room.id);
context.sendMessage(`${context.user.prefixedUsername}: You shot a duck in ${style.bold(style.red(distance))}`, context.room.id);
launchDuck(context);
context.setPoints(context.user, 1, { key: 'bang' });
@@ -73,6 +81,7 @@ function onCommand(args, context) {
`That's a miss! Better luck next time, ${context.user.prefixedUsername}.`,
`The duck outsmarted you, ${context.user.prefixedUsername}`,
`Channeling Gareth Southgate, ${context.user.prefixedUsername}? You missed!`,
`Oh, and that's a bad miss, ${context.user.prefixedUsername}.`,
]);
shots.set(context.user.id, new Date());
@@ -83,7 +92,7 @@ function onCommand(args, context) {
if (['bef', 'befriend'].includes(context.command)) {
if (hit) {
context.sendMessage(`You befriended a duck in ${style.bold(style.green(`${time} seconds`))}, ${context.user.prefixedUsername}`, context.room.id);
context.sendMessage(`${context.user.prefixedUsername}: You befriended a duck in ${style.bold(style.sky(distance))}`, context.room.id);
launchDuck(context);
context.setPoints(context.user, 1, { key: 'befriend' });

View File

@@ -97,7 +97,7 @@ async function onCommand(args, context) {
async function onMessage(message, context) {
['countries', 'states', 'flags'].forEach(async (type) => {
const game = games[type].get(context.room.id);
const game = games[type].get(context.room?.id);
if (!game) {
return;

View File

@@ -133,7 +133,7 @@ function onCommand(args, context) {
games.set(context.room.id, {
word: word.word,
progress: 0,
defintiions: word.definitions,
definitions: word.definitions,
partial: word.word.split('').map(() => null),
flower: pickRandom(flowers),
target: pickRandom(targets),
@@ -145,7 +145,7 @@ function onCommand(args, context) {
}
function onMessage(message, context) {
if (games.has(context.room.id)) {
if (games.has(context.room?.id)) {
const letter = message.body.match(/^\s*\w\s*$/)?.[0];
const word = message.body.match(/^\s*\w{2,}\s*$/)?.[0];

View File

@@ -2,16 +2,28 @@
const config = require('config');
const style = require('../utils/style');
function onCommand(args, context) {
if (config.platform === 'schat') {
context.sendMessage('Shutting down... :sleeping:', context.room?.id, { type: 'message', label: false }, context.message.user?.username);
Object.keys(context.bot.rooms).forEach((roomId) => {
context.sendMessage(`Kill command used by ${style.bold(context.user.username)}, shutting down... :sleeping:`, roomId, { type: 'message', label: false });
});
if (context.message.user) {
context.sendMessage('Shutting down... :sleeping:', context.room?.id, { type: 'message', label: false }, context.message.user.username);
}
}
if (config.platform === 'irc' && context.room.id === config.user.id) {
if (config.platform === 'irc') {
context.bot.rooms.forEach((room) => {
context.sendMessage(`Kill command used by ${style.bold(context.user.username)}, shutting down... 😴`, room.id, { type: 'message', label: false });
});
if (context.room.id === config.user.id) {
// if the room ID is the bot's own nickname, it's a PM and we should reply to the sender
context.sendMessage('Shutting down... 😴', context.user.id, { label: false });
} else if (config.platform === 'irc') {
context.sendMessage('Shutting down... 😴', context.room?.id, { type: 'message', label: false });
context.sendMessage('Shutting down... 😴', context.user.id, { type: 'message', label: false }, context.message.user.username);
}
}
context.logger.info(`Kill command used by ${context.user.username}`);

View File

@@ -9,9 +9,10 @@ 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: ['a', 'e', 'i', 'o', 'u'],
consonant: ['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
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' };
@@ -66,12 +67,19 @@ function playWord(rawWord, context) {
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);
if (aborted) {
context.sendMessage(`The game was stopped by ${style.cyan(`${config.usernamePrefix}${context.user.username}`)}. Best players: ${getLeaders(game.points)}`, 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) {
@@ -80,17 +88,22 @@ 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))} seconds, 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 / 5) * 1000, null, { signal: game.ac.signal });
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 / 5) * 4)} seconds`))} left`, context.room.id);
await timers.setTimeout((settings.timeout / 5) * 1000, null, { signal: game.ac.signal });
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);
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) {
@@ -124,7 +137,7 @@ function pickLetters(type, context) {
function start(context, letters) {
if (games.has(context.room.id)) {
context.sendMessage(`${getBoard(context)} This is the current board. Use ${config.prefix}letters:stop to reset.`, context.room.id);
context.sendMessage(`${getBoard(context)} is the current board. Use ${config.prefix}letters:stop to reset.`, context.room.id);
return;
}
@@ -151,7 +164,7 @@ function onCommand(args, context) {
}
if (context.command === 'letsgo' || ['start', 'go', 'auto', 'random'].includes(context.subcommand)) {
start(context, Array.from({ length: config.letters.length }, () => (Math.random() < 0.55 ? 'c' : 'v')).join('')); // a slight bias towards consonants seems to give better boards
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;
}
@@ -168,7 +181,7 @@ function onMessage(message, context) {
}
if (game?.state === 'letters') {
const multi = message.body.match(/\b[vc]{2,}\b/i)?.[0];
const multi = message.body.match(/\b[vc]{2,}$/i)?.[0];
if (multi) {
pickLetters(multi.toLowerCase(), context);

View File

@@ -35,7 +35,7 @@ function start(length, context, attempt = 0) {
if (answers.some((answer) => answer.word === anagram)) {
if (attempt >= 10) {
context.sendMessage(`Sorry, I did not find a mashable ${length}-letter word`);
context.sendMessage(`Sorry, I did not find a mashable ${length}-letter word`, context.room.id);
return;
}
@@ -48,7 +48,12 @@ function start(length, context, attempt = 0) {
const newMash = mashes.get(context.room.id);
if (context.command === 'conundrum') {
context.sendMessage(`Here is your conundrum: ${style.bold(style.pink(newMash.anagram))}`, context.room.id);
} else {
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(', ')}`);
}
@@ -76,7 +81,9 @@ function play(rawWord, context, shouted) {
}
if (answer) {
const definition = answer.definitions[0] ? `: ${style.italic(`${answer.definitions[0].slice(0, 100)}${mash.answers[0].definitions[0].length > 100 ? '...' : ''}`)}` : '';
const definition = answer.definitions[0]
? `: ${style.italic(`${answer.definitions[0].slice(0, 100)}${answer.definitions[0].length > 100 ? '...' : ''}`)}`
: '';
context.sendMessage(mash.answers.length === 1
? `${style.bold(style.yellow(word))} is the right answer${definition}, ${style.bold(style.cyan(`${config.usernamePrefix}${context.user.username}`))} now has ${style.bold(`${context.user.points + 1} ${context.user.points === 0 ? 'point' : 'points'}`)}! There were no other options for ${style.bold(mash.anagram)}.`
@@ -139,10 +146,19 @@ function define(word, context) {
return;
}
context.sendMessage(`No definition available for ${style.bold(word)}, ${config.usernamePrefix}${context.user.username}`, context.room.id);
if (answer) {
context.sendMessage(`${style.bold(word)} is in my dictionary, but I cannot provide a definition, ${config.usernamePrefix}${context.user.username}`, context.room.id);
return;
}
context.sendMessage(`${style.bold(word)} is not in my dictionary, ${config.usernamePrefix}${context.user.username}`, context.room.id);
}
function sanitizeDefinition(definition, answers) {
if (!definition) {
return 'No definition';
}
return definition.replaceAll(/\w+/g, (word) => {
if (answers.some((answer) => answer.word.includes(word))) {
return '*'.repeat(word.length);
@@ -193,6 +209,11 @@ function onCommand(args, context) {
return;
}
if (context.command === 'conundrum') {
start(9, context);
return;
}
if (!Number.isNaN(length)) {
start(length, context);
return;
@@ -221,7 +242,7 @@ function onMessage(message, context) {
module.exports = {
name: 'Mash',
commands: ['mash', 'wordmash', ...defineCommands, ...resolveCommands],
commands: ['mash', 'wordmash', 'conundrum', ...defineCommands, ...resolveCommands],
onCommand,
onMessage,
help: `Resolve the anagram. Get a new mash with ${config.prefix}mash [length], look up definitions with ${config.prefix}define [word], resolve an anagram (that's not currently in play) with ${config.prefix}solve [anagram].`,

View File

@@ -0,0 +1,420 @@
/* 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)} with target ${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 was ${game.distances[game.winner.username]} away for ${style.bold(game.points[game.distances[game.winner.username]])} points.`;
}
return 'No one found a solution.';
}
function getSolutionText(game) {
if (game.distances[game.winner?.username] === 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.distances[game.winner?.username] === 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, config.numbers.points[game.distances[game.winner.username]]);
}
const wrapText = aborted
? `The game was stopped by ${style.cyan(`${config.usernamePrefix}${context.user.username}`)}.`
: style.bold('Time\'s up!');
console.log('time up');
const winnerText = getWinnerText(game);
const leaders = Object.keys(game.distances).length > 1
? `Honorary mentions: ${getLeaders(game.distances, context.user, { skip: [game.winner.username], pointsWord: 'away' })}.`
: 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(`${context.user.prefixedUsername}: ${imagined.map((number) => style.bold(number)).join(' and ')} is not on the board`, context.room.id);
return;
}
if (overused.length > 0) {
context.sendMessage(`${context.user.prefixedUsername}: ${overused.map((number) => style.bold(number)).join(' and ')} is used too often`, context.room.id);
return;
}
const answer = parsed.evaluate();
const distance = Math.abs(game.target - answer);
const points = config.numbers.points[distance];
const lastDistance = game.distances[context.user.username] || null;
const closestDistance = Object.values(game.distances).reduce((acc, userDistance) => (userDistance < acc ? userDistance : acc), Infinity);
/*
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)}`;
*/
const summary = distance === 0
? `${style.bold(style.code(parsed))} results in ${style.bold(style.code(answer))}, that is ${style.bold('exactly on target')}`
: `${style.bold(style.code(parsed))} results in ${style.bold(style.code(answer))}, that is ${style.bold(distance)} away from target ${getTarget(game)}`;
if (points && distance < lastDistance) {
game.distances[context.user.username] = distance;
}
console.log(distance, lastDistance, closestDistance, distance < closestDistance);
console.log(game.distances);
if (points && distance < closestDistance) {
game.winner = {
...context.user,
solution: parsed,
};
context.sendMessage(`${context.user.prefixedUsername}: ${summary} for ${style.bold(points)} points, the current winner`, context.room.id);
return;
}
if (points && distance < lastDistance) {
context.sendMessage(`${context.user.prefixedUsername}: ${summary} for ${style.bold(points)} points, your personal best, but not closer than the winning ${style.bold(closestDistance)} away`, context.room.id);
return;
}
if (points) {
context.sendMessage(`${context.user.prefixedUsername}: ${summary} for ${style.bold(points)} points, but does not beat your personal closest of ${style.bold(lastDistance)} away`, 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,
distances: {},
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 solve(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);
}
}
function onCommand(args, context) {
if (context.subcommand === 'stop') {
stop(context, true);
return;
}
if (context.subcommand === 'timeout') {
setTimeout(args[0], context);
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 <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
};

459
src/games/numbers.js Normal file
View File

@@ -0,0 +1,459 @@
/* 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
};

View File

@@ -21,7 +21,7 @@ function onCommand(args, context) {
const osDuration = intervalToDuration({ start: 0, end: os.uptime() * 1000 });
const osDurationString = getDurationString(osDuration);
context.sendMessage(`I've been awake for ${durationString}. My host has been running for ${osDurationString}.`, context.room.id, null, context.message.user?.recipient);
context.sendMessage(`I've been awake for ${durationString}. My host '${os.hostname()}' has been running for ${osDurationString}.`, context.room.id, null, context.message.user?.recipient);
return;
}

245
src/games/wordle.js Normal file
View File

@@ -0,0 +1,245 @@
'use strict';
const config = require('config');
const crypto = require('crypto');
const style = require('../utils/style');
const words = require('../../assets/mash-words.json');
const settings = { ...config.wordle };
const wordles = new Map();
const alphabet = Array.from({ length: 26 }, (value, index) => String.fromCharCode(65 + index));
function getBoard(letters, showLetters, context) {
const wordle = wordles.get(context.room.id);
const prefix = config.platform === 'irc' ? '' : style.grey(style.code('['));
const middle = letters.map((letter) => {
if (letter === null) {
return config.platform === 'irc'
? style.bgsilver('?')
: style.code(''); // em space, U+2003, charcode 8195
}
if (letter[1] === true) {
return config.platform === 'irc'
? style.bggreen(` ${letter[0].toUpperCase()}${style.green('*')}`)
: style.green(style.bold(style.code(letter[0].toUpperCase())));
}
if (letter[1] === false) {
return config.platform === 'irc'
? style.bgyellow(` ${letter[0].toUpperCase()}${style.yellow('?')}`)
: style.orange(style.bold(style.code(letter[0].toUpperCase())));
}
return config.platform === 'irc'
? style.bgsilver(` ${letter[0].toUpperCase()} `)
: style.grey(style.bold(style.code(letter[0].toUpperCase())));
}).join(config.platform === 'irc' ? '' : style.grey(style.code('|')));
const suffix = config.platform === 'irc' ? '' : `${style.grey(style.code(']'))}`;
if (showLetters) {
const letterBoard = Array.from(wordle.letters).map(([letter, state]) => {
if (state === true) {
return config.platform === 'irc'
? style.bggreen(`${letter}${style.green('*')}`)
: style.green(style.bold(letter));
}
if (state === false) {
return config.platform === 'irc'
? style.bgyellow(`${letter}${style.yellow('?')}`)
: style.orange(style.bold(letter));
}
return config.platform === 'irc'
? letter
: style.grey(letter);
}).join(config.platform === 'irc' ? ' ' : ''); // regular space vs em space
return `${prefix}${middle}${suffix}${wordle.mode === 'hard' ? ' (Hard)' : ''}Letters: ${letterBoard}`; // eslint-disable-line no-irregular-whitespace
}
return `${prefix}${middle}${suffix}`;
}
function start(length = settings.length, mode = settings.mode, context) {
const wordPool = words[length];
if (length < settings.minLength) {
context.sendMessage(`Wordle must be at least ${settings.minLength} letters long.`, context.room.id);
return;
}
if (!wordPool) {
context.sendMessage(`No words with ${length} letters available.`, context.room.id);
return;
}
const wordList = Object.values(wordPool).flat();
const word = wordList[crypto.randomInt(wordList.length)];
wordles.set(context.room.id, {
word: word.word.toUpperCase(),
wordList,
mode,
definitions: word.definitions,
letters: new Map(alphabet.map((letter) => [letter, null])),
guesses: [],
});
if (config.platform === 'irc') {
context.sendMessage(`${getBoard(Array.from({ length }, () => null), false, context)}${mode === 'hard' ? ' (Hard)' : ''} guess the word with ${config.prefix}w [word]! ${style.bgsilver('X')} not in word; ${style.bgyellow(`X${style.yellow('?')}`)} wrong position; ${style.bggreen(`X${style.green('*')}`)} correct position.`, context.room.id); // eslint-disable-line no-irregular-whitespace
} else {
context.sendMessage(`${getBoard(Array.from({ length }, () => null), false, context)}${mode === 'hard' ? ' (Hard)' : ''} guess the word with ${config.prefix}w [word]! ${style.grey(style.bold('X'))} not in word; ${style.orange(style.bold('X'))} wrong position; ${style.green(style.bold('X'))} correct position.`, context.room.id);
}
context.logger.info(`Wordle started: ${word.word.toUpperCase()}`);
}
function play(guess, context) {
const wordle = wordles.get(context.room.id);
if (!wordle) {
context.sendMessage(`There's no wordle going on. Start one with ${config.prefix}wordle [length]!`, context.room.id);
return;
}
if (!guess || guess.length !== wordle.word.length) {
context.sendMessage(`Your guess needs to be ${wordle.word.length} letters.`, context.room.id);
return;
}
if (!wordle.wordList.some((definition) => definition.word.toLowerCase() === guess.toLowerCase())) {
context.sendMessage(`The word '${guess}' is not in my dictionary.`, context.room.id);
return;
}
const upperGuess = guess.toUpperCase();
const occurrences = wordle.word.split('').reduce((acc, letter) => acc.set(letter, (acc.get(letter) || 0) + 1), new Map());
const guessLetters = upperGuess.split('');
const check = guessLetters.map((letter) => [letter, null]);
const prevGuess = wordle.guesses.at(-1)?.[1];
if (wordle.mode === 'hard' && prevGuess) {
const prevLetters = prevGuess.split('');
const valid = prevLetters.every((letter, index) => {
if (wordle.letters.get(letter) === false && !guessLetters.includes(letter)) {
context.sendMessage(`(Hard) Your guess must include the letter ${config.platform === 'irc'
? style.bgyellow(` ${letter} `)
: `${style.grey(style.code('['))}${style.orange(style.bold(style.code(letter)))}${style.grey(style.code(']'))}`
}.`, context.room.id);
return false;
}
if (wordle.letters.get(letter) === true && letter === wordle.word[index] && letter !== guessLetters[index]) {
context.sendMessage(`(Hard) Your guess must include the letter ${config.platform === 'irc'
? style.bggreen(` ${letter} `)
: `${style.grey(style.code('['))}${style.green(style.bold(style.code(letter)))}${style.grey(style.code(']'))}`
} in the correct position.`, context.room.id);
return false;
}
return true;
});
if (!valid) {
return;
}
}
// correct
guessLetters.forEach((letter, index) => {
if (wordle.word[index] === letter) {
wordle.letters.set(letter, true);
check[index] = [letter, true];
}
});
// wrong place
guessLetters.forEach((letter, index) => {
if (wordle.word.includes(letter)) {
if (wordle.letters.get(letter) !== true) {
wordle.letters.set(letter, false);
}
const marks = check.filter(([checkLetter, status]) => checkLetter === letter && status !== null).length;
if (check[index][1] !== true && marks < occurrences.get(letter)) {
check[index] = [letter, false];
}
return;
}
wordle.letters.delete(letter);
});
wordle.guesses = wordle.guesses.concat([[context.user.username, upperGuess]]);
if (upperGuess === wordle.word) {
const assignBonusPoints = wordle.wordList.length > config.wordle.bonusDictionaryThreshold;
const points = assignBonusPoints
? Math.max((wordle.word.length + 1) - wordle.guesses.length, 1)
: 1;
const definition = wordle.definitions[0] ? `: ${style.italic(`${wordle.definitions[0].slice(0, 100)}${wordle.definitions[0].length > 100 ? '...' : ''}`)}` : '';
context.setPoints(context.user, points, { key: wordle.mode === 'hard' ? 'hardle' : 'wordle' });
context.sendMessage(`${getBoard(check, false, context)} is correct in ${wordle.guesses.length} guesses! ${style.bold(style.cyan(`${config.usernamePrefix}${context.user.username}`))} gets ${points} ${points > 1 ? 'points' : 'point'}${assignBonusPoints ? '. ' : ` (${wordle.word.length}-letter dictionary too small for bonus points). `}${style.bold(wordle.word)}${definition}`, context.room.id);
wordles.delete(context.room.id);
return;
}
context.sendMessage(`${getBoard(check, true, context)}`, context.room.id);
}
function onCommand(args, context) {
const wordle = wordles.get(context.room.id);
const length = args.map((arg) => Number(arg)).find(Boolean) || settings.length;
const mode = args.find((arg) => arg === 'easy' || arg === 'hard')
|| (context.command === 'hardle' && 'hard')
|| settings.mode;
if (['guess', 'w'].includes(context.command)) {
play(args[0], context);
return;
}
if (context.subcommand === 'stop') {
if (wordle) {
context.sendMessage(`The game was stopped by ${config.usernamePrefix}${context.user.username}. The word was ${style.bold(wordle.word)}.`, context.room.id);
wordles.delete(context.room.id);
return;
}
context.sendMessage(`The there is no wordle going on, ${config.usernamePrefix}${context.user.username}, start one with ${config.prefix}wordle [length]!`, context.room.id);
return;
}
if (!wordle && context.room.id) {
start(length, mode, context);
return;
}
context.sendMessage(`There is already a ${wordle.word.length}-letter wordle going on, guess with ${config.prefix}w [word]!`, context.room.id);
}
module.exports = {
name: 'Wordle',
commands: ['wordle', 'hardle', 'lingo', 'guess', 'w'],
help: `Guess the ${settings.length}-letter word on the board. Submit a guess with ${config.prefix}w [word]. If the letter is green, it is in the correct place. If it is yellow, it is part of the word, but not in the correct place. The number of letters in the word is the highest score you can get. You lose 1 point per guess, down to 1 point. Change the numbers of letters or switch to hard mode with ~wordle [length] [hard] or ~hardle [length].`,
onCommand,
// onMessage,
};

View File

@@ -51,6 +51,7 @@ async function init() {
client.addListener('message', (from, to, body) => onMessage({
from,
to,
recipient: /^#/.test(to) ? null : to,
body,
type: 'message',
}, bot, games));

11
src/knex.js Normal file
View File

@@ -0,0 +1,11 @@
const knex = require('knex');
const instance = process.env.NODE_APP_INSTANCE || 'main';
module.exports = knex({
client: 'better-sqlite3', // or 'better-sqlite3'
useNullAsDefault: true,
connection: {
filename: `./${instance}.sqlite`,
},
});

View File

@@ -3,6 +3,7 @@
const config = require('config');
const fs = require('fs').promises;
const logger = require('simple-node-logger').createSimpleLogger();
const { getWeek } = require('date-fns');
const { argv } = require('yargs');
// const timers = require('timers/promises');
@@ -15,14 +16,16 @@ const points = {};
async function initPoints(identifier) {
try {
const pointsFile = await fs.readFile(`./points-${identifier}.json`, 'utf-8');
const pointsFile = await fs.readFile(`./points/points-${identifier}.json`, 'utf-8');
Object.assign(points, JSON.parse(pointsFile));
} catch (error) {
if (error.code === 'ENOENT') {
logger.info('Creating new points file');
await fs.writeFile(`./points-${identifier}.json`, '{}');
await fs.mkdir('./points', { recursive: true });
await fs.writeFile(`./points/points-${identifier}.json`, '{}');
await initPoints(identifier);
}
}
@@ -50,7 +53,8 @@ async function setPoints(identifier, defaultKey, user, value, { mode = 'add', ke
points[gameKey][userKey] = value;
}
await fs.writeFile(`./points-${identifier}.json`, JSON.stringify(points, null, 4));
await fs.writeFile(`./points/points-${identifier}_backup${getWeek(new Date())}.json`, JSON.stringify(points, null, 4)); // weekly back-up
await fs.writeFile(`./points/points-${identifier}.json`, JSON.stringify(points, null, 4));
}
function getPoints(game, rawUsername, { user, room, command }) {
@@ -73,7 +77,7 @@ function getLeaderboard(game, { user, room, command }) {
return;
}
game.sendMessage(`The top ${Math.min(Object.keys(leaderboard).length, 10)} ${style.italic(game.name)} players are: ${getLeaders(leaderboard, user, false, 10)}`, room.id);
game.sendMessage(`The top ${Math.min(Object.keys(leaderboard).length, 10)} ${style.italic(game.name)} players are: ${getLeaders(leaderboard, user, { ping: false, limit: 10 })}`, room.id);
}
/* eslint-disable no-irregular-whitespace */
@@ -117,15 +121,20 @@ async function getGames(bot, identifier) {
const sendMessage = (body, roomId, options, recipient) => {
const curatedBody = curateMessageBody(body, game, key, options);
if (!roomId && !recipient) {
logger.error(`Missing room ID or recipient for message: ${body}`);
return;
}
if (config.platform === 'irc') {
bot.client.say(roomId || recipient, curatedBody);
bot.client.say(/^#/.test(roomId) ? roomId : recipient, curatedBody);
}
if (config.platform === 'schat') {
bot.socket.transmit('message', {
roomId,
recipient,
type: recipient && options?.type !== 'message' ? 'whisper' : 'message',
recipient: options?.type === 'whisper' || !roomId ? recipient : null,
type: options?.type || 'message',
body: curatedBody,
style: { ...config.style, ...options?.style },
});
@@ -144,7 +153,7 @@ async function getGames(bot, identifier) {
};
if (game.onStart) {
game.onStart({ ...curatedGame, bot });
game.onStart({ ...curatedGame, bot, logger });
}
return {
@@ -248,6 +257,8 @@ function onMessage(message, bot, games) {
...game,
bot,
message,
containsCommand: command,
containsSubcommand: subcommand,
user: user && {
...user,
points: points[game.key]?.[`${user.id}:${user.username}`] || 0,

View File

@@ -17,10 +17,9 @@ const instance = process.env.NODE_APP_INSTANCE || 'main';
logger.setLevel(argv.level || 'info');
async function auth() {
const httpSession = bhttp.session();
const username = config.uniqueUsername ? `${config.user.username}-${new Date().getTime().toString().slice(-5)}` : config.user.username;
const res = await httpSession.post(`${config.api}/session`, {
const res = await bhttp.post(`${config.api}/session`, {
...config.user,
username,
}, {
@@ -35,13 +34,17 @@ async function auth() {
return {
user: res.body,
httpSession,
sessionCookie: res.headers['set-cookie'][0],
// auth may return an explicit auth cookie domain, but we connect through the VPN domain that would break the cookie, so don't use a bhttp session and strip the domain
sessionCookie: res.headers['set-cookie'][0].replace(/Domain=.*;/, ''),
};
}
async function getWsId(httpSession) {
const res = await httpSession.get(`${config.api}/socket`);
async function getWsId(sessionCookie) {
const res = await bhttp.get(`${config.api}/socket`, {
headers: {
cookie: sessionCookie,
},
});
if (res.statusCode !== 200) {
throw new Error(`Failed to retrieve WebSocket ID: ${res.body.toString()}`);
@@ -60,8 +63,27 @@ async function onRooms({ rooms }, bot) {
const usersRes = await bhttp.get(`${config.api}/room/${rooms.map((room) => room.id).join(',')}/users`);
const users = usersRes.body;
const userIdsByRoom = Object.values(users).reduce((acc, user) => {
user.sharedRooms.forEach((roomId) => {
if (!acc[roomId]) {
acc[roomId] = [];
}
acc[roomId].push(user.id);
});
return acc;
}, {});
/* eslint-disable no-param-reassign */
bot.rooms = rooms.reduce((acc, room) => ({ ...acc, [room.id]: room }), {});
bot.rooms = rooms.reduce((acc, room) => ({
...acc,
[room.id]: {
...room,
userIds: userIdsByRoom[room.id],
},
}), {});
bot.users = { ...bot.users, ...users };
/* eslint-enable no-param-reassign */
@@ -108,16 +130,20 @@ function handleError(error, socket, domain, data) {
}
}
const pongRegex = /^pong:\d+$/;
async function connect(bot, games) {
const socket = { ws: { readyState: 0 } };
const socket = {
ws: { readyState: 0 },
io: {},
};
socket.connect = async () => {
try {
const { user, httpSession, sessionCookie } = await auth();
const wsCreds = await getWsId(httpSession);
const { user, sessionCookie } = await auth();
const wsCreds = await getWsId(sessionCookie);
bot.user = user;
bot.httpSession = httpSession;
logger.info(`Attempting to connect to ${config.socket}`);
@@ -127,7 +153,14 @@ async function connect(bot, games) {
},
});
socket.ws.on('message', async (msg) => {
socket.ws.on('message', async (msgData) => {
const msg = msgData.toString();
if (typeof msg === 'string' && pongRegex.test(msg)) {
logger.debug(`Received pong ${msg.split(':')[1]}`);
return;
}
const [domain, data] = JSON.parse(msg);
logger.debug(`Received ${domain}: ${JSON.stringify(data)}`);
@@ -165,6 +198,21 @@ async function connect(bot, games) {
socket.ws.send(JSON.stringify([domain, data]));
};
function ping() {
setTimeout(() => {
if (socket.ws && socket.ws?.readyState === socket.ws?.OPEN) {
const now = Date.now();
socket.ws.send(`ping:${now}`);
logger.debug(`Sent ping ${now}`);
}
ping();
}, 10000);
}
ping();
socket.connect();
return socket;

View File

@@ -0,0 +1,25 @@
const path = require('path');
const args = require('yargs').argv;
function init() {
const scoresA = require(path.resolve(__dirname, args.a));
const scoresB = require(path.resolve(__dirname, args.b));
const sum = {};
[scoresA, scoresB].forEach((record) => {
Object.entries(record).forEach(([game, scores]) => {
if (!sum[game]) {
sum[game] = {};
}
Object.entries(scores).forEach(([id, score]) => {
sum[game][id] = (sum[game][id] || 0) + score;
});
});
});
console.log(JSON.stringify(sum, null, 4));
}
init();

View File

@@ -0,0 +1,35 @@
const path = require('path');
const args = require('yargs').argv;
const fs = require('fs').promises;
async function init() {
await fs.copyFile(args.file, `${path.basename(args.file, path.extname(args.file))}_backup_${Date.now()}.json`);
const filepath = path.join(process.cwd(), args.file);
const points = require(filepath); // eslint-disable-line
Object.entries(points).forEach(([game, scores]) => {
const originKey = Object.keys(scores).find((userKey) => userKey.includes(`:${args.origin}`));
const originScore = scores[originKey];
const targetKey = Object.keys(scores).find((userKey) => userKey.includes(`:${args.target}`));
const targetScore = scores[targetKey];
if (typeof originScore === 'undefined' || typeof targetScore === 'undefined') {
return;
}
const totalScore = targetScore + originScore;
points[game][targetKey] = targetScore + originScore;
delete points[game][originKey];
console.log(`${game} ${targetScore} (${args.target}) + ${originScore} (${args.origin}) = ${totalScore}`);
});
await fs.writeFile(filepath, JSON.stringify(points, null, 4));
console.log(`Saved ${filepath}`);
}
init();

View File

@@ -3,15 +3,21 @@
const config = require('config');
const style = require('./style');
function getLeaders(points, user, ping = true, limit = Infinity) {
function getLeaders(points, user, {
ping = true,
limit = 20,
skip = [],
pointsWord = 'points',
} = {}) {
return Object.entries(points)
.filter(([userKey]) => !skip.includes(userKey))
.sort(([, scoreA], [, scoreB]) => scoreB - scoreA)
.slice(0, limit)
.map(([userKey, score], index) => {
const username = userKey.split(':')[1] || userKey; // process the points file
if (index === 0) {
return `${style.bold(style.yellow(`${ping || username === user.username ? config.usernamePrefix : ''}${username}`))} with ${style.bold(`${score}`)} points`;
return `${style.bold(style.yellow(`${ping || username === user.username ? config.usernamePrefix : ''}${username}`))} with ${style.bold(`${score}`)} ${pointsWord}`;
}
return `${style.bold(style.cyan(`${ping ? config.usernamePrefix : ''}${username}`))} with ${style.bold(`${score}`)} points`;

1866
src/utils/numbers-solver.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -21,7 +21,7 @@ function schatCode(text) {
function curate(fn) {
return (text) => {
if (text) {
if (typeof text !== 'undefined' && text !== null) {
return fn(text);
}
@@ -42,6 +42,7 @@ const styleMethods = (() => {
return {
...styles,
forest: styles.green,
orange: styles.yellow,
code: bypass,
};
}