From 2b502cea27250981490512cfe39df238b2c66fcc Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Wed, 10 Jul 2024 07:40:33 +1000 Subject: [PATCH 001/145] Ignore partyjoin interactions in global handler --- src/lib/util/globalInteractions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/util/globalInteractions.ts b/src/lib/util/globalInteractions.ts index 4d068056ef..b819a41dfd 100644 --- a/src/lib/util/globalInteractions.ts +++ b/src/lib/util/globalInteractions.ts @@ -310,7 +310,7 @@ async function handleGEButton(user: MUser, id: string, interaction: ButtonIntera export async function interactionHook(interaction: Interaction) { if (!interaction.isButton()) return; - if (['CONFIRM', 'CANCEL'].includes(interaction.customId)) return; + if (['CONFIRM', 'CANCEL', 'PARTY_JOIN'].includes(interaction.customId)) return; if (interaction.customId.startsWith('LP_')) return; if (globalClient.isShuttingDown) { From ac41f4e9bd559a023a70a05341263edda3f83e11 Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Wed, 10 Jul 2024 07:43:14 +1000 Subject: [PATCH 002/145] Remove eval command --- src/mahoji/commands/admin.ts | 101 +---------------------------------- 1 file changed, 2 insertions(+), 99 deletions(-) diff --git a/src/mahoji/commands/admin.ts b/src/mahoji/commands/admin.ts index e62d8710d0..8425910bef 100644 --- a/src/mahoji/commands/admin.ts +++ b/src/mahoji/commands/admin.ts @@ -1,14 +1,11 @@ import { execSync } from 'node:child_process'; -import { inspect } from 'node:util'; -import { type CommandRunOptions, Stopwatch, bulkUpdateCommands } from '@oldschoolgg/toolkit'; -import type { CommandResponse } from '@oldschoolgg/toolkit'; +import { type CommandRunOptions, bulkUpdateCommands } from '@oldschoolgg/toolkit'; import type { MahojiUserOption } from '@oldschoolgg/toolkit'; import type { ClientStorage } from '@prisma/client'; import { economy_transaction_type } from '@prisma/client'; -import { isThenable } from '@sentry/utils'; import type { InteractionReplyOptions } from 'discord.js'; -import { AttachmentBuilder, codeBlock, escapeCodeBlock } from 'discord.js'; +import { AttachmentBuilder } from 'discord.js'; import { ApplicationCommandOptionType } from 'discord.js'; import { Time, calcWhatPercent, noOp, notEmpty, randArrItem, sleep, uniqueArr } from 'e'; import { Bank } from 'oldschooljs'; @@ -29,13 +26,11 @@ import { globalConfig } from '../../lib/constants'; import { economyLog } from '../../lib/economyLogs'; -import { generateGearImage } from '../../lib/gear/functions/generateGearImage'; import type { GearSetup } from '../../lib/gear/types'; import { GrandExchange } from '../../lib/grandExchange'; import { countUsersWithItemInCl } from '../../lib/settings/prisma'; import { cancelTask, minionActivityCacheDelete } from '../../lib/settings/settings'; import { sorts } from '../../lib/sorts'; -import { Gear } from '../../lib/structures/Gear'; import { calcPerHour, cleanString, @@ -51,7 +46,6 @@ import { mahojiClientSettingsFetch, mahojiClientSettingsUpdate } from '../../lib import getOSItem, { getItem } from '../../lib/util/getOSItem'; import { handleMahojiConfirmation } from '../../lib/util/handleMahojiConfirmation'; import { deferInteraction, interactionReply } from '../../lib/util/interactionReply'; -import { logError } from '../../lib/util/logError'; import { makeBankImage } from '../../lib/util/makeBankImage'; import { parseBank } from '../../lib/util/parseStringBank'; import { sendToChannelID } from '../../lib/util/webhook'; @@ -68,61 +62,6 @@ export const gifs = [ 'https://tenor.com/view/monkey-monito-mask-gif-23036908' ]; -async function unsafeEval({ userID, code }: { userID: string; code: string }) { - if (!OWNER_IDS.includes(userID)) return { content: 'Unauthorized' }; - code = code.replace(/[“”]/g, '"').replace(/[‘’]/g, "'"); - const stopwatch = new Stopwatch(); - let syncTime = '?'; - let asyncTime = '?'; - let result = null; - let thenable = false; - try { - // biome-ignore lint/security/noGlobalEval: - result = eval(code); - syncTime = stopwatch.toString(); - if (isThenable(result)) { - thenable = true; - stopwatch.restart(); - result = await result; - asyncTime = stopwatch.toString(); - } - } catch (error: any) { - if (!syncTime) syncTime = stopwatch.toString(); - if (thenable && !asyncTime) asyncTime = stopwatch.toString(); - if (error?.stack) logError(error); - result = error; - } - - stopwatch.stop(); - if (result instanceof Bank) { - return { files: [(await makeBankImage({ bank: result })).file] }; - } - if (result instanceof Gear) { - const image = await generateGearImage(await mUserFetch(userID), result, null, null); - return { files: [image] }; - } - - if (Buffer.isBuffer(result)) { - return { - content: 'The result was a buffer.', - files: [result] - }; - } - - if (typeof result !== 'string') { - result = inspect(result, { - depth: 1, - showHidden: false - }); - } - - return { - content: `${codeBlock(escapeCodeBlock(result))} -**Time:** ${asyncTime ? `⏱ ${asyncTime}<${syncTime}>` : `⏱ ${syncTime}`} -` - }; -} - async function allEquippedPets() { const pets = await prisma.$queryRawUnsafe<{ pet: number; qty: number }[]>(`SELECT "minion.equippedPet" AS pet, COUNT("minion.equippedPet")::int AS qty FROM users @@ -136,25 +75,6 @@ ORDER BY qty DESC;`); return bank; } -async function evalCommand(userID: string, code: string): CommandResponse { - try { - if (!OWNER_IDS.includes(userID)) { - return "You don't have permission to use this command."; - } - const res = await unsafeEval({ code, userID }); - - if (res.content && res.content.length > 2000) { - return { - files: [{ attachment: Buffer.from(res.content), name: 'output.txt' }] - }; - } - - return res; - } catch (err: any) { - return err.message ?? err; - } -} - async function getAllTradedItems(giveUniques = false) { const economyTrans = await prisma.economyTransaction.findMany({ where: { @@ -507,19 +427,6 @@ export const adminCommand: OSBMahojiCommand = { name: 'debug_patreon', description: 'Debug patreon.' }, - { - type: ApplicationCommandOptionType.Subcommand, - name: 'eval', - description: 'Eval.', - options: [ - { - type: ApplicationCommandOptionType.String, - name: 'code', - description: 'Code', - required: true - } - ] - }, { type: ApplicationCommandOptionType.Subcommand, name: 'sync_commands', @@ -772,7 +679,6 @@ export const adminCommand: OSBMahojiCommand = { reboot?: {}; shut_down?: {}; debug_patreon?: {}; - eval?: { code: string }; sync_commands?: {}; item_stats?: { item: string }; sync_blacklist?: {}; @@ -1110,9 +1016,6 @@ ${guildCommands.length} Guild commands`; return randArrItem(gifs); } - if (options.eval) { - return evalCommand(userID.toString(), options.eval.code); - } if (options.item_stats) { const item = getItem(options.item_stats.item); if (!item) return 'Invalid item.'; From 2129869b2ef1f747d0aa6c370b93e1359cf2aa2e Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Wed, 10 Jul 2024 07:43:24 +1000 Subject: [PATCH 003/145] Pkg.json command cleanup --- package.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 9673ae32e9..4574373a21 100644 --- a/package.json +++ b/package.json @@ -8,9 +8,10 @@ "prettify": "prettier --use-tabs \"./**/*.{md,yml}\" --write", "lint": "concurrently --raw --kill-others-on-fail \"biome check --write --unsafe --diagnostic-level=error\" \"yarn prettify\"", "build:tsc": "tsc -p src", + "watch:tsc": "tsc -w -p src", "wipedist": "node -e \"try { require('fs').rmSync('dist', { recursive: true }) } catch(_){}\"", - "dev": "yarn && yarn wipedist && yarn lint && yarn build && yarn test && npm i -g dpdm && dpdm --exit-code circular:1 --progress=false --warning=false --tree=false ./dist/index.js", - "test": "concurrently --raw --kill-others-on-fail \"tsc -p src\" \"yarn test:lint\" \"yarn test:unit\" \"tsc -p tests/integration\" \"tsc -p tests/unit\"", + "cleanup": "yarn && yarn wipedist && yarn lint && yarn build && yarn test && npm i -g dpdm && dpdm --exit-code circular:1 --progress=false --warning=false --tree=false ./dist/index.js", + "test": "concurrently --raw --kill-others-on-fail \"tsc -p src\" \"yarn test:lint\" \"yarn test:unit\" \"yarn build:esbuild\"", "test:lint": "biome check --diagnostic-level=error", "test:unit": "vitest run --coverage --config vitest.unit.config.mts", "test:watch": "vitest --config vitest.unit.config.mts --coverage", From 80e69c9c9e42d844657c9ead25b7c54983fabffb Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Wed, 10 Jul 2024 09:12:09 +1000 Subject: [PATCH 004/145] Better error logging for interaction defer errors --- src/lib/util/interactionReply.ts | 8 +++++--- src/lib/util/logError.ts | 8 ++++++++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/lib/util/interactionReply.ts b/src/lib/util/interactionReply.ts index 2fe14d6626..ae87affd40 100644 --- a/src/lib/util/interactionReply.ts +++ b/src/lib/util/interactionReply.ts @@ -43,10 +43,12 @@ export async function deferInteraction( if (wasDeferred.size > 1000) wasDeferred.clear(); if (!interaction.deferred && !wasDeferred.has(interaction.id)) { wasDeferred.add(interaction.id); - const promise = await interaction.deferReply({ ephemeral }); interaction.deferred = true; - wasDeferred.add(interaction.id); - return promise; + try { + await interaction.deferReply({ ephemeral }); + } catch (err) { + logErrorForInteraction(err, interaction); + } } } diff --git a/src/lib/util/logError.ts b/src/lib/util/logError.ts index 48df4c3113..2eb4da18f1 100644 --- a/src/lib/util/logError.ts +++ b/src/lib/util/logError.ts @@ -2,6 +2,7 @@ import { convertAPIOptionsToCommandOptions } from '@oldschoolgg/toolkit'; import { captureException } from '@sentry/node'; import type { Interaction } from 'discord.js'; +import { isObject } from 'e'; import { production } from '../../config'; export function assert(condition: boolean, desc?: string, context?: Record) { @@ -45,5 +46,12 @@ export function logErrorForInteraction(err: Error | unknown, interaction: Intera context.button_id = interaction.customId; } + if ('rawError' in interaction) { + const _err = err as any; + if ('requestBody' in _err && isObject(_err.requestBody)) { + context.request_body = JSON.stringify(_err.requestBody); + } + } + logError(err, context); } From d31396e250970b838e848038bbd97b381a1d52f2 Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Wed, 10 Jul 2024 09:30:20 +1000 Subject: [PATCH 005/145] Trivia command fixes/improvements --- src/lib/DynamicButtons.ts | 13 ++++++++----- src/lib/roboChimp.ts | 16 +++++++++++++++- src/lib/util/globalInteractions.ts | 2 +- src/lib/util/interactionReply.ts | 6 +++--- 4 files changed, 27 insertions(+), 10 deletions(-) diff --git a/src/lib/DynamicButtons.ts b/src/lib/DynamicButtons.ts index d2ebaa8e45..f894c792ec 100644 --- a/src/lib/DynamicButtons.ts +++ b/src/lib/DynamicButtons.ts @@ -1,5 +1,6 @@ import type { BaseMessageOptions, + ButtonInteraction, DMChannel, Message, MessageComponentInteraction, @@ -13,6 +14,7 @@ import murmurhash from 'murmurhash'; import { BLACKLISTED_USERS } from './blacklists'; import { awaitMessageComponentInteraction, makeComponents } from './util'; +import { silentButtonAck } from './util/handleMahojiConfirmation'; import { minionIsBusy } from './util/minionIsBusy'; type DynamicButtonFn = (opts: { message: Message; interaction: MessageComponentInteraction }) => unknown; @@ -83,9 +85,11 @@ export class DynamicButtons { ...messageOptions, components: makeComponents(buttons) }); - const collectedInteraction = await awaitMessageComponentInteraction({ + const collectedInteraction: ButtonInteraction = (await awaitMessageComponentInteraction({ message: this.message, - filter: i => { + filter: async i => { + if (!i.isButton()) return false; + await silentButtonAck(i); if (BLACKLISTED_USERS.has(i.user.id)) return false; if (this.usersWhoCanInteract.includes(i.user.id)) { return true; @@ -94,7 +98,7 @@ export class DynamicButtons { return false; }, time: this.timer ?? Time.Second * 20 - }).catch(noOp); + }).catch(noOp)) as ButtonInteraction; if (this.deleteAfterConfirm === true) { await this.message.delete().catch(noOp); } else { @@ -104,7 +108,6 @@ export class DynamicButtons { if (collectedInteraction) { for (const button of this.buttons) { if (collectedInteraction.customId === button.id) { - collectedInteraction.deferUpdate(); if (minionIsBusy(collectedInteraction.user.id) && button.cantBeBusy) { return collectedInteraction.reply({ content: "Your action couldn't be performed, because your minion is busy.", @@ -136,7 +139,7 @@ export class DynamicButtons { const id = murmurhash(name).toString(); this.buttons.push({ name, - id, + id: `DYN_${id}`, fn, emoji, cantBeBusy: cantBeBusy ?? false, diff --git a/src/lib/roboChimp.ts b/src/lib/roboChimp.ts index 5c7e51bfcb..1bd33e5d9b 100644 --- a/src/lib/roboChimp.ts +++ b/src/lib/roboChimp.ts @@ -3,7 +3,7 @@ import type { TriviaQuestion, User } from '@prisma/robochimp'; import { calcWhatPercent, round, sumArr } from 'e'; import deepEqual from 'fast-deep-equal'; -import { BOT_TYPE, masteryKey } from './constants'; +import { BOT_TYPE, globalConfig, masteryKey } from './constants'; import { getTotalCl } from './data/Collections'; import { calculateMastery } from './mastery'; import { MUserStats } from './structures/MUserStats'; @@ -11,6 +11,20 @@ import { MUserStats } from './structures/MUserStats'; export type RobochimpUser = User; export async function getRandomTriviaQuestions(): Promise { + if (!globalConfig.isProduction) { + return [ + { + id: 1, + question: 'What is 1+1?', + answers: ['2'] + }, + { + id: 2, + question: 'What is 2+2?', + answers: ['4'] + } + ]; + } const random: TriviaQuestion[] = await roboChimpClient.$queryRaw`SELECT id, question, answers FROM trivia_question ORDER BY random() diff --git a/src/lib/util/globalInteractions.ts b/src/lib/util/globalInteractions.ts index b819a41dfd..b4f369c74b 100644 --- a/src/lib/util/globalInteractions.ts +++ b/src/lib/util/globalInteractions.ts @@ -311,7 +311,7 @@ async function handleGEButton(user: MUser, id: string, interaction: ButtonIntera export async function interactionHook(interaction: Interaction) { if (!interaction.isButton()) return; if (['CONFIRM', 'CANCEL', 'PARTY_JOIN'].includes(interaction.customId)) return; - if (interaction.customId.startsWith('LP_')) return; + if (['DYN_', 'LP_'].some(s => interaction.customId.startsWith(s))) return; if (globalClient.isShuttingDown) { return interactionReply(interaction, { diff --git a/src/lib/util/interactionReply.ts b/src/lib/util/interactionReply.ts index ae87affd40..c737c6a690 100644 --- a/src/lib/util/interactionReply.ts +++ b/src/lib/util/interactionReply.ts @@ -6,7 +6,8 @@ import type { InteractionReplyOptions, InteractionResponse, Message, - RepliableInteraction + RepliableInteraction, + StringSelectMenuInteraction } from 'discord.js'; import { DiscordAPIError } from 'discord.js'; @@ -37,13 +38,12 @@ export async function interactionReply(interaction: RepliableInteraction, respon const wasDeferred = new Set(); export async function deferInteraction( - interaction: ButtonInteraction | ChatInputCommandInteraction, + interaction: ButtonInteraction | ChatInputCommandInteraction | StringSelectMenuInteraction, ephemeral = false ) { if (wasDeferred.size > 1000) wasDeferred.clear(); if (!interaction.deferred && !wasDeferred.has(interaction.id)) { wasDeferred.add(interaction.id); - interaction.deferred = true; try { await interaction.deferReply({ ephemeral }); } catch (err) { From 685730f6191ff8df1ba1c3bda8dde2097d2e1842 Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Wed, 10 Jul 2024 09:30:32 +1000 Subject: [PATCH 006/145] Add isproduction globalconfig --- src/lib/constants.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 5998a1ca5d..2addcf0916 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -13,6 +13,7 @@ import { DISCORD_SETTINGS, production } from '../config'; import type { AbstractCommand } from '../mahoji/lib/inhibitors'; import { SkillsEnum } from './skilling/types'; import type { ActivityTaskData } from './types/minions'; +import { assert } from './util'; export { PerkTier }; @@ -533,7 +534,8 @@ const globalConfigSchema = z.object({ geAdminChannelID: z.string().default(''), redisPort: z.coerce.number().int().optional(), botToken: z.string().min(1), - isCI: z.coerce.boolean().default(false) + isCI: z.coerce.boolean().default(false), + isProduction: z.coerce.boolean().default(production) }); dotenv.config({ path: path.resolve(process.cwd(), process.env.TEST ? '.env.test' : '.env') }); @@ -548,9 +550,14 @@ export const globalConfig = globalConfigSchema.parse({ geAdminChannelID: process.env.GE_ADMIN_CHANNEL_ID, redisPort: process.env.REDIS_PORT, botToken: process.env.BOT_TOKEN, - isCI: process.env.CI + isCI: process.env.CI, + isProduction: process.env.NODE_ENV === 'production' }); +assert( + (process.env.NODE_ENV === 'production') === globalConfig.isProduction && production === globalConfig.isProduction +); + export const ONE_TRILLION = 1_000_000_000_000; export const demonBaneWeapons = resolveItems(['Silverlight', 'Darklight', 'Arclight']); From 7641834ab21a06bfbe248ce05b7f9e27f826124f Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Wed, 10 Jul 2024 09:39:49 +1000 Subject: [PATCH 007/145] Cleanup --- package.json | 10 +++++----- src/lib/constants.ts | 7 +++---- src/lib/slayer/slayerUtil.ts | 5 ++--- tests/integration/commands/sacrifice.test.ts | 4 ++-- tests/unit/snapshots/cl.OSB.png | Bin 48284 -> 48247 bytes tests/unit/snapshots/cox.OSB.png | Bin 38681 -> 38596 bytes tests/unit/snapshots/toa.OSB.png | Bin 25498 -> 25445 bytes yarn.lock | 10 +++++----- 8 files changed, 17 insertions(+), 19 deletions(-) diff --git a/package.json b/package.json index 4574373a21..6f15f939cd 100644 --- a/package.json +++ b/package.json @@ -14,12 +14,12 @@ "test": "concurrently --raw --kill-others-on-fail \"tsc -p src\" \"yarn test:lint\" \"yarn test:unit\" \"yarn build:esbuild\"", "test:lint": "biome check --diagnostic-level=error", "test:unit": "vitest run --coverage --config vitest.unit.config.mts", + "test:docker": "docker-compose up --build --abort-on-container-exit", "test:watch": "vitest --config vitest.unit.config.mts --coverage", - "test:integration": "tsx ./src/scripts/integration-tests.ts", - "buildandrun": "yarn build:esbuild && node dist", + "buildandrun": "yarn build:esbuild && node --enable-source-maps dist", "build:esbuild": "concurrently --raw \"yarn build:main\" \"yarn build:workers\"", - "build:main": "esbuild src/index.ts src/lib/workers/index.ts --minify --legal-comments=none --outdir=./dist --log-level=error --bundle --platform=node --loader:.node=file --external:@napi-rs/canvas --external:@prisma/robochimp --external:@prisma/client --external:zlib-sync --external:bufferutil --external:oldschooljs --external:discord.js --external:node-fetch --external:piscina", - "build:workers": "esbuild src/lib/workers/kill.worker.ts src/lib/workers/finish.worker.ts src/lib/workers/casket.worker.ts --log-level=error --bundle --minify --legal-comments=none --outdir=./dist/lib/workers --platform=node --loader:.node=file --external:@napi-rs/canvas --external:@prisma/robochimp --external:@prisma/client --external:zlib-sync --external:bufferutil --external:oldschooljs --external:discord.js --external:node-fetch --external:piscina" + "build:main": "esbuild src/index.ts src/lib/workers/index.ts --sourcemap=inline --minify --legal-comments=none --outdir=./dist --log-level=error --bundle --platform=node --loader:.node=file --external:@napi-rs/canvas --external:@prisma/robochimp --external:@prisma/client --external:zlib-sync --external:bufferutil --external:oldschooljs --external:discord.js --external:node-fetch --external:piscina", + "build:workers": "esbuild src/lib/workers/kill.worker.ts src/lib/workers/finish.worker.ts src/lib/workers/casket.worker.ts --sourcemap=inline --log-level=error --bundle --minify --legal-comments=none --outdir=./dist/lib/workers --platform=node --loader:.node=file --external:@napi-rs/canvas --external:@prisma/robochimp --external:@prisma/client --external:zlib-sync --external:bufferutil --external:oldschooljs --external:discord.js --external:node-fetch --external:piscina" }, "dependencies": { "@napi-rs/canvas": "^0.1.53", @@ -39,7 +39,7 @@ "murmurhash": "^2.0.1", "node-cron": "^3.0.3", "node-fetch": "^2.6.7", - "oldschooljs": "^2.5.9", + "oldschooljs": "^2.5.10", "p-queue": "^6.6.2", "piscina": "^4.6.1", "random-js": "^2.1.0", diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 2addcf0916..52860b3548 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -13,7 +13,6 @@ import { DISCORD_SETTINGS, production } from '../config'; import type { AbstractCommand } from '../mahoji/lib/inhibitors'; import { SkillsEnum } from './skilling/types'; import type { ActivityTaskData } from './types/minions'; -import { assert } from './util'; export { PerkTier }; @@ -554,9 +553,9 @@ export const globalConfig = globalConfigSchema.parse({ isProduction: process.env.NODE_ENV === 'production' }); -assert( - (process.env.NODE_ENV === 'production') === globalConfig.isProduction && production === globalConfig.isProduction -); +if ((process.env.NODE_ENV === 'production') !== globalConfig.isProduction || production !== globalConfig.isProduction) { + throw new Error('The NODE_ENV and isProduction variables must match'); +} export const ONE_TRILLION = 1_000_000_000_000; export const demonBaneWeapons = resolveItems(['Silverlight', 'Darklight', 'Arclight']); diff --git a/src/lib/slayer/slayerUtil.ts b/src/lib/slayer/slayerUtil.ts index 9a9237fe64..c8cb21238f 100644 --- a/src/lib/slayer/slayerUtil.ts +++ b/src/lib/slayer/slayerUtil.ts @@ -11,8 +11,7 @@ import type { KillableMonster } from '../minions/types'; import { getNewUser } from '../settings/settings'; import { SkillsEnum } from '../skilling/types'; -import { bankHasItem, roll, stringMatches } from '../util'; -import itemID from '../util/itemID'; +import { roll, stringMatches } from '../util'; import { logError } from '../util/logError'; import { autoslayModes } from './constants'; import { slayerMasters } from './slayerMasters'; @@ -144,7 +143,7 @@ function userCanUseTask(user: MUser, task: AssignableSlayerTask, master: SlayerM // Slayer unlock restrictions: const lmon = task.monster.name.toLowerCase(); const lmast = master.name.toLowerCase(); - if (lmon === 'grotesque guardians' && !bankHasItem(user.bank.bank, itemID('Brittle key'))) return false; + if (lmon === 'grotesque guardians' && !user.bank.has('Brittle key')) return false; if (lmon === 'lizardman' && !myUnlocks.includes(SlayerTaskUnlocksEnum.ReptileGotRipped)) return false; if (lmon === 'red dragon' && !myUnlocks.includes(SlayerTaskUnlocksEnum.SeeingRed)) return false; if (lmon === 'mithril dragon' && !myUnlocks.includes(SlayerTaskUnlocksEnum.IHopeYouMithMe)) return false; diff --git a/tests/integration/commands/sacrifice.test.ts b/tests/integration/commands/sacrifice.test.ts index 4590b6b394..9a35f2705b 100644 --- a/tests/integration/commands/sacrifice.test.ts +++ b/tests/integration/commands/sacrifice.test.ts @@ -22,7 +22,7 @@ describe('Sacrifice Command', async () => { test('No items provided', async () => { const result = await user.runCommand(sacrificeCommand, { items: 'aaaa' }); - expect(result).toEqual('No items were provided.\nYour current sacrificed value is: 1,590 (1.59k)'); + expect(result).toEqual('No items were provided.\nYour current sacrificed value is: 1,590 (1.6k)'); }); test('Successful', async () => { @@ -30,7 +30,7 @@ describe('Sacrifice Command', async () => { const result = await user.runCommand(sacrificeCommand, { items: '1 trout, 10 coal' }); await user.sync(); expect(result).toEqual( - 'You sacrificed 10x Coal, 1x Trout, with a value of 1,590gp (1.59k). Your total amount sacrificed is now: 3,180. ' + 'You sacrificed 10x Coal, 1x Trout, with a value of 1,590gp (1.6k). Your total amount sacrificed is now: 3,180. ' ); const stats = await user.fetchStats({ sacrificed_bank: true }); expect(user.bank.toString()).toBe(new Bank().toString()); diff --git a/tests/unit/snapshots/cl.OSB.png b/tests/unit/snapshots/cl.OSB.png index f4d9e0aa2ff0b1b96b07f869badb460e2efb3a80..b7daf0d5dadd0a920c4dc49baa37a682b3118e24 100644 GIT binary patch literal 48247 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 Bc3%Jh literal 48284 zcmZU41zc0#-~VW&Q3>f#K^mNtz-R=K66scu5Rhin2x$S8lvF~H2I(5zJrSh4d(>cT z|I7FHd!Fa_JpaAmb=jQvo_o(ZpYyI0simPpPQpL}0)fa?pDF5qKsYDBuQV|s@X59C zr!(My5F1EUQBK!8eYaWl6KBs?feR=f2v>H$ z*QnCq>jNF#=RTF%MN!-56hj<4%{Khk#f@>x9BIY_wr=-+ZFQs!sk)^N{JNL+^6RBY zxWE_5kAKx2iVdUR)ol%Zf1tzg3#wDp*tO56bD#HdkfMMrD57g)aS4YqT!pRNMsEx* zqKnHy4MStPSXnfC5*KoPu1wLDyq zt!NteU6OLXJ9AESYpw(z=xwW~xoM+;AK(wRPT6`Bx!Rdf>#x9np2H}65kAjM^5l#3>C^AcWdhRH2LS=91w8edp+Lsf@WmrsgmO z7u4*R_kMoRMhg?3C4FWc^Y*byG9+JaxT!xYFOlXQb_c$^xX4!8$SOUJV`yraUF1*% zbOH#30BaXbXyx+B#uD^sQ^_xDGEES%kx^%fk$C4?+`aR@uoX!rrzufcL8q zog$AD{nb7Ebn)oVTXKT$JbU!wMCwQ0LBS~60i*Ky=_4>UM-B-=r)<})Ls-Wz#K`dy zbi6a^VupUXM+avkT*mf1fsS5+%6ufhLwqUkO^PGmP0T(vbWB~y@j)j_wt*5mYth54)v{I)ag%E)Ow+P=fv+C5ZzxCbNDdJt{qi zc&r3+K}}H_q*8b}^J&+4k?x;~&z?dZGn4WPdUE+|T6PpOi0<8-c;43mlaDuqJv?z4 z8Q^`6%*xm&lA)z7ED+E=>g&)PbE*X~@blrx&|TLNEWE`#Bd$!$1iwZo!wE#6wQPH{ zG*38X_px$+AW92gDSoZVfb}xBq)wYWD7M3>w8lFDZ-3%}oSI)fXYw_aiwQyp$H$OE-rPa2G@U1n69h$u_L=If4>`S*v^+aV)d zxlI%+*uCVx&CV_Lw0(ay(E&_t_1;^~df}PAI%gwLI>)D!$5U=*X>^Y3-Hi6qb(t%h zsYJ61q3oAYXt#sVZ3baY4yWd>r1MOV02Zeq7xNl>ZeEm6TaH}B=0mR5epbOMbf_KEkOPdb&`^$L?0iwog%``! zh@?u*z4-#&t-cZJQ@x;#mq{6fh5@N15yw#LOwXayrw(jo&ONNLz;jMXWgWh5)@Kwx z7jZ|yT-RWjx7ODvkV4PdL38=b%=Ldn)>d)r| z2~Sox4(E#tU@Vw2^Xpg<(v_k)AMr#Wju*? z0#rCUX_N2$7c~3?y>@YRAMGa5BpbgL6Kywxy`J30lWrG`zFzQibF>|KMVRMaymKsD z^>s(TtaHmnXnv1>*6&Cy8+SdC&Bg-zX<1bfqcrQiB(e6yZjqdmrn5hN3mIPTkwx3W zcLt%b1!J2g?181B?cBe{kTma_+pEV#>!;p(KXm&1+sokd2K{QQ|GR|bf5UZ{z)oB z5q^bQM02ebsHp2YHGt`A#A}{CdS%8NSJWq6XzZL3iGm$oUk6>C9#r7UKWl%%(=SVi z&tT{DJ#TMybn~yR6zA*4A<7mEqe&L`>V%T5BSz#V zimX>wP!d#oz?_&ssjgswIrDQGQY7A$>9jCUG&iAaf)OfJ-ud}9CLg(hyOcq-&}i&D zad^<4<&J* zz{73djHmQ#^OLt-wv5ez_pb37r5L_;S&lBUA1dm;)fHR}OQ&JuAmV{i%3MsaVZV^k zbOt{|&-xzFy*?V@brYsMH=7$6@=Z5i^7(9@*n)RNqH_t1iEp*FggnUmBipia0qC}K z#@DB`%0rZz&wt&_%&rRD<#5g+403TjdLhFI75^t>$mi{9RQl;3_`tYp0>o8QtO%`& z(By-?ne{*4_8OvpaLAvoH+Q-I(S)3q>`77-%Oo1Rm z9J!yX7LJbOYp(Yu>>rr1>Uo63`=z7=4H&KI^ht^hyg!y9YI#u87i)rJa>Ig6h}$Ed9SW#BoW=y+*J^`6k5=4oe~bC7Bk!2xN0L}jTx-&O^Y($ z-%dt_g2_HTP#D~p^Ma^PnPt9}B2lFKrIzGvVtpq;XVyDMbo635CfV{ViwN4ERlL~2 z7x8-Y(>rm0GU;aF)Sb-ZlbG5Yqnb^4dYj$8!d(2MTlCdBlz|&G#vpe> z*kr4X7az9uVQ<6B5W0&m`n&ad$d}@P%p~2gx5%Dc1{}=6#c!)wyGRd~m(9 zDZ7&bN!RwB{1qUfOQR}J`CVUpySihQL&Y6c;?U;;2V=U-@b89h7NDamd!)1G=lhwP zrJnb`&hPkS%58nFJ5D0<6;{EXWB+@#8um)*19fKFEca*MZ-r{+HCL7~hPhzdSC<>) z-?=_szD})bFT^ z6MziP*GUiGe+id-W<^n3A+(Qk-;_6}4z^14)uTkbo84S+hhJt;n{~T@>A1qo$Cvkr zV?DIcD_Ot0prMc?ylKR|3mZe189kRqMPdXrQzeeQxmuOS9m+E*PX0}M7qWNWJEByH zRo#z~n7;WYVE}ePJj`{F5_1u((@OABvm%kM6AhlBJs2ZIXGc@(vtvTxQ;kE6+V20Da5GcHGTa)bB7J27Pnb zO#u-Qa~j{(NXCXp#)N1Oz+#1R-!l<0(h0Oy@>Ln}Ymt0PTvJjb3%2?mm^ksOt~9!& zgnvqo7fC3U1AWXP)2nUtczIuR+u;`{KNQUE6;FmROn83#>D(KLI&F|RNnO(zr_!Um zi>;9WiBH4k24>JcIFvG(Xry3R6Mm-V=M4Q!Ghra4`IrpveEv$wPnsRy8IFwppgup- zf?B3|fL->ppwP|&W0aD2%8%?PyuBH+Ixh74;79Fl70l6X&+X2&`WDezYA)DobBbn` z;Oips56ky@FI}%TB%=wM3r6mrWr?1ZRjLrLjc`v!uEo8>a=b~?PK0D%Qx*-bopWiT zIQtJ~UeIsN!}QsvAhkq^8ul~20XB;2qf}&p|)^pem-nPe99R-x_2t8KvG#* z`D|c~L}~<&9Y0a+URqk(R~ku`c_NRW97mS_8{2E4}2U0%5Mv51gI>G^} z_AQ~k#<6OK-vA#!_GSC{I0r}(Zx?06h%$Q1&aDI%9bx4mkj)u|{SD=ARGw;rW9*dO z_ul(=CYiwC)4PH2;*;WVr_^nSRoVya=+3^4_x6~ptyJvaL&1I4PkI8jn2Af0-^yJIi}&L2SfU<7-N!4+&InaepEuPOUtqV75H+ooL=5*VeCB z5?j|A=)APWJi)HWnyC{=SW$qneZC`%so2%#jTq>mGd-bgl@-&~@Hjt7ltzzI?Tk`| z!G{gh)Rd6$&c^WLKAJnRWL57KKVE7Pjp&<0_J{---j7>@eOPR8hOm9hF3Z?I#??W> zXT^C6-%%st<}(m5_RfIVi#t6%{N84QrHzklA>5@VrHjk!FU4gfyNyZ+5o|&)NHaIu zY8% zvV5k^m$a<3^a7z+G!blwCRFb$I|{0h|grA&6aQKw~R>P3>NEG%26~ zl)qy%D+^~MmVMN<{znPm^9D(gRu#k@7NsE*6Ewg77?QSC@);zBT2(~kXF&-q>DxV? zawjwYi?;uZz5n0~*J)+L2S$t~efCO&5Oe;CLjEpLHXhDaQW4^)BNVAV{6pH8m#vnS zf!k?0=s3qq7|?)7X6i7puzc4l2Z&_#zomei!`Dit=k&Fd!JyKe;Nnugv9(0&DkDdL zqFcGRP?M5I3z6X>{+psN)5}PWy!Ne3EykfzbZA9T5r;aisdogj_dlAdfJ2Nal(_k+ zKIvy+?n(s<-Q583oM`tSar&c=j=Mqxx|iM>p`wDt3h41&zg(^+h1eR)w~LZXx@3Ny zdg#(h!RaqPxpjGe51Qa-eI(WI%f9_sHm$qOo@LPWwmE(B5y$zw+qCg3@x#NcKtWCB zZ}$c$j@>Rd;+pWacoe1Eg+6j{3=JxggR!Yt)o-YSf#7v>B=*ChbAtZS;gZ5sP0Ruo z^H_@^IILU%!3CaVs;NJ*J*}HbPZUvFl3Dyr~XMkKje^QsH~Hge~9l@ zA0QBzVWbyAtPO$x_MVALqgK|ydVZ_UyY8B*#qzL+)H1W?3gXjua#{0# zc1^o>;&Io5UWu!kX$T7_1e8FXkg~-f4j#xO7bcyVF#p133^9ZNYh&t>Yn-!js83d- zD4gJF7gDS+P^7}IXJ+=>GyR*vj$y%%?B;&xWmg`vbw&9R5{NM;Y4Ln-o+!k^s9W!@ zcs^QCnhjMz!YcnQW{?6EfQ=Yew}{5Qkrp_kA0%+b^KZMzhjOTMl64p68cpLPgN3V) zgKsfRAA&Ws#2=67=kneq`If*zY|zo2 zeFId$3|9pce*n@+#tC1xdrhM|&Eov~kA}I^88k;qee7DKUm=loy(&h#X%e`5O~N4< z2HofS1Lyx7gIrK=sSnwuMmpxrn1T6l_bx3r035XVXe(TnaAV_QsiMYQNlc~j`AtQa~F*Me^ARozh$w0SAh!MJ6ZM{KtG6YdJUZh!$oRc4Bmp8F%FROUAGuAucff1_{Qt@M4Z)%_wkT_c8osxa8Ef4XP&P9`lCg^ z)vdkFsAuHEDw$@r>sm1_%$4T}jSC$@A5EvEUL=RxuRfx@#a=MUNGwm%^9T=Lh?tlu z|AUbuxr0LyAeekjY$zR^pn~2L)ys|1kJ0uXV=DaI6)iPfAfX$A{PMO~*R6nXqpOk+ z5Gy-`ZcDq3(LM>cC-Y6_O(gmAM^C$`MCoY-wWzMfBhrv_DN69PyIFT$%mdS}lx@xs z4971o_oLlx(RucZ#heA}8Z|*jK0Au=#gMpwWV`UxjwK7vx-UK^ZsJU=ekV_*JZ#vP zO78k@YGxRYGEvAGL2c;?x$_RC1gFbGUl3Mta|5C;AjNiBQD<(ve%&!xUH&mFta~er zUhA0~AAa_CVaJHi2P+zJh-dBK7fh_Ri_qq}{U6_iWN{`vw0R_^W~V#bE1<-@86Xmx=%b5*rhC%%oMW~ zS@&AaX$89a#RXO|8Rbx(2nQA_N39x#319!|tPoq@^bte0ojK-L!*+l=3v~T|E^N;f zg`yMOWXWEacD&PvUy++!?~8#z2UxTivhgTgsDJwzqJ1)J?xXLY4zm-4Qys^88@nJ? zomVgn#?)40b00)TANB;Lm!Qr5^^Y-YSWn-dOX8fcW)i>J zn`S)#CSvow$aNq(tVmKt9nvQcHDl^mATP8GFJ?j5EosFHWD$ixpv)=1a8(y#N-I=<+7IbD5hJ{XBa*o3$S==v3T2W7Wm zt-FL6sbNLCGzz<}dWTY!Kr4W@v>fF8eTS<%71HIxq+3X1kVG(&WgaTSqbt2%>|Q}9 zpf_u`p~=W497fTmB{A-^kkgy?_8T+I={oR!)IGxP?!_4PSeIkonzJ?KL<+sZ?mACN zhvBY_1`Xq`%uuk|Cy@j&>0pti#D3$IcuR8T0f3%>%ZSI`$v1w-??sdm8y!AFX*olX z4uJ&-n0Xz^MZew@M30HBytZmEKpnH3PaFhrJ~2(;>{ojVOA>$N6;IFBPUgvbYJv(~ z++_5D=RxAy18LGNt3sF2W}+3s2}$Okd4=`l)<3a6eMF(jJN}M8E`uWe3GXm!-vU`M zia5T{!YInm`@CD~^Q@114LYy6(eA(@HKvy--xQ_;7#k`oJ+@$mP0vPv@9lXq(%vX4 z-;7E)$Q15IE8)utA#Ekb2!`3uzoA^lss+p*^GWX+{6QfI#pa>9o0Z>x1{4KE zz1FXy&iZiWK2I*@s(3!gHMJLM7Iomx68o!E|2pGI@IPuut;2VtlJ;}FI!C%U9@dei zr6t*b93n6bfcyc72Jxsv{YD3?l)pxOj6Ho=T~$T)q`+{{h9D#)+?*-bNxDdaDfZ?a z4CVi_Z^_Oe+$25pC=Jd5Xt`OEab3Wwr~OW8ph1#uL{BrlqX%ciJb-W}i6g%T1=#h9;d#Sqb5?xO9cxOCUO7gn2hBOkIt%gV zmy|TxeA+;*ntcenI(k+XypDbI9hG&ra2UMfr?1762m&>mQ93ma)oA7=gtmoS(jN$b zUkp09(EKgoWc;iyp670{k;OHJ@HM*7&j6q-#$UYZ`Y~V!(WYXzI)uoy)Rv%TCmDIh zckcqT%c(XRpf4lHiV91cVa&w7SBy5B#x>*ew&fZ}(`frTgB- zRb5eKqq2y=*bo5MQ39OmKl~*UK;>|)4~|aLT)Nn#4_H*ttQ=1vxGb@G%_e;*S`A_t zBk=m7b*i;)SmzQkkZcdoUu5t)>=Lk|NJ(Gm(iL8;F6GhX$ooSADEuK-(7 zdFnZxXurYbV#sT^+gSUO_)E%Pnxce z&Ct?2GEfv8Ys3tCe?4qulgjN&j|D{E=glnnpRi!tt1JO9_SylA{W}@g0sm(a7MGHG zlUAkMKegc8&Nc?}1#G1r7g;jA2-?wPJi^tZCf*kg|CI#@);3~8I`O+ULqC}t1=6bS zZa$$`{7l3_cf8Q#BQNv=^W5S-%ks)FoJ4Hi;Kgonmsfbh)qc23>V;{jHfkfWE&?#U zz|>ekp5{I^1KRbJ2JLW_1;G0d$>BSil2|s7Fd1Nh8#%^gkhZO5dUDG5%JOo_sjCgB z4+~L>rsNkXJ&dmPR;>KRak=qinA5dH@+|j|l3g+$7)*e5K@!+39&@eC;W%A~HS+(a zFHMV;a5-p>d%qGoFpYV9;y1o$4p6F*@y9VRltGdPAxCW|0QUi^8pbY5WK7!e9$W2$ z{*0jh+(GvR@~9W8QysgCM9RMV9%B!x6yRabx75uKf^i>#ZQRV_mUG`MseohuQ&+xd!m6lSNe(Z8gW*u6zZ+k9 zc4mp-esc!XOFzI_x`>BhG=58RSfnMA$xPLn&kryOX3#2r(JiTg22=42`Kxb_Z9rbY z{Tv9(7ZcuLHVA02h88mpg*}Lc04UedQ%An1F4)OhIRJ0Gn^7h1Dyg;q91UX>GjGCG z3u-L8FHHdW6S-V*~0do#BK zWdR&~z{lS{B}^T9C>ttm0AQv(qaeTo^%GoM-&!FDx|8_dCYFSDee@)UFs*Dt(RRd2 zo*lt_)#o#zo_h-SXrRXK=?7p8a{etnmMchV_Q=I^?BRu%%zkXb(UJCzkplP-R<-a; zmaA=A|1b*l<<$X$dk3BuqV#Bai=w1_SG1 z`5*9 zn!mv>`=!XzbTCH61NrgJZxP-%N32<#x<1cteYv{n60|hGtmEB+{V~=VqJ21apz|WF zI7f|WesQSdDI9X}YJi2n5E1~>R!N-4o3y)vXx~)1I9_a$-O}bJJ!LH`V7nXUX@Eik zzv;S`*VKf$)_=6G4%Q-uYu+usN4ek4SREj!xcz-}#`wmi6o=H@iub5hL*CP@)BAA^+T{ zC*)8Qq)6D>E2mBkyuwUR2Hix7=H|Q;!d$r-tZhmS(90?v$%$AYVyzNAErH|;pjA43 zI5?I9Uh-2quA%ZcAE@xZPzgV_ik2I`e-I=)rcjT?gh%4WHEvR$GIYxJ-}qkp^c5<= zVC#~mt)Fz+nRaVOb+?A=1Pkrnx!(}@$@b^HvE~@tF;D=r6?2!KDV6s(F#V%I+_9-h zJBm0HrckWZc_Jb>(7&B3^5ET|!-Y?fA6a1;mhIeM1A)@)zmNCY?bv->QTDm+-y;%U z%`V9p;U+`)stC4_#Ft7r`03ZQ9ewznzCULW=c~!J5sTDyxZumssxU38$`{Tgq4HJV zjS_rGqC981=**>pH=tvL{BLAsX*;1c{adoyR61q_`+dI4UJKV+b9X*})L0YM?qklk zepEZJEH{0D0C&6m2-P;u6X~%-7YR9$J|$C_^^5;|&r78UcWzvkxQ`~V4{vdEj~_jE zP8c2$X1*}#^83hN&8~e-W)Sazt3Pt+^rC^!sp>j^AhqUt=a>s9b{z=7zNZfZaILxD z5%FHljQK$GeMZZ3-MB=jD;SVJ2 zc_sYS7-lH_u@&oC=i_}lGj#!{PvdBS+Kf~H3O|JZ2G6v02vXceduer_orwbCOF&ML zQyCG1jsahPGua4It4vJmXF@nnQdm{Rb7AQ{-)WRpUdEF!Z(t3f#^Ef=2rSh989ddV z%{geDK(5XiiRgxyJ7hMVXSt_~V&Ly?lrI)8%s-L`p!>V@11O^WAI&2#Pl;{%Z6Xye zgH8C(xv?|C?t2Wwo6q_`(7vg1pH1GLdFeKXxn1QRWhIG5q79KZJpwH6agAjsJ#c8? z-<5874fb6I7)zFlMIUQM!Q_c~5=ns44Ei>?)8<(F*JwYX(zM(_HXR2cBzx^{NjD;} zK54e{JZ^7uAUzUoa>O_H?c1ii z$?qMHsu@Q>_OM9l$S1Y6RmUB(=&Huo-Jy>NdXIKHPrPg$_(T-%L+sSz{lG&*sx19g zbiWiJ%ml48xpLvN-8Za{dnxqM)pQ;?PQu8t_S6|8Oelt|0|d*4QFMNY4oL^t#cx)= zaZb6}OeMb~dzM_Q)x$JFjY70mw4z)ql=UJ~!p~3Un|(leShrAlGFL)?n~?7f1gQh8 zrn-Q{F4cc7mmEv=CFQL@;5Kz{P<462wfH)C`tOfTk1tChFkccv&K4#owOUABDH(O* zOk@Iphtc_^?-K(aiiDZ-dt+hiA{q(Gk;BA05JXJ&`5n2DcLduRDWLRs#*>q?V4R<2 z_9~xsRRh8=jNxL#Cu%ET@wMgmsG*Vi@go42jaUKUugh5mz z-0GlJ{F>pT+x}N;%(K3)=AY$3Ja*=beCw|IotnN!Pc65cZ)dtLxVB_LiwEchR42e1 z6q%*26TJ1kDVk5!EQ9f}9a%Ukus%%&=V`E7nZj@LRS{>(om|s)Kt;ydEiTl&yJ(5~ z;}ZR9-2_4v48-REhX8<}K-_Sq<__+gAdUd{y_KN2FlDm>6C=D0;!^LUt``D0O(AK{ zrIdBM2XH-2-5(}@W&|k(!g8C=ob~R!-e@|`dgyJP`Rb_WQuA>k7Z-N9-jHB}J2A-K zGFGa&D!y3#8)_1rbsOV2qnxKBmyZC@1CW{`d{%W*WJwRmw~OKw5tg}x0)MY@BgOz?E4j_7Lo$f{;{cZ{|Nne zRl19&{#bVZ`>N8s*(Lk4L|hoE*#RU&+u~q* zpZdIzCR&87Q5hp(Ho!S_(A>bN-<`wvm}wx+cWL|8`l@eqV`JX;1LcDS6}bg zl@|4~#{4nu9mw_3R7jj-?3TyaC`mKLiV=H~HDz zEC~!2@{XWKt}8XY<0gkv{CfuTCt!(=H@O*Q;iNVpmMg~ZQ1kq=zSTijWfM6M{H_29 z_5M6@u5)?^&7gSJmrTo2EyDFd@i_%$sn;;Vn(iv(Kr8{eti8Rw)6VN4PrToJzSMdI z04;pAzvjaI(&Qbpo_5f|$KBj#vYqlV`T0<-hb|rKKkw=uBUe>CTIlP*u~&LX!RA;ph(DE)t>lS~x9yZvw3=#jH{)HtN!%w;#0j#n*H05jwplP9h>0 za*x?!7v9gXYK^pWIKbW(GxMehv+P`1_G>aR;Xec@%`6O?T%j&QArK3VcAJV{(7%07 zSC%+wlWhH?ezxn8?K_l}K!$zc$Q^eIYoA?7sv?_ z&j>7{QOJ9wh_!HW0f~W%)22#jNASFc-n`uw0|LjFx{_S1#DWU(%S_UR|31q4j&bd^ z#9I8w_o2zxx8xozzoJ(KQxJ{XZbvh9@zh?BgO zoxjq?#>S4>0uH+8k~qcmo+zNC=(+;vkvfb$s*59-E#<-myO@!mM;3TQ(u3m<5KWo7Es1KqOo<>qR={Cf9*WG5Hyyzb&aNB;tXjh0O{nK|vUn_W{izHOsmz>cGH zr4WfY$w{Dir`qSkx#bsQRM5Av;9r~NCCp+uO<+!ojsMuCLCVzyoBqL+ioNKKJcJ$0 zK{U}V&^<0tVB-PW5S_8I{}lE^N*|vc-)jN&c-H&KS&q1^*$c}gR(Acy9vyB;EKdvs zoVoLegFzq*ZLO*q0frd=-}Vkb#xv}5x1IsP=%goU8H-kV6ls1`7@i8tEd1^K2mQ+! zD}?dUw>{(;NKJ5CsVW$UpUQ!YC9?+45B*xC)~)J##1E>A`E>>~nz9`X&e zcZZS!8luQ|(WzIG3H``6Bo)j?B<#boAwmcx;vL!|o&} z=dMHvnB3}u9Tjgty;s&mM{mc*^|@h@+i>d0DP? zvP`(t1I;Z}@ultyAx!v|n0=hcjtsIPOU-P$0(v_5Y83rka*VZdz9w6Q<$d$p^t4c5 z?5F@^1#88@-K7b+AZ>C#bv|0WhV(qw$BNcs62`s5b#EdOc;H+ig9P7ua?iBO#B6L# zpsb@qba2vDZ>p8RXGk)oIhzSJjrixcU-H<*X(Po*F~Ye=_x0xiqL5r9Lbp*q(0pZh zG$Qs<`&DkCqs2jsPS23o#a z8E)LUpSmKq7$+~rO;`=J;}Etcy*RX$EWaqIKAV~@dkO1V-h~1bnMc|xAp6O!H;2jG zglr22{cU7=9VM20p0ex9c~bCzn}TCV|Ag}?Q!DPz!G%j;3ry) z7cyI<^-~w%;S75nI=FIG5~O*2Wb;U=QSM`{hk**BWQaGc10u@@VL0qVAM8@`c+cLW zj-6Ya`O|Xfb8YPDb@_$QX4H3)4)l6WlkccTy)~y-&8k)u+}VZ2Jn1+1iGxjIxQ^~g z4Woa#(_qOa4-^w;B7FIgETvNG0mv?15)70jSX@MxpasE@E3ZB{a$2;TM~m8c7I2`c z^Xh2i6bOXxb)y%2JtzUW=6>*ja@%Q{z$8vG0oACG|9p1llH#4P}O;N3Q)noSU!FL*?H@YkZwa@~=<$f%*CG{YLBiymnB zdC~~zDu8;|1&kiIIj)+-`Iz2w82D25DfQX1 z0X4_RqJhf=zxa|Dag)jmw#t5#ia=2MV(vqx-PrXshO4JwO>?^XSLk|{f;-e4w!~E@ z*xLXE$YyC;R;D$V)vnj8vpRb^UC!#p^C9@zRRCY(*HvtxGx>*?^rFihmr$P zM?m}ku%|2^XfJI@T-pUlhIml_r^8HG|Lg5V(fE#twaLn#PX_lA-nPZwz(ilONY*gB zRBwP`e44BUBpcSQQmz0KnyF6FVGC4>%`9=^dQc^|mm3EsooORaLG}camTz}@jQErB z#t*x0REu{_lqL%^<=g|+-!G65X$;g^3Ek%JatG#|#mpJ8TK`IhazdE*b9FV;bp#DlBP zR;9Iu)a`i~+Fz2GRHl8Al{g#{B$5iF0b@}&94W4_toUkE9%Mxitftk|MohgjE_Wh% zXxXW0Rud0zNR4=mW>5X;pRa?Tun^RWGndcs@m*#Y`%m5@Ra^MArP5-zgMf-5OsN3& zh!7(Nwz5Pcbldcg!1pbg&TlZae;$jb<}xDrIJP7&h~x^-EoY^&bAy9Xo24H5(L3Qt z?xSUEcJw>ik~P6Q*p<~+$SiZ%&Hf}^^?o=R9`%3klYj=Z-sw&23o<)tgs|F4?@Lvx zOzZ)v`A+mkP?sdeM#5wJ6FoY4YLnwZZ{+#t-;>-ec1M$E7=AY}IcGnDA#?SC_+F_X zAd>=U3b048__8<*Ved`pe0@*rj^1dijlC0`o}nDBlJ}}=uC$>~$Wdqi_IldRJD-VY{oBWwlb#*#E9RCL4+hL#874gahQP)F?iLG+ClU*V z-@mvBPgy)8GSAP_C=c_fRFbJu zmA7VTn?xuAlQzzM3>_B&MZ)Y}+p!HHr2#EKts1w-Wz?xw7Uxr9qLPt7@=D(pkS?2S zIpZs6gs{0!{ovD0Z5?4T8GpbT2Q3i!Jh-?h1Q!pD(R?+q((v}2bF5!7-Z6>|GJ0fv z?3?P*O z+4%{38X%Bpz{f!xT#>!D8cq;66gLnNpzd2>NM*VTyIeU9n)hRo=29}>*mcR4a{V#! zJc|x{e68ymZYl|>NpNwdlb*jiD8gtT&-K`eay#1+y~{Ijq%J`KgOb5UO+&UjL5_T9`YD z!-cJ_P=xZ+JzV_!^6)S3S_?>v{X*~ECPnc6f-CS8;fVw_GPj*F(yp)`xgoI1;foF>o%|tZeddOyP~J3As0CfFulNGRoUPA zf9^#ItD>If&J*d71ekrzGBxjc$;c>fY~0h&Q8Xx+{xp1Ijb>MrqI^cY_2wU7>wLqd zWv@M;1Un8-c116x)dN^SyH6-8j3KQ~QJBhMVAG%_eK>aR5~p8@F}e#kss90A;`4%O zf+<36(In+D>y&HCfgYz7(DUf%slPKYD-^KSX}bU@;7@z}F@*n$>%%vlpTLHjC)441 z__*!avyZGT4=w4t1!A>Zvj-uxEu0u>3U6!7BqV_7W+DKSA_A^?XQekiltok0#_;{F z=ojh}b8LunJJSE6C46rWh7;LtF@$~4lF$OOk z;zpa{MAsPeP{1i&1xTEKH<0bwv&awlG-}R*ka~fRasO`E>J z{_(M`I$$E$*3O8_IJJRQOu013eSDw)ExI17jtf8YT#I^%E^RPF^TRY7v+DXy%$R?b zj-|*%fXfo|_nxFqIe=Zx&@aW^_Hl>P8v{?>=*ny1lmlSPue2@4iEqp6Piah=>OwOL z=upo#zov(=M2Y9DB30gA$i7UqS#qn~zU- zi3l?#cDv2=(3Rk66tC$kmwZ#fhQY1_9%=7}{-I2dxGD$m4^p#;Q#`9nU#5d6sTUmw zQsGEC!614eBgjP*OGC4n&HP$spAg22k;uT%NV%;hF_EHd7r}Eq z*DWWND-;tfO^2mIigD*4tbO%2n$1(MfA!z@-4$i&{_9|sA23>m<|ZJvpnpga7kBu( zyL*uLRP_Sb6cH2VdB#9=X9mVXCYn8tf&<k@(8o=L8F=^UttZD#|mHA*WX-Y{tAnQ`9*YME{kDL2h?kR`!E6Ot1q zP8RZQ4!Io44*Is}>uqo6Vw+dWGm5-xOK2IB6>J*eyZghkJNgwp;SLb}?kWJ`y_>Ny zK?t?{n6yr;J3uLrj!K0(>Jjj>){=1^QI`QO&o`r=Y{Uu%VL{E;9hiI12+4ewdyLcr{j*+Yd1Cn zVKX^ewm_wx)QrhIEAPrTC6?ir61MAr5vy?xTDTNj&^UAbfZJZ>=sl-lBH@!s6higk zXx~lG#8A%m$%q)FczpIJtA)P@4WTfw-A2}rRnz%hYn;#accD2|miVMKjy$^i`6Xon zmRAVPqrtrAptzct?)xtgvVUEcyJO{u(YAaURGrepZ1m|*XOU7&EUdge7^Umg%%WZ_ zP{kSFj<6Hs;mcLQJ?YyYB?8+qJb|$)K)d3gpmvp24woY=O9^HRw7geNN&pyube)6_ zcYky}B2!?%Msz>v3j12PG_~bv&>_;}|K^I7hJR9NtVWKQQ3NMY=&nzUg}_B#C_&1P z(7Ep2?%&o|BkjOVPv|VH8_8S$-p9$q{WT5rrtPzemYReCjLO9Mej_|$6;zwcv$)^L zYGt{ivs+;=ed+HvICsU=HSr4+2+*8oD1eg>S7Eg+XG8&Ol7f=*??e7Xk+bu(@@H=% z08PmFazeeb*!z8F@8|i|1DgEzAW)<2cFQJKL>x7)iiBDdiBT#`MznVR9u#i{CS2(` z0klbqicJ(V9@3-?@U35fGpUw6x;Qu_`n^GjUqJM^6ExglBdOp*QObCRESlKJIN(a) z58BtBK~$bkf#-(FjTI~MM@femE3$V;ghqVt`QG{w(5PnGzXyd;vUA~gI6%5qDOq8 zJjwxDqERdXL7`BCk=OhL#Z|l|!q_0#Zh|8C)yQB3)0_S_PSal#bsn zc6k=gGeS%P?XG%^rHg++>}H~xry*?WO7ul&^b@O|Z=-o3>rnu{@OMxxs3uK0LeK5XCOb=!@>Oc( z@Nf03rXTF%L1KPmzq0SAsRS1i%!`3M($_wG#1!;~%HCc8`c2BIC>)y7RXS3W&$W`n z@mEC@g6mETCQXJO*!%q=j~MVHly0EhG$x*~tefQuW7#)iTdjZCN6@sW^KoX7P1nn^ z`LYizASFfnbV|%?l2e?-GI2FnJQ*8h)TjV7&bI@vUE{+}opDPrvNDuC@Vd#p`IT+K9MW4v2C+96+zNng-?nwxG8bcIi}!otN@C+BqYRG$4xf#)os-f|NZOYoW|;#dt1{y=VX=)k zc@%qp$)dfS=*xaLw$0^6G!}6TXKGqn)7`1gZg|j~E4oev>OR3tNeSAgR@XKrDz@=YGXYs<<(A zPfazz%CYF17ditt;AYQzntvbjA0D!7)au9BcpR32h-GRd`S5ypZ|z&LOhO?aUV`7; zLUCv@GQrxJ6OJI7O8dd&NX{#Yj|sOY;^ca@LiQ&JP^xwG!9xP;F|a8h@f!t>4H9xs z|lABDLA zqipr63*r_J`n_fLJKcOzDQ2XX)ZaoPqI#j5tSm$2{f(G&-B~x;!52_llQk~}qn+1Y ze+2swTO@!T4si8}_;oTB(@edl29%yg{3So%j5P3*r35~Bk|?=&60|9{IXl=k}k=c zp&DG9kK6PJ5Hg3bnb`uZNxyqp!tZC^b-qTbq_vY}8BJzhc9b}NCAhVE;r4kVb=X=f zvF5zRJx#>jX3_qu!%x_LMs8U5#Sbfl*B{a(yKb6pI{?vBzK8yPx^YdXT7E>ckVnIN}i)#IW<8o2@E z*b2Srz9aqfby$h1Y#nSfU<}*R*ersAE5(N6NR9$@-1b|r?;v$Pb_{f~jRf-5jQCit zHjnY;42nt;)L_6&qLCZ8t`983xAOz0_bUDtZpa6juK%Q$!0!eNnhRiDM|Ce7xzhW&`O;L|u z3UC|%H{!_458BAwU-eFwCDq469M{Y{tYRraAzM=37H?lQNIpW3bn}Z=s_})2M^1p@`r*LE$~Ws=WdC zsQQM5qsxsh7ClYJJ>rpP6t8OG|K1_6hAvKr3x9 zoBh@%LxuR9shxd&;uojUJ}Scv9;}}#&)lcVP<$5RslUa6Pen6$roA`j9iI9T9f)|4 zkDme2+WcfzMzjt^Rke5j8JMtUPv0z4+s+QQAAVw(i{GTj?aXS(z~Ma*w`uonM{EK- zNQ+bYl!=#DSuCHxtUhSJONjKE@`qgXx;!?q8jVCz%rz9l_USJ+|3s~4_wUcX=>OLb zlhe6Mq|{SN$KeXQFqbsv&VMGtIG0kzCaav8Ohv6cPI_N!Yo$4J@Tu2EErqydtL zl2}*#^)&plF_bLx9U8xA=+|HVkagXQr9qMB$+c+?n5n2C#0?%cu9!IkQ#5-yO`99% z{*{2kgy_bt!H-VIK|*DQ$^@{>p@VlI5y*asxYuS?kC&Fj(5rKzSl)6wD!iY*k}6L> z)Q&S;Uu@>m><6-Ef^CiIwnc;mt9V3=GwnP^)@1j;f=Z~sJLGRr~ z2Kk`*$Q(?OYtws@{`S;LlpB^~v_8sg5I(jcc6_GwluGEkrZMbBvcVQ=qm z-W&fbO5{4wBAyT8`S6aE{qD^(+I3+U?1~TEARK71z2kq8Mpv_;6jDhQjKPk&{vp!^Ibv9i5giHgL>iIg*VvHXq+E~cMHR?{n!ENwg0hF4KKW_q5+Z$0Ym)1=n^kx z#{*e1fXX6sEgFA-1f%u4+0jiLSITLpkP5mcWFCHaDysH0P)A~U&t%WS+}t=YAv8XD@>|_x-DFLjVbwAm zE2d^VIT-<)h(|m?BOYMCor=k8Q>Yb4l9ZGl4sno2uwk?!^^c$y-ckmI87%Uto_QnWO3|WC7?lF}8iUW#A0rH=rAX~kv5&8btWE~Y zYx1B)2OhiyIX1^c$n_tC(f6;yF3%GRwq(-=MM$lBe26|)4v1;%{k4oAEls`5kjay}1 zW?H|#*&d4x>VwgPP&WgOGZr@ACRNN)CoIgH%M;N4GMY3UrMxQmNg!)5fy#N(cOE17 zY-4DGW$nw=~#abH0#0db=B{IEqv!P%~)Q1$wfp`LRW@gkWnc?$tffQ`=(4Wd9^ znOBjQ7cQcR9JiW$(ves2?pRh_lLwcd&{fkuSm#>>bXjnP4Fv4{z!oZG_#7>^QUq&= zLoyzOtdPgRuxSg%sBZB^JVDlY$x@LKDg(DzJvQ29cVHA;*b7iyg;V0CNdr)#wbjQ) z{6yxym!>qdH6}{kfc{oA4M|I|JIm39u3^&8OItx3YDQ5T7{@$%Qo-uT@_eXz;F9;( z(cSacTjV%!GVT=x1+3Up$@|Iv$C!R0S%KQ!LqI*m`dIbe;5c*?RR@o zMtA$e*b&h@jLj5j4?H}~w>i;r#Bh4Fa2Hz*w(OhFOd=^P$e) z-o}+Ypdpf1J#E`t7b~6Qt3aO#i&tz0ur7RjL7f9W4h&sl+NfR}@`9vg^pV9xqk>u0 z9ChMsW{dw!@@Xs%S)Ykn$C&8re~u2L0$%WdCP3n1+|V7N4&+YiPmrfv8+BAoQ|G(B7tFf4 z`gwePHBaVI8aZ31uR8MI8>3;HXJ@*E16Mq31K!L54J5|%@D2xum3Pe!QqD5O^KrZvMteUnf1`V3tVd*AJGT&jZp2*8?#)J!l;g|22#que<(qx5qK;>xl$> zEiH`B&jJ;264K40zpJ*BPXnjV8wSrMIf3Vpdkj_B*%1Nq8>UUIsF8{LIsR3l(T(U` zFqN07y-ZEaMj7>M1JkAj3o;WUD$0G@C_b=4U8Nd@OCG?j-KuxAc9Aan!uMOCI-%3D zg=yaP4-dbtNXkkRr)oTmK1sZ{#}+Y^FDZRuPKS^7`s^A!3vccQM+@W>4#a%*W{?!O zN;|6Cjd;46Sc+TurhZUe8MmvkpQel-Z& z$6Kj-LAJj3UPvQ|1DDo#U~S<0w=)GdO0aaeLE=aZ@*7n})o--kIK_LSP`1eL9K;=H z3m@X*m8zy2*Y)dixUvC~t?KT6`t13}QfNTKM4ucztE}D_maiP?jrD#OarMDgz^(To zzzqdn5M^dMzAgL3R?dqK>P>y^jaP3%9jos+p-)A*gYW(7jbMgLS*`5GZcH!+UKN3a z{g${=^k^hVl%yC=fd?|5)`Pb0T06eildaxu#swVh&_6&@!FCo;dN2ceLFNqaHu%CN z$6SQ{4B}I@TU0Ij($DVyd4z))5k^yA{K5vl&fiqgq#oFb><_V$ZgjD(5Y-vL)pJ;@ z$0iFhgFN{hZQud+nGGx)4TF@KSJb|(BCQ6gXkk-_^409p+Iw~7qc^*|28W@UH zDmF3N&vG8Bt|q35N!iM1i!|P?(c9bA%P&%Qu*dpwK65~!d1B?#XXRH@B~2vAp~RNB zeqSHmqWA2!Wx+xjNGd)EG_$f}nwu=W3taWfH2~J59$jxcpmjhVvrLiG@HLyTP;P8H z_=@@aVY%ba15Om?aLpDQbd!zuS6{LhYc-4_v^FYikD+&(h+H0w+xy1usB^tP2fU|- znuc^?fe-~?AMdS1>c?qGY!s`!>3NpBlCZmTiMH+dlA?hO!BulyDm@rY-N>8X$d<>y^u$LK6yC# z$q5xWa-U;b;r`?G_64h9VK6eyS+D`L8nt}V33dr(VFRBL@q5!QwNYQaK>}S0makO+ zdv2}BSjQApeyGy~P%Oiq`d^P!IP9E4fKPzabbWZ_)<79cYxdoap;I_`wvF{v=d&JE z#nW+9KhdV>=)@XMF$f^r`JN>F%Df8}Nr;f!$J34V0}p7z3820pJ=c>XhYA~<7!u+u z*24%A;|BvWUd&McXss~%OoO(VI@q8evrj$ig2jAGre}*UGft7M8g2HAEya#^dI17Q)(d>@qAl*MI1}N{AVnTV3wo^Fyd*VR6+mW7Lp?O zdG?0cK%oV6|00V&8ifIYPy_2JCrxfWh{@4BXY)qcD+!;#}6pq{{W9&hDkQ zv0{VJ=yfnf{8Y)F3+8^Quc5KAk2JSyFy+2GF)2Eg^jSTN}|s9 zhMWRJoCc^BmLEskuDh_6^ffVC70o|I4GDu2){%cr;rp<(h(vtFh)Ne%d0&xJZJ$Yd zXAg{s-Y#=Z&CLrPBWJGA&m`(pX#eXp&yo?K>}6A@*vx&3kvV!`Wo>??orl_>ZN5*n zU1Gm{Opzd*3zk8IHQT`Uf|gC{MH&P#mMtj56*Kx6#0)4jIYaiDA*!<)H`J}omn_{x|~PuDarulzPJ z?C%jpW3W|oJ6*(Y9D=Ebw9!peQdki+phOa%E}A5=NZq!cZ|5*}VNAr&1=B;@AUMTo z5oTA;3OPFANlRu_tL(yCZM3ThXs(OGl79_)Y*vAhV64Y#tb2|$TPT@^C?pb}@tjO{ zHDtIgtYQ%UIoomIFc;9EbyX5{^CSwVK_%iBJE%DxsjdtfVqEWUUvLKoVEiH;R{ zWw$V*sOY=N&k%yHwUeQFu{P?unfgYPTEvAt3ZcJSeX$RBXfr9Gq*RNMcjDr|q`-!W6qM=b7m!M3rqR77g*ev_q&Y7q{vO*0!FM^5BmmJnIq- zi7y^zUE$fa6v7GGJoT!^8&~0OsUYCjE2G;Cn&YoI%Tj-KXXU5CNC|ipK++^d_o6+} z^Z}_+$XzMaE#?mV(K*4w;WU_ zS_9@ik*nCO?rcu68e3ow3`3CH<1MMxA2gr9$NX&yzX`MP$$VHWAQWi^bvAvG1=8ddz|C`_QrPZ=H#trw+{Op~j+_;)+-`4~TpP`;Lx zFstwk@C2>oeE#VS!p*gqn6{zGJ@3s7nkIl47(Q_zUACEac#Qz&nR_PWd_v zGkl78aAH#Xy)Dc*GrnrSDdFMaV-cQqR4S7OAc)s^%+idtW2rw%)u6B*pXWWdH@g%n z{L*DZZbgKA3wr;~M4yGy}I{jm2WGIyG$ zVhVB?5Cyi_+pkttaSyXFUw1j>Z&BwZ=D&dN+WaXZGxilhy``n=-gqI!|k%B5gzbj_srdDSu+ND|x}N=preB-qCvOga5TL$1z}5EGb7UBmNF<&$GB z7#OsLX>#0w0pG`39kNb9)yAl!X26*Pt?(H2vgil|W92}x){zTe&67AEW9M5AjXAta zW;CV*7aSg=sf6n5ZXRg)rQK9&k9i}bJ(@CT{1*&D2r9fkl4Eb^rwLOhR%US9epmxf zRS%ZatlSH-h954%fiBfCr?nQzPXeN2}~w= z@Hdf+vMmtrvVE5;G4d#tD13X|+gUo<##Z}r!IuY$X(g1AI4w-uskkYv>y$i_ZeD?j zeMy6;mbfO!I?-M%l11}QVRN>!tJHp`#XPF848=oMJ<5_*2`{2!+p~>Joc^xyGFM|; zBPoe;D<@%=X=s}dQ*xFnpI>$NeG8M7)3>|+eX)dp9IS8i6j4^$+bd2-ProE&5y?{$ zJzP$?SDq;L{H3F%wfV5qZ8v+oc-x?>m5_OdxulD{tj4kaH4t7=FK7qb9Ez{2N_h|4 z9OZfM9f(!bdR@Y+DUzeZr-nl_M&uorSgHsg8*}Z}9$YZIl`#tm^C2a+^K~rIi;9gz zH8u$1GDQ!c8=VcfIM~;B#-~7$&y0w(556i!7_L9lipGl!F3{Ot7UN2r<~H4Dh{9YA z5WT69_c^KCkC)wn@PdJJY&(DQmxH;9-McD~m?j?}FHnk)eDjrfUJTG1h(zp_M@PpC z8eVzk(oknK@-B;}b&Lsej&W9UNYiIqx536AJtEC%ufq}C!X)V=aeFhq^mW9lsoBc7 zQtI#b7MO5GkXzH>HYjh1Fv$eabv6#bV6gD9pWjwboR=%VW6J+Ef4iK(unZTrD9U1o z%cv||(11oGy=Nv67+Hl%VkjhLd<5e{HHy7u{xRZ3*GC``Pi*8c{tJbdoo=mcl$kxhts{D z3X{`$97=~uns#=|0zaNcU_^8HroW)x*2HFHkY8P2&z@X|R;)P&cbeAz%MSr&r!b~S z%SvD#he@2+vPjEcb}MvHfCjDs<=Qgm^S|#unf%fv88zP0fF{Hg2>+x~Q2X%e?}Wp# z92rd($AqjTITt1!W3GHwL=N?xb^ZZN+n8|`*RN;Xv^kD$Drf;zCxjfHBtakJ3wQs0 z+@HV#3(M;=*l7`e9(XvW0s0`&;|BZ<&BT;vgC+t$D?P=6gH|=f&udUR(v08iXGFidr4_`{8Yl z@mPZ}>MywbIm59T{g7|V#G{}~VzvW+iIEz7N9XUFbZunrXlGBW7FF|RAT<4*sNEMq z`;g3geyMF^Q&XzEdoPiuoQp{dG5XKn*quF<)>OS})yLbw+qL-)ZnH99jO9QI#bsqlq;yR^q}aWYdK^Bog_ESe;0WcokQeUq)V?1tL#u z4joZnLSZZyS#cL4>-I)mBE7bCh=m_B)}01-E_{3!y~h+6mq7F9GBL~O&};8#6Z(rQ zFWZkb!xkm=9qjcS{Sf060fv@|kmAq_TSr#90A8ESFK#gr1FkVUx^#VC>qm(b(OIho{=@E&$^#D z-7{H8qAA!*oNRZJ4_dYo)b;LbNod6?XG z&$pGNLh(`SoAjOj2S_Ld@HcFS>kxn<_V-%cfj;o7*~9TE8J^K5CAo%j<=g3=L}=ZJ zF2`G-ia1e6z95Q7r!R%D>!p2J^UX7=E6c@&xP}%d^@)Ou9##2M(GiIHWi-;iM;U8Y zl;L$zBAIws`Hzh~){ zH>zYqz6C#bd+)Xqd(gFsB|0%d(rQxs-yJe=Oq@f(ct51$RZkB`00|FL|I|{MQ&_xR zN0D%3$D1UeH6u)qgcDCfmMQ^vTHyQUa+NcTvT)9XX3LqwLye9}v{|3=&gy0^PxkD5 zT1tXCHQoG-4Xkrklv%m0AOyWY(EAGO8h)Rvfg#=!$Dob@JSI|9&R z;5>v$>pjVM|Na|=dyR7JSEr6DO)NcLVxk_6494`P54msXo_&*+4w)n5(2WxR1Z`4E z8l#usc{ZPkdV!vZ*alzy|67_NrgC*N%dQa3(ib6AitS;K&w6%S6S-}4^D&&!ipCj5 z3D^6`LSo0dk|_ykxFTOuZ!oBom#|B`Ppdv_e{F5(t;;g#~qP z4^s3Op_~91Hm2ygWqD5)v+AeI@X(-WXj8v*TR|Z)6ATz=#w-EwgM|t3^e^ z?Cfm2kJcAKa)N?Q4~%Z9AM$Wez5LYqMPokcdJc)1Kh*F(JYs_lc6!&vH8yE;&Q_bzH-Nr}d)$OSocuq_Ax-;4nP>J_@9ynz;!%of#Wl zZJz!?owTOGcWR*}2D6)8tg?$EjjSP}Z3bgB$o1a}qdzOoLE!)vJ&`+hjP@FR9%?54 zR|cfMqakDR1c?E}s}QYES!YG~z>P};Qq{L(z-J1-zGbJ>IyR3ih{0xuZx2#W>eHC%HL5;TMeWSfsrGn-aLN$mUvET!R)tWh*1xVFUQu!GT;r=Zc-B!p z8S$pP$zV*+p-^zW6)9YXa7*?Z#nTNC9&32>@ zzED$OD$gnr4f+RzO#Uv$JHCzQx%$#tSkg%Iu4mb47w>|B%hQGaL+-Be+3_3A16>Lj{iuP~;DSI_7ngBow2Mj9~PXRmPN=cPbQ zRuG-P+J9b_o4RxQG!o@> z+S78)z;vD1;qB)&cCf4TPSKNmA$)84D4{1+a<@>+4g~5RsQ);yDVX(wKf>lAQ2gP) z8tk7g}M@kT2?ByM5n4YQ%aR?}Ca!K}V39%v| z!P$e`hVhYm4OT?Qx``c8`k%ncG`!|kr%xg1^~(t;4P(Wab_!6-|GuIUYG2?qq>oOU zL4qH)^8Wa~s>hW5+GgskOP&l}C8sPHg$W3OeG^0q=YX|;dcK<**d65#l1t+A#pcDu z{aF(M%zCq^k9}4~zUcda!s$aHo_i!c?eC)B=<|Lpg;Z=*3!u3-1AW=j)XcZtW5GuE zAPUrV!h~6&9f3iBU(IB9Pf#F{?$hN#mzX5AWDuRFrWHG4F6pga0(ozZl0I#lpjNd= zCbosz$Xw?~+f?L@>8_jnIG1=GsEpRocv*cubECafy&x#0kPHlToaf@7--1lHEA5tJss7znI-bAHkS@r9irU9&whgCKF|0#y4 zp#2(CQ~(gD{xg$2tqq2lw*;p}}yTpZ6!lMH+crRrG z&o5dz*JRYlojS$hy*F#76Pq=C+WIUx0Bj>vEamA<1MFp=aZGcuz0(P0_fgr8hk|Oa zsD{Y)vV5h*+2o=dTxP_`es)UzNM;05-*!)_9Fc45V+~H8gpS<5ADrj?HQ`;_ z26*+%wluR$MN1E@E(cWaqqRV2s3M_Kg#nzx$YGI|IMID^aA85*b>+(c;yy&Zkf8M3 z>i+rBe7NLa9c8Oy9uD0WDB*ZN~WZ zI5%;`LFaxx4yho)&pt@eE^ zUm=Z~K=x~9HSm_fw6K#a`C~}Mb1YeuSiHmWd0cKVig0wn2?TQ3d;F%`vf>-hcpknu z6r4;lQ9J7tq90gMR(uNT_~Afo?TD0yv_=;6mVI!1!}x6r{KH!VQpM-QIDTlhtI?^OJ<)qG0*X^Rm|+NN11s$EfB*pi>2O`hO;%CPJK2y7#-{R7EqE0-&Al7C z;g^j36TM$a_8STb6g_wH;Oc+Ws!ls7lz)T}2ZU%p#bS)}3L z8}F1t&x;3_ds|c}r{Fi;z-_QK{)C=VZ^J+AWpm`s@4^A(i~WLb$yfGyrC)sr3t0+Y zq;MVSOBNDfkDSar<`i|(ND6!=zI`?m z5ZG7oeQ#L8p{3K+Yj|YPP5wskTIgLVQKoR3Aa|*4NeDeNe2sI8%Ai*Qc9X3>$l>`lZ(gV@>gau*BadMC?J8Pez2) zN1a%D6!+W|Ij0}>Mg5qw!09S^Ih`A&ZXMtmu>HY1E#c%5IW}ofs3Z=(>O(WL`c3CC zRX2U0e5EomrHg7!#1JLZ{p6ZNHLb*9 z2@b0Dl!B@OMoC8fZL5eOh^}hP@t8zU{cA5SzQ|9ygYc!`YY-*Wb|8EvE|SO#5jnDE_%%NB zB*LNXUXT~H@*BSxv97uTsq#DW*4`Kd5Y+6mkm?Oqi|fC_WtS3~XWv zQB!N9^tw?kF(&fgJd^SwH6x^3@lPdTuA)zbI^t6a_R;BRxi>GVyo-Arl=z->-)iT; z41hfKBp#Ow@z1UfXGPR6k?P;a%LS7KG4foPod~u!4l8Y#6$oIDuJB-#Ge2ySp`~vi-+T zo@{h37}z`IwW8pfP?^z>WpHP`tA{N^=0`pQM61GgI+f4<=)5T z9&hOjsKn#;I|fiha|`XHx;OkYr03DJ{YP4E&Nw?imJvvL$5^P(g*IV?iZ!zy^$!?v zZ1{-``*cO(dA$a&v;pAE8V29ru}zLD#Kr_lKIg}{+AL% zZcaBoF`#R*s85|REmYbo#~JfwH|`o1dwg7-DnDf>V#cZLPMv+sIq!l$l2_< zoou`!24%mIr;w>0fR38|6H($RwZNXVd#AB!K*1Bu!l7~ZC#rlpo!9ohAwI*rtF#}I z{#oJ$DV%~d?e$2;JQqeENPd7x=N5SsKVI)2no{5Q({4+kqW}^!CR2&Vyo%pS_b6?O zadAB>CC#9%1=T4_+EDQ4MQxdEe8A$pDQ-|m*!temUalgr0VvZT@=b3WZ{E?l^Q zBHaHw@i8CQr@a(`sim$l*)!VjI7KSzKgPnI`>xOn6L4RR!_+~bw@1p={h={@LL@KV z7@beM=wL4<1@#+_Pac7aDchnx)zln{)ns1Ejf-AMT*h)nyyhA^t(h^PQTNMLe zpfk4bQ*>1hc`@g*P-i>hEz&Cg>%5Cy7=wCknrIzu033ASKP{UJG;xt9!S-bv@6e<= z-^grbBaRy_4uD5oZq8u2wo|P98S=Vm3Sy~Fj0F@EBfGLDi%g*Kp8_cxW;Ps!<)RZUoc`;5{b zUe_$0jk03O!;f)58?CxUH=qB?otFC4)&!}0qje8jPKn&u-40ky$6N#?D*mT^2OtCI zkFgaG#Y3z6+jrV6v>9RtvVYQiJD9pPRdRO&fxgv~y|-jS98(fVD4 zXCwduCRsFsGEL!iRb@fQvyESU$d9q!bCmeqg(UmlK}{@E$FB*x^I7Vi(pL!xrd%y_ z4l0Qhv8>cCV=xp781=f;_m8B7b3HPtzYV(694nIU2`^~m-AmtUkYWt}%}SfhXfL@) zl=H$1kJ|s2W|9mA_a?+uD<7l5jx4%|f~5fAx3xbnat4UGuQg5!Jgjj}6%i>L7#CRm zTMADAGt4W0^dUKbBGlm2@wL`x!$}D;A69`}4`UEyM?Scp~ zq-b$Mk_T_DAqS!KU-l+eQmY)_a#`tCS8ZHVQJ3=$Ll{JCcYNT-IC$nm4CWD1S9uvF zaGC=Q4Et(to~;RkFdn*+MP5ZgoIviad%xLH!PMW{QWg^x+|Y+2{BkvsF3vR7WK zcC3IUs-H$!{sC+Jy`r~TOv}Satv9Q~Qd+Nv<0TRYi?A!diQOL`{yUD%`n5eadkTXjy-}FthABkbh)PaL!%D5fca3T{dDUp_tHCr(=cb9(r+L5JT6Md@I{E$mOGj+ zt-NhP0gze+z(hm?bEr&efu6a|Ehn8@Vqk2quQN|RZEk`%gHECSUc5_!A(o5itM8*H zDTxpG>V`e>1a&_PZ!EGMgIGs?HWm&2Y;3f@EBe}mUU@_p4oV9Pd|&ge%9<)cL7D!% zo^aYlV?x%0c5X@V@Pn>7;Sw{zH6hqKfhasOVEk};g8tf-}XzvDZD2e9% zs^5t?hS#Rz;e$GoE*Vo*6#1`PvPlvas|GQdnZ!B?n9{T7=&FAK_W!%lU%j3dA^pMR z>--Uag-^XiMPy)1eF{M8-79ivx%tA-x4@%PvH)m-r}om}F?^g%CzSDCG(@*PDsU*v zYlcO>hkvA$38x!{VDpgmp{SyxjR9T%CV0_R6Z~Ax745)80d7wFx?4Ka^!}U8{QThG zan3M=1Q?bU9~d+{`sigLX;^SG+}nrZuRVF@H}y#-jyI=8MYqhG&2|pTil@s?Y4By( zqmaI@DPt=#OUuh6$e$HIrmB_a4-o(Ov5n0&hh1`Yz5^pdnH8x>QGa}LE2!Fvtnyn& zdHY8Aq0Y|fCEuAXrOS?ON_~0!9s%{4f>1T&*z(L)9@FNaGA{toO?tnjZZD-U`%Q@t9NeZNLr$?VzVTG%LMc@rXvK+5>#Kh_QE0RS?2{ z`i%I0v;ZD+vYc^}5=9r2|K89L#)XnSh|kk;Cbkba2t>vy?@tlk+0YerSAMBXbjN>I zJ#gh<Hl->LGjCkh2!k4Bt$zk6rn zCSr&4sV@1K&wi(wB3wTH-ZBFi&tUMKX>sP(S5sRSligKF()&yY{%Ft4XQpYlD{hH_ z3Bi}#JNia`Y{Hynr!7F+acN=E!ik3P@?sn+Ha@O2kxRZ2rfp+*ZS4UxcO)_XbDRGgpYGQp|*Yu;7V4N+4`!08PP%G^7)MoBb6 zM(18*hf_w%_@Ch0w|u&K?s{?%;1FT2rtwgY0ut_Bzi>8*#)^MP>q!D?ZzWT@_@%NB4bE4o+s zb8(Hwn7MC_fhr^Deb#V*G?2%-j>Ut=Mj<*LCS}y1B^k+=WiJLE3pMV^C8>XRZJO(e zNZLvoS5P4L{uF5R9TV^#r8^2X=>B88+x+z_*-Be;`{J{&7BP;#CHlm`iHhveiVr|E#t}7?hpryLEGsd_l*w;rsb5s1OcWt(MvB}G6OEo>yXiiGYI&;aDEEa6Uppv^2%j43aC4Ipt* z%Yr=l8fV-{-87y*kZo`)b2{b>QI(0$Q(i+HQNv%%>bS=PZ0Uc9DqTk%HL=D_7VsR& zbkKL^#-5A8_}t!vtWM`Qn@s%il6T`ieh7f?0tU0s&!wZ(cfz+7-r1fN#k4>bIB<`y zOJ=!9_6sF7{aYD~zeKM{Z!5&L;P5vORyDo4CZEV~&}EsYd{~1>YR)MDiWu z3hXz*syVdHt)qb{_Y6Q?Y@o!4@Pbq#V`FdikdtS@`$LUMxqcBZ*D}NcKEuXTJg=v= z3@K#68p6I7foa~K)cd$r12ItvjvUcj)9wB*HWfThEQSRUZ0db3C6i~uNMZ8tV0mO^ zOjXMNuAKz|tfM#_7=vg+W@QeE1f*-si{Dw`m+ko~V^w|EWfWNKh(c~j^UWkc7seC# zS;;23Z2PtT2c0OGU_1(^zu+2dMT~)szguAZ;^JVj1GX~%$12Yc&;NI!yD@%4YgCIb zIDuVlciJvU@_G!oBWIrW>TQ=sEeitaX_7umW{Ep*Bl94mT3l5bS<j?b($Z=Nm3J-=#s^UvA{5&nK z^R|^n_rbJ+Fy>_dKuP_He~)_d96bRLI{<#Ye;EWocQ0PN0CDKWD-5i?d1}vqA784~ z6)O*t7+CN20ud5lU;10Hw~ODSjj3iwfZ;N%;(V-yLZ*LFc)xGc6v${Mf)#Ek6H7Oa z^MeC`Nl-u;+HpHnM7qQAr~u*HJhRq4I|@9jgk8@tVR^ZM#-w!(C@GSA)h4Yx>%NI+ zH0;aXcY%AA?8E*Szb1sqcb?cNA!Igalem~uBz4q+(zTMS_-yZClD)rUTolg~NWX&< z*g^U<08#|VtD8ek{268aT;xJpZz726B0hLYf`Z(MZywqZY1JpSxgr$u5M;7R9CMR4=HE*8~<2>DC9)X`fOYbRt8X+Ll z489nDAzV&pAzxbAj%@ISMXmli&GP@!14hN!QV`R@6oTdX{!d?T9TsKRwT*+Iq?D4< zAl(fTBGM%SA_zl?I3g)sL#K2}j)Z^(3W{`xv@j?l2r`t2#0&@wjB|Y(y`SHEKkx55 zKL1b-JdP{&wf9=W@RATP{2OT+&p7Kkt z@2p_@13GQL`?$7478c$ec$gr4Pn~Jd*N%C50Y=kpj|gGy?Emn~s^gtZ_JUkcv~Yyt zrwr1}I`1nEXFBK$hIBV=0)B5+N@`uG4fj;4orcPQMn0H!dclLz5m#UObTCh>yEx%R zK+pC??_$!DgTpO7yv1WBrEMok<&>U4mAU%@{l#n6{MncnA^9sv{;G=wM|e>_7ZDOT zzDg5oFToEj%4R}8*9I0B8r*>|x%W27KZGkjKAz~weaQeKAY1_KPjME_{eigLSFtes ztmzrLecd%WXdvg>)YOUSZ+e$wR=y^ezO2G?wY7t;ufJ!{K~4B6|-rhY*~s zrVlJK`w^K-=^!JhM~HXH#tRPT?_DxvG7v^h6vPFe&_5qG3x4LWtemz1@|W!cX%PL` z)qcaRA<5Rc&SV6a#Q&nAa^@Ifpwx|jf61M{ZR&+FIhL{eRcBy{;*BoGkQn`gz}Bp9 zVmk<+3RQTQ$`8p#i2)19uSY=jAWst{k#5xMFekC4e=+oRZFA2pNm6u3K)oIi(wA&p zHk8sdIyIjx+$?XO|K32+BZY+#7V}x_!kSC!k0*@ln3PQKTs&Vi*C4dVGYlM&u-zau zv#LP>8Z%E+moH3zxGVQfN#aG#=@hlzhD?;)*%-RU60)=Sn?{G{2;}S(C2ftidUq46 zF3`S9+Klg2TOn)6t( zg1)j{>%K_}|uyIVbri}Xe)Ql6L@LyLc6m6JJd$*r%vsu2Z4co2e_h>IuIlZgCm=2ux7 z8RE}<2O5Q5z#O}*RQ*&D1}=FJ`W~Wxa1p|#_jl+DgU0Os4ev@tNS_S_B=JQ!PJIhL zzq!Bjy0k|icky9gj;Hm|A&_0!8>lkW$1&WO>ZQ6nN6Kunw(}~f15OgT#&dYJ=Twbb z^!0b?Bvhg1$Y+(8$`huuh7c1$?@(4f4o}X1hU!c%W~;t-YcRxuSs=3v7?mKjsv%w z$giR-Rp;eqb{~&$hEAA8Dm~LnS z_gRqI<#^5dK8q6Gk38EuWKmI9X<7M#vOsaH!XStLpgLq}$V_39?XBDx$S(p0{FbUa z{yE_e9EkD!zIG~`l|KIdOAL%H0lkqyCBk`2asC~UL5co7ve3Lw1SD!Ha|odFvZxQ8 z{Kel_sF*6Y3xWUV%O&Bgve-jumy~$h1R07(Ym4Yi$_Cx$v?NqXiwI|i+o%SyaSkH< zZ8L%DBhwbM=Gxa$_UyZ_5x5Vy+g|XAaO)Zvv<zMQzTNjqe-YaoNxgfb;^H6D_HchM93^||HJvNtUEU(+hebL3oLeA? zI>rVTKNgtz!0U$rwq(1zf8wKiA3>}{#OQw-lvn&Gl94kX0oP9A;5uo8faY z`KIgTm_RkrRI^W~2rq=D1KRz>rHvD@W|da=CWf-D;#39R))}X`gn*`+*0<%%_`?Sg z?I07qG;E<$WY16VGmc+UlDZ^dzN&uyf6{B5KYpqB+DYQ;8hMRn-U+5OD-T_(fO8Pt z_N8ka(Jv2A^pWr|WHaPGw$)WfZQDAy-u^&ra+V=8d zeRtgM_D19&EOpY9Hb_*IK6l{N_Ku=r2;HsZ1oDi(-$#Sr&%io2oahhL_egl zt+_Tkph7#z;iDr@M5#KyJN;fDjX?==RLjK+4(5a!gH>>S#0c4%WI%H1D=XT~t*xY} zpJ1XoF%=b+5`v(5q&u7O@P+-&&Yn#gp(l-?1-SebibSQ)69`UVMIWXN#M7U%ym^>o zyIQ$p&DJE~VJkbdC!oIFj0wzCbJ7knMez%Js1uiF&|jzsb#p2|o3^aM1?(w;zWy6D z1KLT>sU+^$Ti@*Y^oa>{67v2NIK+Wz=|Yds13wGuM1bC+65idg$#V)tFw`?T^;);M zL+0Ya7u}rEL%dZaTU`yD(f^ZHlKxF6$}?Qja*Zc z8y}Xakn@j|Vl0w2Az8tUr{543@(VM+&Pgc0BhpapOlT5g5=3m{E>K{8moP#433*?6 z$Mp$<@yqsD4L5@OKwKrfTJZ-67QWU{NJ$1%j)eS=x+1IUQ(s(25$uapV$gZ6#27|< z9r|5Uy!IV6(E=SbP&ba%w>dEMudl1G;|G?q@WWVpQt*x)%Sg5Ir)(xBDxc>*U#)fR zlA?|vVk6dkd++hY-OgQA;6b|lz9zJ2*;Fp^eH2TG@#*ScUr9Wjbnp=SM6Fq{cPsvZ zh*=Z-ne3@7xT>rLCr!H$evLv9H!hfb;;*ZXK-lvJX-ml10fU83Bmfkc#k3ENNevcD;{$w-%oW$ChInhZF?Qsxr`SsR;Fo=(dfsILybS1-q2M9-)A zOUz)o=gZk+-@HKz5QeXU>OFJ@p7~^rxbn2HX%1iB0?ym=qeExKwG)@x(9i&+*Q(>u zWy|Y>F$tm>hY){V(wU!GhSpFPPS^xcx2W5(r*)H`vq}hy3HptBMiYjwGHSQ}Fg}ipTWA3A-g{43ge} z4+aYv2qOd$r8Y89Kp~H(V%L=0!7%DjB{whHX$UZuP8_}QXhx~*oqu7Ta$Uog@|z+?&i;#bbh=+*E#h}Xy$fmoh}&Q!jZ z0W3QyR|X;%y4x<$b%;0Z!1HB5C}FVr-%mbp$9;m@f0wOO3Xw)U6S?-MaQ)5fW}=a( zK3#~kcNDs&HKC7KaPJ zfgeDri}!|n2r_a-<>~?xpdqeD$O6ABIs>}y0w)4;8d{d*7M7U5PTpZw0eIZ0jknzy z_8_4FvU~)ED6{Iu=-ID|2gF3R9Vp70uQ>neYS?kOc|ytwI&*9#7%opA`qA62_tm0i zHv%`0r%at00}zMKtw*|&{FTVP$%QayPd$%t@o=?pTEcKecU}DS0vvbia~!rEJa9hl zn*pdWfF%eW$kCv`-G~qx;E8)DVPumTKLwSiAK>ali$6ys

axbj=X)br4)1>yLtw(K) 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�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

=KHq&`)verw^%OPq+Mn zXM%$^WMb2FWDQNggbM6T2i|}GN=7KDC@+iDtBhtn7o_Etgr&?2#tFSV&(=T_`Qd_* zw;v@3U2p`Ygr3p0-9j+fw&x|X!DBk)c@|i}#6&rO{VAx~D(Z(0l6w+~rl2Hem);k! zszDM~tQZL!+~270DwCU)N>+Uyud3-zP5KuG2p=Q|Il^5kGsOWc0rPGhMR_9nB-13h z2I}~fndnoBk9wcPjkKU3+uiT5+;-O8U|#0f&waAYp)_Bwnm?#&!!0BO-Fz_A@FAm) z*Lmt`Y|NSnu1mlV{5njZd3^qMvm{R6pLqD;VaVw{6vtZqAzM3vWF-dUXZ`YhbES zjVig42Lu0sf!?6vXuT$wtbR}T-cn0 zj*?LdrCiw%8$C)l5NMsdAR9hv7t4MA>%I@9A&$P`!S=RV_by7mf*1ZYaq)WaxI6kA zHKPNHNmkRApOvV;G1f@{yV?N$lsr_ltAoxLpi7i~KL{;1n;RQ*9!t?}g`ekK8Q4&- zJ^tr1W`^vOoTXVJhY%_gzavN%xchx0#`44ZYkA|HyY2+@Kz{zn9xDFeJqb8n1_WwM zmD99!y9jQX>teT=R9hkMc@hmIK7W4PID8P2gW0)LV2#}kQ|0<=%)nAI+lg<9;oj2< zT)R;*7|Ii&;zb`YrS}P=6Vc4jhsn^3(JyX1?}K1|ZU~|IA73}z;eAlIOUB-;nnFbV zZaag?nIVz(uRtE05JOogaFeP)zp^d#C#IefBPSy-$n@Z$efmx9^J=UA=AyT;BFftV zQ3B0xE7@Y8l_(iZ2m!C*2@Bu6_VmPn-~un9Yy!eoBY%!NVb(ccK;R+DJDljFLhsV8USmNxy32hVtVymS0~QoxrKva2H3dV6d;S*J&?`>q4oM=5ii9?qq4v_f3p`~vfGNNBZ1;_ zPgFjEqr(6CTIm7>M6T+4<8isWRw=b6==zt{&?j&h00` zS(>BXVLu6~%l(QWk$VB?F$t>2tmuuIPtPg$Aba^6o)9Q4(i@cgXa4=?<;$M?0h7zi z412n|Q;F{HRsKyBdRjPlp%?@l1k%(KDj0lU z_y>pWW`=ThDtgoiO?tRwYwosdrr^Mb4DIdWPwmZxFo0jB9j)$W6T_|-$rx^_u&m?z zkrsYaApxUE2tU)_U5*2mOzkPWoU0(I# zwdniP)N;F&inkWMUDa?#Q=u%;Nq|0sBNlI&|hX4H6{if z5Oofn^4`gJF`s(!Qi@A<+&mOT5_sPdK!eH5)s-`6qedHLUSpo(huPfFkPBZNHZ|WD z9bi#wRzXcQk~H!aoV1b1SuVVxgla__Gc01G+iVHmvHQf{;|aUbb8KV(w+rc)42s8+ zbc1ymxeUWeFx&`zlr(Xcj0rmmd}9F@O2~)T1M-v6ivxX!tu zXFXN0YO?#Bc(<$vahja!S?liR9kAV}xsxDZV~?s;EfgDsTMSd5d68q}T%Y~0901xf zcpeiAMckRxYSp4HFR9|luAB2#}psMlv=-n-d$vsF`{xp)Ss!_w~>(&j81oTPZ-M+ zrPdZOMvco`V*xL>*Kpf)?WaM`?Eyrm4^t93A9ljg0MlHV^BLl7OIFPl(@KRO`N+<~ zw)_H_u^N&YgGi2~uDlX$*Y$!g4cWLt)6dTkBqo0IcNzGGE0~vp z^Y7lWCdje|YTbm7!-G3Yf?J8845{qcq$AF({i|6!NP-)D6Wi}W>U_2{prjK_C_rz% z^)b|X>$^5SO*wL!w`tFDpBHHpqf0YvR4Dth)967ucqR!uV54ywQKo=B#BQn=)N}r2 zvEW2n9i#Kh7pI3=lq;Dg%NpRt=CqWOkS-Zv%O?xojn#;gRY_eo(CqamB4xTdv{&~9 ziRHo8r~|)gb6Hn{d9y(8*PS{Nlyged%qtBNlHhq>==_G-oBNO`Il4G!T?@8mtksBq&p`;|IVXQbI=r7bsAgk zZC_%SB&q$T!R#s>v8OH;&@#73E2%&KW3@sM&dUeQDV+IdX$g5;iv>mBEPkYGL@Ct= z<2#aZJoO;8hH5E%i7bGo*8pZ&a&Qx_?6sm)kXLo%rZ3Il55yYo;rto$>hMaBhE}xA zHMQybssOTnIq(%%#ZPcQi~IrT3EU% zH9l89Zbs~=}}!R2@jqEdunw0q?Li6`{9#)v=B0dXha#0P;e*nat78WnT0m+ZEI{s z_ACJ+!*X?w@$0TB++($n_^n5=?R&!?js|0=6xlh|e49evL=5VYHkys-xCmaI@ zL^`OZ7_3ToB%jT!Xqq+?+ja^C`X)0dLJbqo{SDW;z^Tb$b7DE{&ttMYs^BKm`zf~f zd)-UWfezX@g`y*Jp4RZ65W7`yf+;cS?{Gj!78~04_PHv_{O`elL4XEzWQiJwYwVtt z!D%}h$Rqng*HheZ9A~ZsE|S}$=+JlTsD^_G`&4OS609wsO`cm4dO^Z)Xie0?A1w#T zwNy;BdTFt@#EZoStZ;DRvJbP|$X{W%DUjaxvf$-)_X{#XatxH2ODgnmazZ!5R)7J4 zlN6GHykmQs1$gpDKeM1>MBv+SF$5L$sS?6Sei6F-E$K{>_?23e*tRi3=@{_x#Xun4{B2``eY_j0j0ahkgy^)#s>oOZo@Plq^8)9E z+(LiTWPRJQSt299wZ85iI^m6%F#fJ9(lKv!#FsxsvOmojX+3@zu;SUlhjTO}IKtW_ z`2myX^+yBl^ z?3}+2m}HTHqRT4VdhLDq!&5=Apj@!1sTlct(hR=1R78$~Pm zUeaniqZ^i*BOo-$aqb*b8_7lD7Kn^mnqq{NcO1-__7g{-omnfrc*I)XMC0jaehQY* zv_dM@k1kTwM#M(Ud*_OqrA#1CpCfWCN-lLK>f&k(N$8<(=V9u!bJLfF`?E(=4z0Uw zfqHhxaU_UECgVXBK7^1%PBs-RMV>d*U#zm7XhlBqVOj(%t0nzd?hQCzsTwdQOqa?c ztW$@SiphKWlnCNR&(3>ocv0y1t>a}mwgfvyYwFJ^gKK2UYV*SU-X2#C4bz&l*r|Sw zGZ`icsWSL_NLFH)UTuFMjC*hotn*dnsQ5o=j`(VSf1rqv5bQj6KQ#IC!vjH9xQqI5T|rhnO<;f?6e`CEK&hgg-k;p0{YjI{vt+> zoY;4XK!3gO5k5l;SX%vy1R^+!)<#}s7PF#7q%`4nBIg9l(?9CXB@I)H-+iHEBeP zYtbyG3*q$HZh_l;F<~~lOKnhTsiV#>4@gZ}N)REtWh0>v;gv+dWsY0lZC~noL^?{( zaj85ykgb!?R1$`^c+czvJ8MG_4FUn~2k#$+>!o&ci5k9i=)6GGj)9^|^QfO^vJUU%~fZLZwUBj8L3F)OiU?6iNKF z*~xwzY(|3P&|W*1@Goby1lHy=2qWB&j*B(15#-)(5l z-O{~w6l=A)JkkCAJJ(Zjqfj8%l8miCHi>Y2?9Vea-XWygG@P9bIq(ZQx-k-2gr|;wD}&P)Yunp zbDX6iY$1E_LCb>VvCF>_jAozD)nO*!-iO&^v(eL{*m(b^x*bx-7GE=X$uM>6_(NWM z<~D`u%;`oqLQP{)JX1~O#r#QRS$XA2!~3(*&vpC=!@)?oM-M-1UrkMM+kw)KD2SeA z#nZllUUR=517{5BIeoBi35nCVNUb8w1#xUF;pAVMN+(chs`kNo0%oSFC_@#exhCuq z!3H;Z;P3xp2cO}cw1KVBlk3)tmQ^e1^Pq7Ls`5n8ysOwZ`cmDx#fyC47Vl;IQ(efp z0EGagCN>eky~PMX+rS1Y`;(_d$-v&^@$b%}eEZ%vVG(^WFh_6rWPKqj8!)h621|by zl$`{gNPZrS|F-Z=#G!Q8&jQ{j@sj{W%(r-mP{#Gj3;VWl$p7}~_ZuVC>3 zm!p}AIFBlX0lV|%PI6)%a7)0y@P-K}G_*Rr)?ZCu_&rr=lFk5mqz&w&@)d1$4_za)m$#f$ zYNegSM<7{7plLqBe3~`E&T+lJfm`Y`&{Ij z>tV*}Rg019>vmpB3?&nVoLPzHhy{(G-&Ipic(;lu;i^pD2V|%?6*Fitk7G=bQ1gjC zUA(5d!;_zi(Pwr7=0R{KR4{4&$Y|J@0|9J@W05Ai%L`Dl##d^XrNqh3qIoUAH5vFz zeBEoyqdvYuky61n;S6G)Yc;*{P#?sD0s;4AENW}ri(v!}o~x}C#%a)>Br^q-sGu5jF> zewaNSYS3Wtj*D>6aT?TMi|mT2VqFU#HS$GF&-21XBYT9RH(kC9C!Im;_sZ|Wwu;F? z0`ZyuQ}p-Sct;qdAV6NRAOH0!YStv9c3Q$}!1Dq>{{chfeLs1Gj~?ne`J4|QwgDnJ zPDbYc0L75Z$}fY4b=pKiFgbM1)*H-%Z;%|!PmN64h2G+@vt`vAom|h)0rU#LP4a8O zkE<$8!0KO~Of^w8lVZxWmbTv{Ja!X9=q!ksnaS(emq4&iVd*wGMwT-~^r_Qtz&zYE zY_gBL*lHdod+I+~$|A4RhzWp6TWk`utJ-OBkh%SGtDtmBREuPNe- z&9lysVn63!pB;bx!Z~{bz61Rlz$>OE5t>n6Mt}5Z+2uG}ps)pUHxYsKqbRxMByFPQ zJ2v1Kbq<}7i~61*vT_F36)G>rpsyRRA;6SU)t4~% zFEnzzRBVgoC(nVCo9WE)w6SrvC;gD23lzd2TiGUNjzYy`F`X{qBz}W#A5!-WZvxupKU~m!r;>&1>T~Uq$sXCK}mvOOGC`Xs-+S(V%hjpPAxD@bd#va>>aW z^~?&IirWxx_0J!r4rQj&=bA6C#)INs%4tVJ?@A$C`vDLTw1kaFpjK($ZjEku_?)!L z)I?ftwvrRXxi_?DhRs;tKe!<@yhag2eL*NKVhQ&7ZIZGxDg|16SaA`${mr%PdCT*% z`S?kkJGdr&?OQ~CC%I0JAIlkFi|%e|vbvWG86A7hTaIO#UX9Yn7+|(zrX6locVTa{ zV#kCYs0D~(j-G@8S$hj6n(+2N-kFb1{#5~@iVFg{rJVoLu!5IX6cC+_n)a!rmK)C- zCXeUtEEDFSKzu)I*OK^k(=3+3@Wu6tq*k@Wv9}+7^}*|{O6qTiB)QTZu^#22a}O^% zVAbx2p`Z)=mKH-;g=RvNWQ#ss8CkPxQ;Rs2a`|k{o!d^AT?eiWUxoy0t%)yQLoU^K12JIw=;6#^ zwfhH3_j@oy=X7T@Cmg0(?V3r}oCjOjVIOC|lMnCW8$y8|{`UJd?4B@Pc8A<=|3qmi zVzfwYI3E|L2;H1(tdpqGgQCG{mVwhT@?BVY@pYiV(Emnscyu>tDNyhn3t)OiBqSf-9D>)k?pni}0 zMcf*C1iK%*3B;wXf+&ed=<-$@JpU;|;89NF9<TIq z05QsX8a8KGzXT>%P*pYlDwJu7gP@4J27rriW0KNu-+AtbgYQH%62354p!w}R)W*zQ zXEf|w#$f{@>3~P@h>5w`+U$Q2iKxVJ8U6dpjvBrNC1?SUmH%{*ecAbbtZZ;s#LKHsHNMop4Q*S&V~P$t1ZUMz+a*zTV#p(`$W?2jK1rMStx&yM-3In zfiyszJfqO6?9=!D>n>d}O~qjFn@$MQ?mdJdD-#cK_xgL#DV>7Yn|9Z+q zQ+lN6rJh}QdHgpdcp+Vt!PQz7;@HnVNo_ytmm|rK6|(=bu5jJ-g%PvE$RN@$^a(+y z4-=-C>dtsz;~TZX$ITC2PpgzxLWVsHdJPa6BM@wk$wtrL>I7YG(bQNbKqm0fM%utx zP*zaJqznlG*}%zf_n%tONpi9y1>uZUb5QsCGa0Bwt22b+{U!C_@Zdu8(X@{biyMJ( zPVZySv-aE^ErBe`B!Q*HDGXXB=~lRB%@%!@-*z8O!Rffk z)RbD1Dx9X&F+yaP{3 z_pd?t*-bS2`l)*Mw8D+6W1eD$WV6Uakry-_lQI~4iJnx43TiF&R zy<{KaD1I+=2Z73zO^M*?V;MmT0mi=g)bP*G&Rb!27v0_Hee^gcu#w`m$EA)XS-2m| zi&g`{s;FV&SDyi&dQm)+6nH}E2S{Rf`DYwRK13h2Nxu zD!>!F`k(jFEc-7{uT6_f@S^hl!ppHlsq1pr^-&eIfIo_W$4$0vCW1^U%vjSub3%q2 zage2R8UhXL+rOb?(Me^_J>9%F|?n56(F2 zR;#nMJSV10( zS(tkNP#VkeMW5Fl2vqAcq4Y2y8r3WMIxkqjwW$F#R4EDQ##khTcFAxiUYP)sEeUW+ ztzjK~!ig^sU4|oe&uVA|*D}21tQmaN2kFb@snkMzf5oB)==#d#zCbTOu&DDg|G*(l>}nDrN^ z8T$pbrU^>SxL;ktY4Za|h0PD+_x^~W3*hVtzKpi)@R&>mi>3Ct)d z&b4RqlK@|^oZ6(y*&@(Ucp?0=ve|9r!Ejq`$e*xrRR=XSR_XnU)O6YKp9x_NZ{j)C zp5z&fYuYF)oM(4gT5=F2Jbe(EzowS>5}`d8J%As>K!*_&EiD)$Tp<28Z2tjL*sy#K zeY))Pk}|=d_zeqMe!9!nkc{XA9DdG45|$?{@yB6Xa%k^2N{%5lip4-ff_|T{b1zLs z$bO$oz;YRs1d`Cks91iaX?AE!sPabwhEiu}-Y$J?)1!l`12^JL2I7zZ$v*WVOp1O@ z;jDE*ol1$ASEG~d5AP5OA;NO^|3}kRM%B?Y&5PT`-QC^YAq00PxQF0wi@QT`2@ss% zPH=~yL4#X>;O_9<=RM~;Xa4W*?96m`RdvlStzkcD%(?H$Yzr0RXV(tq&1XE9PXJe@ zEdNR7s!Hx|hz5*IEOM5B3JN^;<-Z$DEn?ibG&yz6afNm7+nj+OQudvo1nh%|%dor$ z(=1~FG$X74FA{vWYwb2=1UWQi)5#8Xy_WE!n9Rc}IsU=#>5v^b%t7iv)!;KQSsFx&!x!d|a&j_(Aun8^G^w9g;YEC5SFY?4Ecpp;QD9hV+Fo;V3C6@McCp zt^l$kMxxa0heF%k=S?FR zkT+rP9lDGxe_Mg^S3s}%H~2Qu!d0hj8F99kA$|mv1B;ubNV=`@p!3s2d#vT-RGE9X z2*Bt*F)yuKoQB52ZElc%9_fiaeeFCHzqGyGd4@_GOhXoM!!SHD!6`hj;S?x5jC19U zos>#QGmR7<9z$eyk95>5W)j^_$&1GUyM!5GwFx6KhCwlm2OKbDf`6m%(j?Q-empXD z_UAc7=!3xB1hZ=8tQ1+{cz3C@O-gu>rk3hi_mmqZ)4BlnR9<#WM*rcCW%Cv`}vVX7eURuW0J zOB+=id&F)ChzmA(S^gkU6D4M0^9}+zZ==ff1-Ll8KCEO`DVf!9g?m`o44+k~(2Tfo z%il)K^FM_z3Es*}{Z5hMsnK z%nXNsErUyroLcd)1Fs)wQyeZ!Hk%0*jF@;G*NyjsHA`6ug;xyODmPpu$qgGZbU8J| zGd!s8&j%YprNRj49!cNC-zJ;}pYGp8gER=_D=Gymar%8aM#X0M;p0Vq&$dOGg1or6 z9J?OOPyBwa&m4!JJ6!Ha(p!J)sNO%1kd%%9444eQ$)I?uCcx*BIYcmHM~c1NO_&kj z0JrhyyWc6#HU=c8u7lv|uKseG^gk@B0K{8{C2+1!LF z9N&UkL(K|4aATP_DCad4AS@>x3t_^d{;Xs6_dy=QPi|hDEvKiv516qiMXsJ-G<9(S z8ccL9pg6CJ%z32l-LOzb!TR4xX=oj%QOw$KDvXuQAGr>hQNL+q4Clb5Ihf6uX`$1j z&oU%T|3ogbD{rpU<86Z{e{;j+xg9dWkXITQpc;Hwec!KK4%oc?Ji_ULwcO^yiY!Y! zB_hYWb+h@<^-!aM@|(BqO+-M1W3smyAb(eRSe;;3+j2+uJ_Uv^896sVfeYI|7{SapQSl=4uU8~%#vSjdy?LzL{}GUFh!fbDs2 z^&0x)JAI2^RI4S?Hz(a0VhOwSbDvJ+B2Tp5{m@a5iCYi+y@M*W@H-?VBCOc~eYY>Y zyhp`z&KxFROg#&ui@DKiHe6ts8r?Wpxfl5>gbquCbs7mb6iDqkh)`MH!R|)aQ7ZnTjl?aA%=(J!Z5Dy2=h@7?kAhhzOAhz=OWfJ&_81P|lScLEW6ArRf zngZNIZm~v1pP6Fg{Q|BZG zcud7_y@?v6GT!e#&z9D<0^PFn{I<;`1Q7}JWQkJWCncfKm1rjzQ*DU?8q-Lz+zkCc|eMwY7#mXLobjn;k=61fgY zK7AO!5B7R@kV!pA6bnAG_s4Z=pHx%6GD3g9oj!|tHP-3a?G#hd0sdM<$l?|cy{)l* zlwz=_-epi4YW+BEfqmGVn{P)@s6av3$LrLNG(=J8IK9*p?d2#eJ`8pjHAn2Q3+g0W z>s70y6R8+e;s1G0T5)f}_Mwho?P9~rbW)joVEg@A|G}8qoxdX=ePq9UAxA(^x38}l zrEO>Um17MzE;uH`=ZpB?8{uud`ZO=B)psTb)5X! z{``k8Ha7Nk{rJ$WJq!$W0K=`^{veZENEPofD?#+d`pQ3^sZG9k`ZF<<*7vcJLXW#Y z1+R|Y34de_Swnx=F{C?aF%`X@|%-I_;z6uS7VvHzXD<)^P+S%98NUD4NusF zPDT-%Ykl3VZu+7a`TTRU1vQL0MS=un@`UCizT=Cy!7ih&fa@HzA?+4pe-~@m-GFKF z$ec(axpmrPZ6Z+=h}>Q9?LpCR;C$)qCz+U)8AUwi{2lWO-#8o$OO)!zN<3FA#b!w< z1=!eIrd$PU3czK+Z!`Gz{AKdunHqSLY|BR(xmN$u=KVCI_0I!^8?p4P2TY3$=DJyHTP+&NN=ib(F+p=7hVUWsp`kMEgvA_c6RZD$ zDy%JNX|af%3kmvd!CYYL1;n@uQ@F2!sZ7=9B_^e<0V``6>t8l&2XD(Z3E0WW`ud|? z8Mh(_V{uW~!gnhNC(TR7$Dhs(9ENGwZC-+H^83TTq)G9oX!WyJgQLD|qh{pC`Iex` zeoa^rh!ON#t$qlfc6T$2`fph1kuHUWB@%%J-Xh_6+93qr$3o z2Kn-cen2b~CaC7vR3E;BYpLrRz&8L)qH zECeC$Pswi-+J9US2{NLiEd>29L=R+b7p)bA8dfky;rU9LB~1A0u-ujJk+?d3Ts~YZ zvVO|LPj|$H!e(CdofRy5Y{ubP4cX=B*4X{D{}1TP8rXTnpbDdIISow~Irt=eL^ntf z@7|=zBzIuh6ir%h%-8eY$+{`HyMX^7QC#;+W$s5Rc0N|Ej~OnH!9|+UfIrOF}q= z3-H4o4c`ZP3Ra%soz|a!$cy&K9eh`cP{kz;>nIEQ1?)D%0ckPaF7F zpv%mm{D=QH?|}}uc0}fgLNEQ28YAC(+Rw>z=D_MkCqQRQ;upaxyVK6717_%>=>(Pz zvYWiH6@~+xP$M0^Z=FQ}y}CCP$YM2#3lQWvb_aL$_&dz`q+Sf@=7qDrZUyFgAh9N; z41T~lU>k(KfS-;m*^*zL#++*1(i+;tqfEn29=E1a__)MVM}nfrO~G&28g)8cAm)c+ zsbo)Y#`gZm6kg#=d-?k6S4w3FXPhn(g{uEGdE)n1XAm)`3Uo`J17$3X--ww34{*XI zsA$nt*^OfW4h`t@=!1ROuOQZDmTlfQ9Ax*D~{O`G6z74$8USCIwPMJJ$$VW_;4 z_3rIqe*aC#Lp;%YquS?+D@7nmpwrFc6sq#zz=WW$;W9zL5vJ<@_gRuW2hEamFV`J+ z`{9Nr;u(4*wdN>#-qY+Y2G42>7C34MrV$434pwbPNeSOs)ndfnw>#KzVExQ8)=RMv z1YBK7LLpN^nd>!F!dhjMf+Z0`b@BWg-U&k&RzAjzE%uusy@ffjetdxgb54c9x_$wr za;vaF%X39?NkT9Ywwvq({a6s0aSu8}* zFHb-ZT!}Zb5=Wp6Q=kP`vWAnv*w|90W!M?C9Y%oANQ^7W%8vg1Sv@xOY=M&hyKU9c zeT&jb{blMG?2^V0_~*`*75(oT?j)a^nK)))+$y4j`>{wEWwQc7-!k;$sfZ~UKTjg3 zqyHFc2~gd^Cif$Kdvi;30+MkBw2tYx7@s7F4Fs z;@HYW6JBocv8Eos0&p}Z?t>j`DDKC;WquIdNj3N>>i1WL!LlHNn;hKs1hG(^0#LMw z(vNN!JSf2`xjHO8lz9cSmlr=82DC}#nj><;VK}EYlGgW!?!j)Kz2aP!POHVxJ*qZl zhvk#SY-Pk00VkWC>vk&;h9r0YZDh4*1+TKGe?W^>FeTrf0)T0{3>ObzSz!Iv92FGp z62Vs@bTo^qk!j^)yT$a5tvXcRMofXbGVnwoM7Gee#0VO3^0BPKQHMi_5=rp zEMb-d#x5~Y5diTXVcFC_inhYUxw@gV%O*pz+j!lunnBVI615Qz=blzHi}ou}j)Vv_ z`i6tAU#`pKO(`wSO9p>79k+J9pF_l~uDEuzz0Tf69jley4p7|9hoWg(?ZsS&I97-{ z*>@$&nPJsJ>~Vjl9?K7&UOEt*?IuLVaG<4ft-H_?04u}tp`Vud9K#fjf=a3+eHUCr zeS!v7982kxFFuOR^yYF{1sDdeubYWq0HIkFK1EsvA-KayuNPLa_@g5`Al9A$USUYX zX860x4viiH%GZHiZG-5s7U!YQ+ zEO7C0;$tDX#}aPG^oK2kW1$j=)ifw$(FP#rrKLACkn_HU=R?C&NHuKr2El>-#}<6j7JvP4fkC9!-`J z&@se4-2k@KE}iO_k#p+ssCox7L2#h6j=#;xOWM@mlEe=z)Gw0hE1IH!XC*|+c6Pj# zplIgq7w5e$QNNHD5~`;t`N^g_yN9pp*DI%fMn|xW!|q@&QeV7+_D83S=$>abdpZpB zHK}5w;OvR1*b@G$XHrLp+|sU87!*>VI;4G&ZVz@{^9JN{V{+hW!2eFB|3(~`co3#=rx)p*Ah8dvCXgark5gn%a z&GXVOPH&+hXZBYYuPTp3gaal0px){n&&YRbJP31z_Xm8$6O{_5xiDs>D0@D@B#-5- z5@ex)Zolk~08-|59)#P`=j&T30Mnfnd(}k7nbInh6cGtYwX(v;(DuL!9E%!KA58t4 zLD0lF{5P|yD*EnscM?)rsv`P@v8!yn~n=G%yq$uV5ocyy-$QiQy*yf)}im855qcUq}xcyBlVpo zg{7~qI;#Ebk)YuW&(igsa?{{~Ze6qU5B8t@2J5bO;HOGRnfI;&MFLY??bG@W$qsVy ztgQwcl6hY=t?OhJ)xr)p9V^vlI&;DE8@uxWu?enEq5$mRcXTPb49{qQ#(*50Ub2wQ z5+C?i5EVVq@1Vg{@SD!(ABY}*c&9%YmuZgC5rd5UrFq6@YKANA=6-~j%^=vCnvQP% z<3Yvm+&c5fQ6`O{dR$s{{Zc4;Hw9xDfM7#UnVy%sR&#{1!RL)9wakdygP(`oNYk=n zeav_z!ZHw+duDWq2O>fRgz_t6`EY&6wtP%D!;qA=3{X;^(V;+er`9<-!DMQ(U0w%l z7o1ZHyu|T?;f!ko$ryulK4nM+H$IunZack_90{RHZg^}ZlCkKutCvm_nPK5v5C$3A zud`~NC*!fh%4zGcQu}yxFSIXTOryV7jU4pNxRAe_=K=h563+^pzFcO>6`ps1K^z=J z`>=@2A#JxwG%-X!=67AV1PV`MN)0iOOAPE*9&vlfZ;HWb#R>gmXDG99An#i1wEE{ z;8s?d^Q`<>xTvV0$Oh5a!(VnXl_snv_`DhF;(~TM14HWuCpWA^Yv6^wly(NOGm0t*F=WtgvuBAs-JMFy zzUie8)r!g9=eB$bMFg?%T9uI*MgFcS(OLM@pEbkN@ne=7f#F*(w57Uak72H7(Y(RZ z`6&c0DNDbv#6=A^?l8YhA=$n>{tJ(tF-e6ML#ck^2uVNV<7}^hW(^IyexvPN4>? z13^ys&1E~U*iz}AC9ONPVVNh48T*M05>Sz}>c20P-D+w}_av2-ko8w6ZElq#X!rG= zAmUc}Db~rK?n;=OJ6Qf9Ht`eLW3`zKH;Tz!Yo8Z+fCD|B<9C0gO9q%fGDK6agT=}B zcc&;9a3lvpqigbps;BTurkTa#liMLUdwtLz{>on~7}+Pd0w4B!hQ5+NQSIyq;(gX< zQ*lh?@R14(<6ey1T1BbDASp=Cw?mTjWCl;u7N1B}wew$!Y(lfgnCiC-75=g1d66eW zryTwPH;B_~q!Y9hslqHKWNA31%kYQoyp&ZhV1U zD9Vs^T$p5}Z#-E0ZCey^AqIY;i7Jv_r!lrt7-cYRuxo7_AuaHO^Qq4mFD*#|La#)c zRa8uF)XX)B!$#N*ld+$ia3Ll_JuU3V)tx?95m?DM5|)wY%}W8~0i$sFt5m18mHm&T zT~R5pl%;E(guwgamSt~d6R;U*Yk(b71OnqtWy+AGggwQ7>|_|&zRW&X!hwbAdW9P# zUukRR!M*}y{~<$#`FxL{#6vbM(fs!Z8y@XvaXj<%g;FJmzJJyi#m!8S2lASaG4fB^hJU_qH zY#h?M;qONVfxICw5ej~FGZ=>ZH#~Acu=$KV@2D}8pBq|F(k_dAfZVy{&)vRB?*nGC z`_xr)CYIw`zmXz6M3+CEo|JNuINJohuRXK>l!Q zxpCIHEH2(f(owP7$gdG|o8r?ntS6mgLa4*{MF3AE1s>do78e-cnaB z4M+sB2R$ibLJCB**qZ6!n^qA}pc7f;0z);^KkcQeTTy|rC(VEJZmHf2EZ^Xz0#o`W&AJlP*@xG4DSh}*p> zqbZ^=M;mAqAMVfJ5NkW)!YM$J1)swE-U46t4#ed?sH-v2HFEo<@VuE(8gW@*O|-?y z^SP+8q#OK-5x|*qA?bC&`pnqmMiucD`0w-E+UU{K_j^%(_I#T0FvuJVW%P?I$w>}9 zX#04#jg4>?N#Xnk1uBwoLENYhh>V_#gO%T*kSB3pkEmJwSmOOYV}pCxh|HzGfx1w) z{8o%9fYw*gJp#=VNe}c6G@v>F8UuIqyp~*4T!fe@zob(j{m94oq|K)!4#GgBh8=v# z92g!xJ;?15okVI2BLwMHUg=P^Nd3z<^)C`BGRrNvg4xQVuHHTbRP4_P6Lk;6n?1Ed zsvNu<_WDr0(Vv$jYM0cRHLlWw9r8C0Wap7L+_3qUcAx)L)wYrK{5+)_twfeDMB8xN z^rfEixkeQ@9!KA;W)_b-_kRYg{SQ|82YA47<@Ba9C7IX|_wnRlwpN%FmPx{_2gTTO zhCr?T&7Nm!qnE$@Te{-Z-WQv+7a>6fuU8puhq;>SU-%%S^NP~@g-(7{Hj#6TE{$_6 zk&7WF&M4tbSTG^hUy!jFm1Q?0I$IHqEK0rf2wtV_#Hi=WHItzK007|#lyf3hWS3fB z+HPVYcb7wONmk9Cfl@PrKkII627G;cn3WVbUMXZ`Wm=~xGWVOiyQnwc84O*s22O4sg-v%fz$_J?C!>X2GCSSH*WgUdVv5IckT`0WOBW8N>ub4Sp!l=kaQ6Sc$ zB#UbhhIexg~w_^>h z+Pss8OzW0&DBYlJe^tb`Fm7@Z?EbzK!KAgWewthKftou#$=2->>rc{ji&t~z6qaop zFMo9Mm+wKf8&4LX-p7O7P>7eM$P<0)gRLomTB1P+6WS$E7pwASvd)F|*lnf@QZs1L ztSK9l4j{~ym6w{58rDWYbGV=!$?Kg(_ltVndr^*z;FEw|M2itp%+lcGD`b?ron{@QI8FRpbK z+IU*bxmd4V4?@}WXf&*qirkGf^odeHW^aEn$TWbtUkj+J#Tn--SyHv)e#`e`JL1DH zBKCRpIPdLh^-+%)LK1yDNjTqrCC3LY<{GX1ap@d8j-;30ZdCTT#&A3SQk%i`HJd4! zBZpTPS5{7EQz23<)_j6lM5Jrf)uXm0BoI5@H-#&drCfWT$8J0Pu4%v;s+N5%w;>Vx z8OPW4qt&`4-o%FiWy#4su0SPVWs5>fkRUGk7hN3-$LE>yApHd28D3UB%KSIL&D~t9 zYF@6ea$YV46K3N>g4KnI2}vk6j0*!CRD71LF!$fzdajg{00Eg%VseRA@GWcox|qzm z8{nJLpCg-`eGv+y&fN;2u5Xt8*tMfAKcReY^zR^VbUdur6AiH`*g(=wuK(M%a5$z$ zo27VbdM{8CROW0=p&9K+t|1BpjNUUWx157ij5>z_@zu@PU?ZpmtD<)1aTJ&8a7#Z^ z4v`OT^r72=Yu9;(E@y^LGSoLpE+Me;I;Q$`EYb+|tRk+Pr)^K91@NHxl2FKJ6=Ccw zq~L1H7HfoKDt@2v3=$LHlyV3u*a*bm@CyxUWKX-~$Di?g`&#Rd2LRp|oR-{n-!<+0 zA9FTybu9}|&!c@#mg~9uGv{r2dVS;M8>&s+9dNRgh;*O}3TznbSWw`>xoCeO*5pKb z`S<8y)uF^24@`AnlphtL8L$^3vH5Qz?w=4w+&DK`(zjk6K3Uw zl!9HKR2>)}DY5}N$s(iJ{5&+N0Iotd_6KxODO|osa9MgN4U;J$yfU@-lba72dSD<1 z=#$l6$^474rx+?ZTh9f@otQ)`#n8IDM#;)PhmyT=i59a~u-G%bX-SU%SVIsdG#jVV zXE8~8OQKRFhGAcV8OjMR9%Y0P7w3;8)#i^@=SVKjwwnT1?a)t+^BGhq$V3LHD@O1I zPovsx`>7<3%mDDAN5ah@LZ^Bl*ycTK&|u4u<;xkGnZc+gVrBfY>NzL`1Xa;I@%7+NO(4~mDy=$s& zh2?)`C4H#khG|H&Z;yB@B-=w4+(Rauukyetvrd}&4#|{!=NQY64#k|br&lC?A>pw# z8O*mz(-t#)C2NUrcDOhY^(Z==|H0c*&h-UwWruNC8r4lCDkzi=v~I^Vhqq(8(6O;hCdIJ`Zu#-Ufzi4Axy-60%1a zm)(U+E~~CNAzPy+s+3Y~>PR~~4%SIkl1gp&c_yK^=bWHXTs4BDf!{l`;7ktqN(4Xq#1LxPin>Sd+IWxr6&O z4ibk|D9WZcsT6JOj70}wzDs8k_%I5ot-e2WGRC0e(i5GMt+%lXu(xrqKA&zNL0QjT zp1}4C{{vpXw;S_tj^XS;HS~b1m}eh~yzdSz=MZZYe$1NRYF^t_UND#Udh=3Hs;3J* zv`KwxfeW>*mUu1!+ei%qRzK!!NE^I;K|<(zGpt-b7KlY|pcH-O@bkT?XaA4eEjIA< zTR8%;iC{>Y8jK|Ng1oOjNm2d32gL8~obTtqV0v{@(`m%tuG5d!O{-lyg% z|ABH3S0lQsQkh(Zfx+hu*U!55VM9ccV?xIaG#0_gLeh%z~~cn<=R!{ z`r1aXEvh^D($k>gdmPSV{B#$H)BU1A>5ZG5YyK)3n_dRH|{g>-iV2 zotW18w!L^QLZi!WuZM)8g4Y*>wv%REv)63ixsaEaAR-zPUv4q$X5;$ATr^R>WW5V> zdWYE=2yod2;|KK61autg#3JePC`Jay$c7J`-RyC zBj#mEV`|%CgFtzsMiTPH(3BAGQHwpjhG011ho73u@h7pD2Pfyhf4?Emca+eZA8+`$ zYLSF%a+>~&7E3x6^CCe=u#yadsS|Oa7kJO_bQ;1X2cGGy6G$YAb^40MdtjW+Ci8hO zro1+HTJsoT1CvWCusJrq&p(`w>b=kXZ%P}g^j>bYgSjsHGfZA~SRYO%O$IA*nd@8z z;QuR>5I{y35fihSd3LLR{YS*3IgvnmBUVZcEXUjuWzNC5%kaFbh+#$CgyQPrh3#cz(js(DQkqh1yw9a?8vR_B98+pF?SHL?8_aK!Y_7 zsoYb4KZPq73*>r|d#*c9e5QI`IH4jyyfF>idPf_{=H?F6A5gX_2m<$Dt-hnjmg-2B z7=&4^c>mQ!b(W5#LZ-szkxMKk*^>ukBO6h zrc0W%aoLH`Njog%We=~(df4HA_ndm(-_SF`lM2UsGk&psWOS~w)SkV79d?4;>H!}4S%iUx zx1y*aKc`M?;MNz<$=^_mFb<4n4ms{b9>~#)ds^|2b$|5Fx2Y#&H5xZTLtag1&lfsQiQS*r=03n>$6$iMbk01R@DP6RQ~9 zFO81h6p{#jCXyga(F$cLGtG9lIzKv!m8frAVS&`18V{jq@Dz7|$^ado0LVjAPGQ{q zD8`H@n~13qcOKblvwgeifSKS*Ru6UP zUQkR3HM3jpxyPtm&_99%gO^Fl+H?`ygf5@*yGFK#b- zjrZwim*;ga7!aG!vv%^ij?J2k;K*5<+DOUo2fsM^5)Gt|nofnn@6pFS<$v5SJtdK;-&%q6T5pqH(6kp+CW zeFuWeLFlh{CQt|%4`4Rm&C9cM?2k3!NfStQUl3dsq<#RE-!$^V9Y5-IF`=``%v#-@ z$Pz|U3z8Yv0E}8q(yp5Ms(+f05=E{MEBA?7(%<8Db)6vHO|m+uYTm@dDiNi2hq?`! z_MXtKP3hJ-(?xrOF@3&V8KGA-1+Uy}J=>q_+({R*?3UA^)YE#~STubb(N=kSwXMyL zh}?e!PXd5Pe8DD2m+^+(FkDpi1<#o@F`s=~5A(Y_b6y&hk;&Yt*Jk|EC%^c2<3=)5 z+qAS8S3CH)-ERSK36JqTUkul|=~lwr!Qui5FlEvTc3q3030Djw4b6lDs`mTJ6scyN zJ|!3UTjY4=1LR;ja)N)dUFxz1laC9r{|s&-^fCh|{{#xW%ZyxsqMzJM4%dogD|ZaQRI!S4wbI zOEo}jAZ@udc8Hs zLf^JoXg>4nj4e+A|FSEDrYQX+d>qFOT(IA-^Lts4G9dgFe!F`iOx>}oiklco*HW+1 zwvM#|vz4IJOuKb2i9mj?POVv~-iI!!SGX{YUpNz{`gjxR=*7k**aA6dAaO$8$Q1>o z#uC%7Mh$zG6u4pM0trLEs$0W3Ui4~MG?HT6``sAC8i*zmQ66B?9fT2s3(>*A>x6qD z!6DKr*=BfGbHP?Fw^$gqiXgY^@YS`gSV-lZ7@|llEwej_5PV~!#uUzhJr%tycx{q= zevgVrRV!L{>`)SR1#T|Kk%gXUz!>7OE}bY}WcnyC8FhAX0~L3pe(ifM1Df;o9Z|{k zyrM&FmtOhLEiVty^QF zo85#N{#z(puuuzgG%~cPt2~kpD4k>h6j;Gg6W7sN4hN8X4@SrVf*k&W=!`ELaVBek zxERnGLR{N5(TbI(h1_gkx36^IR<2CzLc!wjqMx8oGE6hFzmZdpP)eU80sNV&Ym(8f zNs3MiEUygN(ZVdL&=+@{YTu*}+Zx>tI=mj;w!1-_`6bSNIo8O-cT*7hK{}L0v3pEZ zOyyyLHhEjp55Mh z)wWyj@T$Qw5NS#b!l&{x06hBIYSldQzLn|1ve&(R7jym==8(QYl3%7YM;tjl&|<#D zz&t%qwR9SN!iD`DR&(ZEP6+qxMJ}VthS(Q>lkh0RnZG}yf~Kl!F2K*9szxwQmhSi? zWHHSso7{C0;!Xd_OXKI@vpc_K!pR5rCW!`x>~`YD+%`%g4FjJ_caNx9Ts>-Ky%yRfdS%~Su z`6$|73oE(w($5l=CPGTHFksQR8ylTTpwrFFva4avP+%R*FqBI8dnLPWauSHW^t*MhXg{9g&H`(ObJS?alUC28a znTu;$CrV{;yyv;HH|~C?@zWZ#ezzs(@Sx?==ue4aK~?Ml_^B6x^r*GK>t~mBkMjXV zV^_XZ8bBG&`&=8pFVz!}0j8Hx60A7hX66uOGO&aG@znXgj$y19au5a2T})ldXasF% zkcLpS?e~8#|3ceQ8+|H)A+UGVH2WcUj)li0(zkdO&PTvk^_LpYhhz-_4oU2kxh$N! zQ?Vf4&sIG(n)o0PUh02Fz2zE2xEa?PSP*sn35`ldledfgxg%{ZK!LBz5@|y=q;{rd z6mZn(JbmBuzZVCYp0@zu9rmm;vf;B1PYQjlIAZt4Mt8}z6xxS!#e818u1*u*7XFR! zWNcUKDg-VTwJJZ1%87+78TkwPqQF&?5D$p2yF{ML9VYx`Rnkzj>f1ecWRDrbB8lXp z<0bOAupP)uqXEQ5&Ddy55Rc^*3zYKOc1o{T$ix{vF_w0=wz?AR?d`A z(%QUHi5}3y^j_?pYm{p9V_e44FAY^qJxgbqz+1oFTfRn(01Z$F5TQ`m#=sKuA;pMbR(sLNgd zPP7y;rJGyqSs>y%C%}qB>=~8>6u&NCAV zG6!im=!Sg$86ZmtxP3FSLNe*T=YQDRKQMm*O=gpbb3|`j!IV$Ub-C0e#p8%U1xa;u zd6>83!R0VREtnQnRl+KBQ>BM2y$7o!=K@TsYQvP-GT7tcAK{uQ1QJRG{TiO3vUMDY zK_fFrV(uT*aCDrYyI$4P549rh)VGel)#}MT@5y%c57u@V+f#<%B9uyyd~@KL#YZN_ zGMB^&saJ1^|Cybm7) zfPQ-Wn|7A4YGf^gl~M;&K9Aw7XHBO#WE;O8u+iPw-kHHe86So(X_aYCCo8?TQKT~ z$C{Lwij$%_jobdK=kByC31Hvs8^$>n46|XOq_KdL9?vHG!ywekkTkYSs2B?M!N`G^ zdx*<59IOFBq8EhH(y%Cp-HpTs{L@R{73%+hwY{F}>@7WDzth~$n@+-aM{pD>{2a#~ zmUe+2M#A)KHiW_&|%tC zOXo&~yBe=coNc4x`dLp4_|~;EjM-C@b#g z8sPn!<0K`M#;_6ZmRvOGX>jHzQi-91cq+2)qCwhPGHT;ur;W&i87UMW{r>68I~#*V z6=liX3@gM ziPrkA**_>$?4fN@{R54=1jnd2b;>N88m1)lp6et62_sMS*ifLz7{ALW{>`vtCA`Ri z8$)Gmh_KQR-p1jN%&Pl20(}$`c3!+G_u;W6tc8XQlea)uy{d+l@aWB`sH zUHlKI?Y1Zli0r4Rechy7E<0M|4cbh5~KHm1UXa^m$~-cxJ$0i zf5OvDY%K^65AN-#z?DHcMFyv6V89q~|Bjzo6*h1>%^w zD|2wA$K#0y(Qi0XfE8QT-2@kZ&%ye?Cyy>l?N})!VO`Cgu$~QN7dH3tb}nV$2_8Dq zgacNBdX&|D-bbK+@r!Fds>ZJU!-!wb8qmFzo7JUg>XXdNF$E?Zy^*AU2>sD#S9I72EZ9@BNZnvGTq8SN2})5bSXMc zZdJ;tmcgYq0$or!Q!<J+J@nN4wZ+32#5T zO~7{PNOzIZqKOQi=xz8So-@lGAgV}}pIIaYmDL9i8)H-n4yb-G=`%d`X3?LF5 zrtH4Ef8yly!?^u`>*RO;-v2SPu!sLU>qmR6f^omxr)MR?rmdS9$3%nWOccLwP!#t2_(+s9t)(jp5<(Hi}gZ;_GwnwqzjFCe<$zZt4?BD44(MKKInEly0cL2jYbl01N z>Fm^&5cER~R@HV^8#zGGKz2+1v$^_>M`oGZwt^YLUOjKn*59d7t*q5B(?wQS?5R%z z>Fr>fpV{dI0zbXPHI8_E5uOa&@OP>JBdEFrn2{FHYUz}1L~x*~$)Np-r%%r3m)(A0 zQi9YMw2@rgT!evR3W(D-4!|ZN#xOS3zyiC(a^;HaJ}J>`G%%C07}L{tUxY4SuOn5D zm&S0-Bmo7QM+U{Ldl^(JH{bEExfh#Bq{AKh&g9b|&G1&y7GdCHq0nM$Q-3%M4g>v}X9;Op56Nd!@3Y!+PvZW&L}A%x5V%{~@${T{1tsLHqDJ1MD2C+W1UBD&5=GU?!n5M(i-!t%$!~ zc4d&}#K;mpbcY@f6q`{<(ThWn4y6Ky%5nIu>&-F{jNV68Hoi-6M0$H3UdADiJ_6a( zL2@6*!izX|(5jU_qTZgi9XjTPULNYb#z|{SsoaE6tZ=j?p z*%7^zeLT3C@n13(yLuc=!)0I8|M`h0#);ZjiLjgw^Zm-yVoCpNt(2YP@XZZ;G@ls{m67$|I*f@sW$JV*_1tX9 zLVkO!1{ihHKuK&6zgx$9$1D#;q*Q)$$2Ptrt(ybpi|BoCL27p_2XxP6>P0LUL|8-} z#|0;MQ91#gY7oZ*rJkA?w}8oXC1gwnX$pIJN|w8*Xzf`{^@ulzDSkgr{SyyR0b`2J z6J89>H>ghjxbTtBoT7xz6wV|Fznkj)5Ugayw&zp*5;UPMQ>PQmv1Xasr69Iy3YzY` z3+4uX6XX@C(ur!&&x5I76CGe*Kbt_M!X;?|7eBn3x@sJ0R0@V!q;<9lj|S_<{~(wsc@y^ zkI|UnKo3vTps;k0j!f{#zN3e!67w;zgt`vS=New78dpz5S-^YT~QhEL5G0 zn!oa?0MeqQ-k&hCFTe8(S`fC{3a3qh^Te44DDdt&4*kt?(hT_XC*PgFYY|zfu5aVZa9<7gLq2uqkj7g0~ z!)Hu#h%-ONq@(CH?I9rn$ltbb;l_<%5ABbfy7Tu>x_%1Bb^dg=$sETH4F=OfF~zX4 zVt=)8s3qWOPA7R?^i%}HxHx`q&=WfmnndmX_sm7mq$ffMh(o|JykD&;ybv+rue%D1 z4kH5x4qiDp5ZN2qk zPB}MihZYicD8Il$F29o>Bt%rS2B84QMD04KWgIBk|g?~+w042ao2@Nr@;t^d=~l;#WVe$4~Niv#c>=sHVqN`{c{4q z)H$O=uF2s&5CAiE#AV7kI!EG*<9{Da-pKeTSPx12`@hul&n}cPDN&s0|K5sEopXT+ z&#^3Q+e#QDh9)2}PU3*f2aJ(C$kB%csR7!He&24vYIwg#V(G;3 zeP#FoIvM~W$^VSQKZ@bn1|Do6<+xP0DTI;RdIV%_P;{KHlD?omEpb3k#-eb8B;a zVx8ZL-wxs{EMoyA}U?afjz)_AADGCvqdng_>wxGh)QuBJ>_?GM4W3pS0=W2({uOx z_FXNVgGANC26k@ay%p=uH8_!x8)Wh66JqmwtOZ9R5H}c%NyoSy7bh_9C;*ZI07;e- z5NJxo*47pTYH=<$Y?200>*{D%3gi1*RO`;RQD(eBXdsr%i8^PBogF(VIK)X~&P+HEA0J;G2S#L{R_)?DwsdJ!`R0ybnZp169KGPe zwbE2R%%6CXB8Po2GBhzsyE7!F95MQ6SDoAJ-SI2YYTu(h9&l_X=N4xt{QlK+QNG<| zxJ1&6HFk)e)Y>Ax?*@!YSNGvWo`pD$8#7X-5TwFNYE4Q>*?ZYxse4exjfoIxSzySq zNtS7bJ|*JVET5+G@4eW^v+vD~gWmrB{&g;EqE3I>;BIbi%Jr*oWt5Oh(|X-03FG8- ztMqRF{tfXw=L2|9;5IurVNY9RF=bg6Y}T^Xob-E%r>O|CloX`NxFs$v85w3o$Q0K7 zp89Waj{4lbKDf!BKSMkdV`5@@6_|efAmR}f4b9FbyS=+BuC2u}=ISP$)6ua=3*|*# z^t2>rQkx5RsTNB_81b^;M#wt|@x)d^6DA3P(?CT7^wYp+Zqs20tqg6T#bpPS{Pd3_ z!6zcBMVe*z&j&>}$3F*3x@uj1+~uvCf&W4BH&@#|;c63;^5MuR4++9#!|FIxkLTkp zeSJxh-eI+wpu)*0U6S~YfZR4jO3fz_#&`BtP7jF6d1MFp&gn2iLOrpk{D#GkdJX^a zeY`aXiX}nw%484-1Dl@BIO zANckU_P6{wzsD!u(Enj&32k0!edeNH^y;6un6bP0o+C=FnvPn?mI00w{yqaFyn=vD zf7s@;mEY=K-jC@R8kWQNYwiZeB261?b<7q-W+u!#RtdVJ8r<(^=H3QScR?2NN;GNh z30QFw8iS0BfdIF{*$sZRwwk!(KvD!`P#A9L>uZp16$UKe94U|ZkF@}8aej(2fx0Sh6)y8Xa8<8Rez-M z4a3wkxmg?l1=m8yZFYUU#H*=3uPPTT>zp0l8cd%7QI8bEwl&;Q@mYS6ZZ5&WJTg7#V*w4U7zbsi6Xk68`}_Mhnp^1?eAl z4`OcP!>nkpzb0+ue8A4K=|jPA_J%*Vwpx?APzG9XUo)FA*R}~6x8Y5(qVo#*wRsET;MjF zO#-S}Z$wyp{_8g9(**Pl`qTJ1lw=uhK_YJEVqVYSBQDtTb8=*=^}{wzT%<&C^vk^6 zN3~ls5Oh!9K|hkD>Xx}^CTdu2oKy83RNbBSE(Cb&E_p*-HgDO|IIVfUbj ziXcUYh*lE;~5jYscCL>%;-lFT?wmyp0pwe8GjD+k;$f5M*|jj22x zzYuIalML9z(t&d(3KLw(duIYUo&5GO`+n~mkmFao?kTVOIO0CLvM`dK^m=Xj&M}xr z*7Kj7^EQ9xIOtxJ! z3%WeBk5k1mL-~+9PiQTHNfQT^I(Z=y!dZ`p-S*$_c7N@#sm0g%GdPTSTxqHcvi)S! z{aIg;-h56!5W|=s2Fb`tLA?b8OpT+b55Rc`3t3QPK5ikQBuG;3;I@@IFAp3GoOHdt zwY@!nTB41Xt|BtpDi^!l_T`^+>)BG9L4)2QE5>#_8OqgB^`Fj<0Tw++bv}##R)v|H z<0#d-(j*$Pgm7Gr8_@?!`U+7rzPWvn6J_fMCiSfW@+HX>k~aq=XfslrJR_7>W3n@EB8TSZm16Vg3x;b%-X zJg&=z|33S%6g|rFAz@Sh%y=~-C#Iw4jv!>Qom5?HL!82>5`l_uSlSs8lUhp$X2HW_ zU6*p?niK}a-TwJkc7tWDGJ*c=-LLGBzWk!CLLqel2T*{Ivuo<3yK9T1 z=EzsvS;DE<9x)`*o0f^eTsaZ_kFOJF(Ugfho3}^q931nsv{H{x0TH z@vN_3WlPFXa9$4SV4MLLJ~`yoOqxZyuYsFeIQo5cWVLZ?|7~SEaykj#n(Ut`$BGLH zIez=YFsBI9i~=6Bi)&^Bk#TFS4- zr9<@cDm6<(x>J;8My(f>(Q|fX@wg@2z z{Douqa9nP60t^g2QbfcSQ#o^Uu1$m8(UYT}t&eV~a2qZ9VaafQJY?w60T0{QOz6S( zW5@GAi-5!<8_7AzPw!tJOsBut#J`awFn9i-hG!Z>S2H8o_4*0Ifk6TMgiEoRFMDIv zR9rLoWadDN_O(%#wPE}4`wY}U3iv@EH7NnE|7@?G`|`s@XNHrrDjONliC*XZAy1vlbu@r6lkBHWC3+CjL_ zHSZua06rYw%?mLc2t))vAjXr~+n*hds5Gr4t}sbxsEBEB26oX}c9YQ*DwsG%Y1bJ^XYdO&#=Z5v4bO6f30 zqCr67$|SR9Uac?9>d*mn|1TMVY1uVM?Zl3uS!tE5u*-3zL$HyhJ75 zCdQ4CW~TmbY;4TR%^iZR086H-aDX?HS_xyg)m&+U8zqWyixZrAmPUdqYchh$iwOMV z#Loa=-Q4_LS^8evj=(B7dq*t+&r4E(@ky!2cB8E?TY#5)%{KaDJ+{FV?OI z_DqD80n;~t2fGHF4_^&+$zJv2A<9Fps$ReGCYaPxN1tD$x!(@h^WnZ$lqdIciC&U_rBMw;A|P_PONKO+-^wQ8P1= z+@ib2mn>BX{Mh2+VSeI8gaS~bw~H%-_@TDkzL*|RBrh7L!(~=ko{9HMo_;D?$p-sf z#O>hKKK@caL@Z-`{{7!PSZ+0|My2V39rcjogj|+k6s*th3GKu$HoRm>Q8nZ{$*+XW znmp6QvAMT~#n9t^m0$Uua4RkK?sH7AV03IoFZ3uRE4E16oV*u?)k$pWD#i%E7I0zn z4<#8VjW}pWs|?399(zgtdDeVWlp2k1j#m6Hrt<0QbOru7jh^w0(q>!obbvJW$6558 zrt8Y5?XO*8Fvc$Va>NzhJ3mhdTiV`oC*#PJXtk_=ZLm*nQ}y@m9V;%QtH_SIH049V z$u=K%`1OkBLtzxh*QB>a71RgYW*e_>z1;E9 zv3RZ74bhn>N#1Vhcizk`WuHIxGjO6cTpdZk0P08)Xuu~DNu?y0k#i1X>@1ig;$GG~ za5QJe{g>%{uw@^7sXB9EJtmidLdof6t;=xvpaFHvu4iEtXut%ye z)`}!QmeEI@v|EUftILw&74<_mPEQNcN|)ibUSntUy!&dxW{2TAt z{lyDSuC(>Hff*`L#&j*<0)d44C6gc{?6uUg-)k+9NJP!k0h3Y@k%ylB?(^(~{^A0n zr0=co@r!8TteHilotqyMo;H)~tbjlZf}#e;$)GX%OQCX18O00a(ir=Y=|z1`?fa;7 z%*N*BC9cy7a$(a)+gMb#{RuvOJvFYAH5c%F7b8d2FS$ePyyg^RbUAf85Oll z52`L-d%!lz>uN*K+~qzTR{1)wupZ+pA@;t3)@5!;a-_$c4=GeFhxJF z2WmLu50=b13Y)+N9eAn@P@Q9qI_vkCm8_cH1B###^bv4I*JbakiwrsXy-Z;PgNo$k z6J4ZR%^2T*Z_5=`{_lIau3f~Xc*xVbG=&4w!P;K$b5MgmB6S?M;N!DmMGbmaj-B45-gm5`N}7@xBHQP?<{q%N|wWC(X5vEch9Ke4}|2F z|7`94N6syC`EMF3{_lL5`bbgKWtj^L60z7<{L|VPTR6}Ov*+*QVlV&O=c{}`g4DQ- zz(?e8qcVT50IH(8Ub2l{O+PX=qG9D;&y<}7($(7~B`24u(3L6C#umiz3Bc`m>RO|*#<%!uU1$;|2D(E zs>YK}+hJb-kxKN=94960-$Ff3lCRq|I82FWKe<+LhFLf!;>)Mk@|uip&A)u<=(-(2 zU+&GF0;syuVksjlM|s3Q6Aui?a;rDOctmwb7%tn#9Y-!eU<@v`Rh)|DgLLPyJY26( z_XF)Zw4thZZC~QQEfTAF^vhyGuqI?G;jSQiMg~3Ao~HHnM@XUSrH9-Tl!*~2@UZxN z6)1rb&C8C@Tp=I61f5x;?^ku8BvGS0N4U2ij|oD>=Ym>3;I5rGXtF9o^ftXVvvR9Jz`4Jhm2+kNf}=o|iZc^o z*yqn}4`)KXUgE+vVJjC8+AG$oVH}SK0o^Npz$m*cRHJb2f$KSz@+e2ey6`cRhrOxSQSNCBxo73ADgQ#!LRxJ-AKTUQ=dzHkc& zBzUyX6NMX~%Y8rYc^^V_^IIHe@rOP0?<9bRh=7F**R0f^Y!{%`X)Ba3A9*0@uZ!-PBTPc`UZZu8AIMM5^jQ@`_D#PoRDkiNk`jNhH++u zJLHfH@or)$`h%uH{LU=aBXnDD&2G~Lbj9w0O2_WO!J)gir1U>37slls6*dmefZIXm z{M!p(J!cYBy8FkMk*gK1&v;->i_3ZUG<8;IRVFQqVEH#Y%gEfi+;!bbJitQ{h1-&P z>wkj57sj!ukDh_<55wiB#^-*9Q7^b}H<1WJR>wh*o|^EwZsugzitT#; z<7*vs{ES@&FvV9`IC4_J&ILalL9de5kf$N-{U&ia<&E*6%`oU$M#kCmOcd1kGTZJu zDAe=0?`>`r8A4<$g@eE&5D&Ta^=gImJwGwmL&tL^^3L9Fu@CjC zs0S_Nty#nuS&$aMB-Sql;f3}_EXzpTGFq%lhpYx#4RQZ1GEncch@l_;D#}<9@BWBN zM<21540+9eygi^ryu-=Ad7d`&{Rx475Qr~l+qgB=`P-N!_Q9~lJlsoZNGu)MqHXXPC{Ft}tGD1ofgxGwLrE0;K9;*f&BY3O z5hkU^=#;Dg-GAt~*kyymV$F>PCqO#xjW!e-Qv^%AEN)C>g%9y{WhM&sTD`TO4+nyP zK;MU>4Kg`0A+DG7TcrLS**=jr+&Wl!GL3L&;zrloPLVR1Gx0GmgDvMx%1nsv@0HEG zxUZ6r^)2eJv2H8C>6}-dRQ5bBnOq4_sl9?qxSzoiHKR~9NE$#7J zt>Ax@KD*+N3UeWhW0G=^TZn(PSm)aZ#Jt*)?CgX`#cRRBbNW#w+_l=-8*R{%0%QzE zmzf4FPNH{p99hT+C79vbgl{p@Q?VxauUa;G-YA_LYCLjbZC5&Xm!ZA^-6gH+zJ2$w zn;DrdHlR-TM}}gsE@tH;NaA_%Cw+jQv;A zE5e)sSd9fMbNcz!=Lz&ZQ~+(O&Ba+ zh|x3PY_lwMMQcc{Sytppfq3~Wk_ja!ct_^cH-%$V6;Q!KEjr|o)hEOZoq&I*>(=%q z{7ra~7KifK>B@Lc=XJ01xdLNjM=3i>=#5mff*L)>6oRcfC8X9W434IKue8@zA1dj; z!t61FETxrV?d`4a;>eQ8?di>QD?@^gL>dWKrPX_Qh)RAi6&^hd%x-@|j`UEm5vEb9 zd#MVTe6q`2Vj)YlEHPM;N`d>q^!LY4?ML#-VCmpIZM4G`^cx*TdT+~Bu|E=tTNNPx zvki@4+)!DZoZheIEi@TNRijK{4)3|ML$qh$`P5+hR*V_1+_}iR7jxtK;|Pa9hrd%D z+Eu`3{ywLL>JfMbtXo5Hk&h3mb%wRr?w-#4XM);}HFa7)>lveBPc&@F>z-cr9JF75 z_U8n@#bZ(Z;W9KCbL$XRn2?7TpOi0MY!-FWQNDNr122u*(Zbr$D?NUh-PHizPMwr( z7y#VQYEZP$jOYovq`z#Li`nJ!taF>?`)DU;ly&hmr)Y;c`a}F{&P}Z5VRxCPfp}v zn*Hxua~z)_UfQm_on?{xu*3+g<&ghy;@mudDzDj+aDJMrBz!!LSE2rDhk%vtIRr7Y9VD?XuZhUr}aj z-lW}|*^_hgzgIfoAH$s5a_7?H#pwIo+SBy&YA0+C^&L&T^UYCa@4}?{VQaAw>n4tUdv@r8dUr^qKJ7RDp%DlM6 zb*zva>9c%mAbo1YH_c7Pin>%!RV?No|p=-)}B=hYIDKCgkx5vyXjX)kWDFrnpT z;N*(?x`;7N>GQGhhhw!G~ryrF3P@927C+z6OWwGu3WW@H;iH1N_(8{>e5ovUL zwUc7_wvf?+Q-+l72Lf%lrT0E23!hX>lX%zxEToV@*u&3`$!TGz%2mg!OH5uAsC&3! zaMHd7j-JvC$ATo3IgglXSb!E&t~s@rG+CVRi0G9CGh88|%Xj*bei@>O&=QZrf&V9r z1WR(RS*AB(sW{-~o=c5Y{iG81fQR0%Q_L8!6}Fbaue(WE&-U1i{w=p3-z$6X+5|ok zPH>0hyfvyL40!l25g{7^LgU_e;(PeQt}R#?uUWAX@WG-0@yV9sMh)@|+JBRKHOubi z)#6lfsIY2`Efgl&NZu~AGy8dhZ%oPE0G6&*Pr^W zW)xr=JfO|meh|SUcox6#9c|eWUqJQzAM>+Rd;EGHdiX&IH+nxQ>Icw=KOVQ5a0rJF zg-A5(~Iis|*mtl$ZMyffH(>-JK zGPd8U0Z*Pj5Q!XnA*8FoKDI0r%{NII*a82l-hmiojyg7eqE?zn&@0sWOdnpaN^bN4 z2nb-KjYM+f!Bv18mr~bZ;vM9zN_|W{yFt|%_L&>)-*6;oyM|+-)O**QOq_Rs$i>A) zrE92|+3=y0l>57f7S9H&PMzBGa+E~;$C~9OIcQgCE5+?Jx#k~5UaH3l_#BMe^bZ{l zL;sG*f9Xf;$i!6fv6fJS;1}I3vTPxaK=oxB#Rd|HBz-+ZiuXh+9Bg8od8(VNwZ=r) z4>|TOapg{a48C?Dz}5Jw>ntryos~=Br zp}98V$Nx~^XQh~Hw)Nv|srahRDX+d-@Wogq%sKP6j;17{|y`%zscIrno6xcl6&c{sbIT2x{V9U#-;Nhs2qF8(S^s6QSGll67$K@>&)Q<4G+7(uRCZzZ9f-&os}b_>xdWBRiEc|Cpc;q1U}m!C<0M`!A7ZVG^w;NO%UtB!!2 z7+C=-m=!9I_y%^Ke~1er%#eO(`sr$32tWz8vH2$4N2-&WErpwqZFOM5ZkKi9-+ePFUt-6dHW@UT zjf+mtV$*DfJbm+-UB;lbWXJI}$K=~51>`ad#w9f3jdjv(qr$AI9OWxF4eEVU)=^nts7Xet^o&*I zHzEA}o`E?Q9NboWw^VZFPQ{$%`*IRb{v$#5I}H5p$5_^0zD=|c|FGh7)Hcp2Q%2!{ zDcGu@#0Ne!vRT+l(wcyI?)xZ#)TQb1>jBxx%K=>p28?x3;WuSo)%~3KKU0M^zpLCB zt5(LzhTAD_FCOwC!MsOMH_DN_h|!Z6A`$zzSW0%D_T>bfV~vuoB^|$lB}<|+=5xdG zxpnQWz=9r5=cjS0CHz@Sgi!MbuEl$7K@wQzH)u_9%hdUOmZT22cA^Iw>4yhTFeDM{ z%|KgIAy?#C?5g5RNXP>PEMEu{VapHu@t0vh{>a`mqZ>G6wwOlBc}5;cig|BpaW%X0 zh4lSUh`8i~%zgV5K8{fGtcvHmW7dA8~0bC30C$6_Bv{v7lAGoebKkHct0d z5|FRtIvigbONk4Q41}*v)*9@(iIZ^X8^NRA*nimTyUj9ZP9`pMs;1Wl8j;||=-P9+ z>E4KnIGlEWqA=v;i>M6Yi1Sj!$gX-DVYD-!Ym_rWWuZcq0 zUK5v>r}B*+1+eDesE|b+DpjZ062@+ET7vJ!6(!wvNMo!8S!XGy;xw2^o{w9u^GSfP!4;V067)m_2RhU1Mj~Z}CqNXt) zY>At2dp5a|S1fQ_%8jwCWJEE<#mA?Nj}Su+-&EgXZuZH>jTMpfp{UPlNF5HxrG)DH zqpj3ZN+c@2?%-vW#xUpk&QAYqX<+xDl}>-}{PcZgs-`VAC&>?ztM$+?am<(WOW^DQ zhF(A*Fj98ech^k?v;1hI^N;OsL*Qw##_HzmH01LLXh|uT;AgVZt(_;OgTtP_;?Xi< z1#x>K;fjUVWD)<4pb`4o&GexRI^y@=R}jHk4*zp_Qi zvaul_bkNIE-ZvAY9YpvrIcSj|h&-Q{jEj5$JV_H4tt*Xiy#NuUX}d1pyl+)L zEdwR;3GD@8wfu|8NHq$vu}`P8>gD{aU^SISo|T`A*tM&>O7hyT*GIiu3*1D}ruDZt zvs#>Zf#`qGE8-tk0n88uT}-#^`SNYT-@ot5cTB(owe5naD9G z(kw{95(f2Pw*=Zl>X_RGj|22j#4BF1w4G&Z;Rf;9M2q)lMsw{zEJ;05Zmj4 zUqTJ0Y%z)S{Pcsbr!PZ5AL#io#|n%E#n)niLjq-q#AaV~qkt|~?VN`QUVt79t-SGH ztJB%~V-#!yv1G(1b~9ympQ3bUY7jABJUfHl`4@jKb)Nq?io8}Z0%rJ$=`4odxF7u5gh`_Of!nfZ?GKpJpH!AeJwKyyP(#KUOKUW#pLe@h>XaT&QvdLeS-Xo0&b zyZX7TwQK2Fe-jnsiZF!PI3h9)0W-+|TSG`8PeaErDb_cCaJsfzViI)8d~we zxKfW)sbUn*d;W*b16a_Ua^8D*KV6bW!f_<&SajkYVJE(7rW!`wfu?cEDI({m$4;zv z*K9^gqp!n%C|bZ`U8T-UpkIUhWz>JO*X*3uq=!XN?0BVP8rPM|k1LdkJku*h^`0Jw zx^88jT5BkNC`oK&p%KTGkxSx$1IfApC{5lMX}fh&BNPev;I=ZAMvl8uVm15MC3(@ z{Kq63cn*NyFRe*m?Wa+#ZY`d8_RSdUo5{3F36ug7U0sH|>>V7HQZKtc({|xov*);w z%Aew29E4zk-%TU$^?(}trlhxjzU!a7Lx#WU%UKaF9wCmrrXN0hwSRqygR@YEX3iD_d%ikF6Zsu@3Rn$1j@R!lIOi8*R z9UJ1)vi*1=*1kGoVvH!{X7mlW&>$SKW$o)MER>C=Uw0CZtsSZ zpz^$b8@U1sYp;|XzKgYkKp2v6lf1*zImmaZ>>i~5(?O-CnUBThaVU4;y&aD2-=@%s z&SAOzpWvGL<1m{g6P-IdhKmMFU0xw-&7Q0W&X8;&_ljbzL?-o(2!ZMB!CLSHABFyNo$a9($sjazW{JR+|QvKA>R0^Mb_&3vL9zx z?|hxRg+H~FEUnA9fnN^?VyLg9x}nt>)?dsmSl*L_)hYIgBd=WOOy-i*lQ|pIjlyqcP z@NPG8f>6)hwG?>nyL=EA|9S{T9<5w~KL;D<(UthvV=zZ`Ye+o0ebXR6pe(VHj$~^O zc3v?Q0(fB^%AT-3=sHaJuzTb5Z6XnPn@(=OPZVsL+r8S4>_*73`g9g%p!;DVn!&?z zOcF!l2nS*|Qgk3|LktxPdMnUU^p-P<0`%?%13eYSa%f@+ETo=l`)r7(?{ z8WnBf&jeS1%jH9U5`Zt3&lg@#qp4a7$9?|{`1IcpgWe`$fg+96#FI#u;R|STh9i12 zRP)G8P=cAM0scSR6XInRu->J?_l2>PXO&5X2pE4_#U71`b#Cu;=_fHjFd;Zro}s_% zGTUg&`!EEBVY*2tRuCig6706y1~tCIO@YI8*n>ahS`& z0sO{vrZvrt^JK&5sM|=0!&i zv*cd=yJu}`xxKH(w)v&1rZ)^nsG^lTbbH-Wq|g=lNYa4)Wi(%y7QU zAf3^1edBRw_I3jqsjVKr!>gRxczx}8a7cyE89aSV`%W8p z_T_S#zDlidkl7oM9+J%cNU$K+jswK;qUH>4r!w>)ids0t>RnA@1boC;KQCKsHt^d1 z)smNBuPDN*555?&7C7<*u39&Q>us1cR6|yQJ@1}h?;n|>g|z(L-TBFpyiUbb!|vsGM!2Mp4^79RRgiUYI)K7akwqDF|3 zn$7P0?Ay8*!of6m^a=KDYUuJ=fj*hp=ii#%B!~5HZ!>_{!+MCptIM1u*CCHC=zhr{ zuj}r&`SZ&1Z&DC8loZD?-Y|9gPUb+2$rC9q7v==GOfr`?KhjHp3R; z>z2b2hOFG@#J))r@xMY;r1Et=OMhV`47b`_sajvws~dgM3wGndm!bHBfCSz~V~V#X z-zVM}Y#M-K;6o<1;+`H(D*IGKYT%%_Dj zgY{+; zgWAu2@BUuC%M!+nmThr z5{1}?IApn*k}N#1IX1s{Tcdr;=FtS7F80;tgw6{dFZ^%S@`PBkTKhiBz5ol7HDaXV z7Wudr*HhDXdbLIvvI_RQAN$^Kv6}p_i|R>@^d&+ke)Lx#4<#|D_m$m;lj3CucR9h4`d%D&b4u(y z`d=lLLMd%R%tF7QnV+mV(<{vT{m}*;-GX!rh{8!g9t0>z>?Ax< zVPn}J1Ev5OHOXZY=;@~q4LXEfkP|nX&(3XsGW*ZVB-9z+Zb}W}AV>vA28j~X7Kht7yW zhVJKsKtdaBIHhobhdL+>kQSjgfUiv#@g}uHne3z8c1B+p0pzE)fI9ifNEn;avLP-Y zsGN(v@JRWu!pdaEMAsBm(?!4SUO{+^iTPGzb%*PE-iO|udmG-qTIRsZ;)r%H_4|ch z)k0`2C44a5d)?hpgpMD3erA_4WW|?Yp^%CNR#$p=_%}Z|!ro=Q=GTaR1xn#MX~*mb zO`i4|s0M?wS%Di$Y)E$7^Yzc*9$^;S9}nlUQXGxS`H1*$@rBQK2e0YfJqP)d8OO@b z=SaFZ9267i%k@GHzFuiwf4nc-5^*!Z?z9Mbj57UuN6c-bLkae$gtb#I2|SibUl$dK z95pRZoXISo7uW-1YO832z{U`nrid>IdQ~L760Ltx-bG&5D&TTicK0~ zXRZkeOpU`F7KNc0vb#gcg_Cl_m@kE$bp{7QWS_;oBd;|g(nP3pwzTtyjKU~iY@95^ z5On5>Hjni*IY!2sZ)=&z#}D92HcO`P1AB)c%FqDq+?xUA)g2aq=qsPtoOO9LO*;mt#U1#3<^OfdjlXY5nE03R4_i8imWyJdRRIRi4T>E^~jhbT(D-u&ux(L*<>H)z#*mz zV&h2oq8As7R@8JZS@hrmxP8KwMUFL zt`z~ycZ0BdCkl&63R!LKKb#bhm9*^G&f0Z+=>$C;hygTyvFrv$@6BF(jos6|Z|Z2# zJno3*GGDUp56rLNIEEQp$J<;@8ny2r3QcOW?$?$e%C{}|d@`UZ{ePI^h>?3MBk zU}c{hmE8loN31d4jv+CInMsMs?HK|!tEj8hm(&m(%9O9PH335!hQtGQuUjkk zOSzdKiJSf;yecY{^jIq8`cs>DLnf5Pb<-7|>usUUTLg1dNTL`Aem-}N%9*P2Z|LWZ zw=646X5M>{NijtVW9SGp7X0+3s->`1Tc*Mu*}L9q^~KDk$_nKh0o$b$^V3K)!+L2w{dY z?4lJcBIL4KEh7G%t($U1LzcjA;I1H0Fh6=u_9a!)b!3@_CPZBPEz0E~Y0cWpDa2iz zLJ$T)!#`X{#-1e5&jms+Q%=SR?uZ?|)N;O#$d>I}(bb*LNRI7rKA%My?ce|>)C|l# z``LVuesOw0R6GXjB#JKFzGhUqu8T-Sckg9C+-onvva%PTG_w3@@Ocs9J* zPF+?jhpa~2&-ZmTjyjHr)2D0A;tg+jxaOFaKF89%Phf76mQ{X3XO8I!BT;`m&Mr_k zlAuSm{eMk_sTI9uhI%A^o9MI|-!78$*ph37*I!)%PCK3F9SH8id=iN%GczqGoFj$l z{Q8q%Fqkrjn$&JL-C|pARhr(h34YwrtyKF@3t0YQ9%2`I0qcBG_^}S_eNIOfc-McN za`eGY&%XqF963>PF6NthqV#^Tp2@=L;6w3F*G;bZ zDYe&oHYs;@2-Ih==)SOf@RR>Ng^`@_Hl3;)!6LzIvqj;&m=ZzLo+tHAx5dn)w-bod z1SbiamtAO-M!(u~*WEL-Z(Um=Scf$w)0cqt!R(l5{@9TOW1ef+amFp$u|6_%Q>#9B zo_)DuM#89hsO{(B$aYQ5#KbISyA8XXiMoMROL2YW$~DL@%0?o%^%i-W962mnZ?_=4 zsbSyzbU#n^-{`A9e0G`I0KXT*1_sM;L%-imjQ;@sp ze-*Q|qDp1+kW-Bq&ia3br$?b!#b^pi5!y>`lyMs3FIi$X!Ums3@4$VU?%aEm&RGLj zNt@LY9^-OBA3)ND-4J$J23MxQhFKS7EP0rrc#FC{8<;ul zBpMVO@^?c&HrpWd6@C40jn5bkeardKxB96z%K*aS;*(Bdp{dkiBen0e7W?5y_8uO* zGyCco*>8qoh!}aUfctw!1;vtip|_0{^om|V>q!Vtu=8-hzLQ>&`j2hg1(rU_hDY>_`KmC#8FY|~ptLr5DsgPb#tZtt1UO2ojaF%d( z^e*rYnPOTEcwZBg0S~jg3%E>W9FK49LyvQq9B!1X7D#yK9Zm6t?3^Q#^=)vZMhCl# zo!w~t9p$Y2KcG<1GBFn`i!jqjVxjWu?FQe~b^-cA2e4DrQ@D2r(POy8t zE{9jnMEJ1EYh2LKsDm&QPpzZ1 zhodM^)P>h0$5n!uhwEknlL=5rto+zK1}#N=P6iTq{`% znB}H4&O3S1D{SM@spHCAzBr(H8!%xS8?| zyh{8y)&a)L)uffPZ+1JXsOTXl+WmFIrz$GW@Gw5_2_iViQMSk!=a zv&Oped>;S8&6l0_{sW5d#4qeEbSLZ>Mtl9dtimK&dCrJm)k#Z~%A@D;*KhLfh7UEb zm;nzvJ_%E;svD&pVNzS3l3FbpO?3?vOBNYch0i0)^V!^)IRPG)#{Xg|m6I9a^cir3 z$uUY|dMaKT*N!g&wQ8qH9#5oLrAq*tUN0o_#Ha^ykXztI{cI}4F6ho@17WuS%8cIv z-fvDw=^O1ZD%8l)(q7_@3(;i?xAL%RV2%9VHMf!%K&Byi1u2+@nSHB9d9jqDqon1r zc>PIKEf;%d5`#J*v5)fIZs)b7Gd#I8lbw&Z&`T2L%O_WI#|{_VUaUmIE5M@e|1|O4 z;cR~Y`)cp4w6-c*iY~-1P3^tarZsA-RpU{#R#BVUTC}JbRa=Y_YQI&32qH$6nk7gi zzo(z?KfnCykBG3SzP|8QE!#9Rxu4cx7x6pxbaA@4eGmBnlP_QN zLC^gns8**8HC^&mxO28SL4aqV2R%0f5yRXA93Ac$!Au-1Ze5u zGjB*i-Xk3*K=|Jq;4Es;=%qGz&sPERE0LAJRVkW_Wm3c*ohs~ct8|b_z*PnVq+#hf zUHnShb`WlLZ6xcXCAUy~jOV*REmK=4a}?Jby~bhnyJBgEF(1f(+3dH!SE3Nx1Yqm}}$7Qy}kMgu{RUjQDl`?r)1yWVg{ngyoI|FURh~cSAmOy(!LFY!u?PjI^lC+R{A>| zozqEmlESYe(u`x_z+SgAYyBujk+Ou$&MKE~gU>@DkYfx9|9dH)nu?q6UZY5YHf-2= zkX1WKYOONjh`x5=8EFt5w1;lnX!WJSDTfbp$&HBHl|1&X%s}XJ)UQS#eItXj$zVg0@4!qd(PmQb zhQ4uQ>IzTMz5RO#OH(K_?FX>pHftC>E2>j$yzxMZ@=c9SyCB0#hZQ<8g194Qhp$)a zQ%SQ8zHD{z8!xPouqn75GU>R)wO9!HNPt6pWd1)d{ZZ$uU-STreBw&CD>$g~=DgVM z#X0g?<#XKbLVXJ=I2#oUGLBH5%`UwDjE>|=0#|z|<#?w26_h7#?|qLY_vC^|fXI80 zdI{5PO=BW_!9zfFy1G8oe? z9nyV(4#U&}6u8BR&@x}iM-${KwOZl#k5*935;T@2VyJQw#?|$-n5E#-`yRJR0cBG7 zBvbTqXO^3e3gDh@_Y%3JvA^(G+uyo?{JIVd9OZODe3|AnM?wYpYQ^+3HQA^%_7#lC zQ;BQJo>M+?9dc)2E}$^!4{h(q$@=IV(wd4}wUTx-uEwXv@M`J#0N!mQLSpzCj#m$FHDu}{i} z)HZK{bHNuYluJZaj!qoc!RziIvKY0qtj7nFTJRJQ_I7ifUiILU(UYHy+wsHz*2X`D z2XmhY0*^#Wr2lH=ZICA1ZGbx>-yl6CL5Fzaqev6O{VUPR+|>fR7{&-pwp z(dO-k69j323VkILQUB~1^*=anD6{~V2b|6F2wd&ug8M_B0-GDRmE=)!C4V&7ef}fb zrW*9UQYZONkoJgeX;_jaB92sIY^+&3d>d3)5Fp$F*-~rj`p*M!23@c&!`$kpV6q-| zaa}DHK{|fGM=Hgwe1>)P$-JbG#H6&+;NZtONco=Lg+F-0eJs^PZRcQy-n z!1f5Hg9Lc3UWbboSV3wWjuxy|D#r6x#{I&T9D>L1e_T`8t1&xwZ^?Q^nt4f-gpl|V zo*+>$p6tr6J+AmnZ5~hbuwwqMof~Ta7kSUxPaHCYLj&fxE#F-3C=haThF49=>pqej zGc4_Egfq&=fs>IZBkGAgbc#HA;LSI8qh4B5;T@_P;$u3+VW`mSxgI@+>0Z?$sfN`v+8OXvK#N8Yp;y^Yz^-(m~Fo6RHGx@5%G6w_NWzqw_V>SwikWvdpV3$M6fSCS9v} zg$1}Je9xPILhAMz(uq^nfFgk zu_i-#DMi~w5)k3}nCS}4`oo@LZV}4;SFmVi*hO!(KM+C)kQ$p@Rl-FvlkNYsT1u3O zJoY6WG4T#|Fy6@<#&%iP|RInlbnii@W$d(S@^P}42xZtWABH+RTq0BWm1OHFFfxyHh z$_@-<9rVhoa$hW}#2?u`qfVY);(y685d7QY2e*miM{?DHXiRM=_Bs+B|2gkZ|G%SE zhA!!2^X~S}lX>NtrNZHuWKqOkR`!F;6;qD!y3>N47mfC<6WGk&6qKJKtxVtUA_Q~7 z334M{_>jF`jO+5i(6os+y6cJ-3&61rynemEqbI{8m)bherB@D5i+K0LoNA*GRoMvP zirS4VCwWbRJb%R23>Flv0fCLvAPIZc^7dbDF&`^X4XNk!zUSt|q<;B}5ONmMks7(W z6X}E*hM1~KIhbQfqw+asf+0F?sd3q%rr;DtCF4u|AH{SC*>PQKQ{5>#9KgM#`hRVm zJb2owf&A;Sh+i)`z$@hk@14>U8nqX+>BWj%%FTT4!q*-F{D->s7pjn@lhwA`2c*-Z z+S?>5BM>V}$Q0Azg1lA#JNuP|GecABNA)GLf5w$nx}SCyG$WgGeJA_~@5GrbfjfC` zysPPRB1Qevk<&9Jh$Rl-{kod@#|l#tp_U5z+M(R?^r@d-jxNvvvGIgA*HwViIctui z8ho7#8?5`pHge;rlqNUBGuT6(;#2X7?fB=}GSb-{QoDBjFb4Tb8q_kqyKI zE=tY=oYuB<2WNU3nnm3S+smwcJ4%_oDp6549{xh}S==VQtHfKMnvu8C-K%4rAWtiU z&b&eD-^3i*hGvCT%TeLd;~3Fxqmj0n#3%lrFJEMp4BVUmG-#`;s)XZXeDd6&%pc6x zw8^6mpWguzKdeO6%Kf-oabxpx7^+2nq}kEI7WH(7-~Lg=(GcNM!vM{ zy)MN3cosIVxzvH^A+Nnb_&KWDU-~s^E?IndCNdwXscTia*>q|16jOX*1>xooo^q;DID#hTL6BV zbX^qT^uW($bAlVY8M^z86uLs@OL~)vBMn*W-B|QFPgx;+Qr3EvX-AWyWr3Z24+P@J zCqJ(d+i!c3pTWz57Ghz6Ri;?*>iX?2!Qi7s}`iW%LrDj$Pm>}v(Ne13*AJI6T*V^-_eTR z-UB07hMG#dGaPgJFZjD6*K0%@|^Bl&sYt z{XMY?C02vwCy);Xs^5(My!C7AQ?EsJW-E_*=dk;{x+iO?)H$W8fa@SoSAu<*kNZ^6 zSSAyNr6A+fk_hi*R~7G4}Zno<1a6b z`pWcJW+4{FuN5XDK_#3Z0Hd(a;MTQ!}F-;IXB!1d0iMk7OUjlYeg1&P7($r0FX+?hNu!?94X;*0YiX=H@hp}Eyls}N5^jjLc zUe?O@?U}Sj+B?FfX}*x0@t*ySi-qEXVkFu^prf|4qobzt965$J5zKd6*2lHhs#(}y zTtwIYHhUjtqQ*fejTK2KKkzb^z5Mb&Ssin?Ld_#xb~R|>d>c@xz>Gj_2nYugr@?Baiy3zwXW1Z#kKpPez9WwxzY?H<=d%r!v5$Ttb(>0 zLEuV?Xt}@ER&+GJ^YK=Vh^h{@I5awSulrEbZW%oo6uP%+wAY;{JpnUxIv$0O-v!^=^r#MmFOza%OrCVB%fwSj zZ=;agB@U8v-V|p{!h0^T{sh6O*jC_fuOA>_HK8XH#UKMG@OPd69t}G}_+ds{{jh@( zxQ6@Ydo5-bC8}rg-6tJW@uZ&7nOq=8gyz41m4`saz)@GMh*qimAbY`hWSpw`k-fy;e(xpFJhH+l}qkKQfg-LMyJJxW7!I7|eJ$Ki@g#Fx~~QaruK z!^S`O&l_^1>)V+Czd2$1`^@!s`*6b_8;-_nJdD)VpDl&dx3V`EU!8Z*ao#lgBX<9m zX23HbN2=rnwxx9SA{0oniY~vru*-fHuTP9tB}iN@!=q8XNO%Z~OwYcCB1H31o@-Cx zT4@}dtMKWdfyoejBs?mI?ls|=&(5yl{O+#B*d~L1wbI=DeBa$k<+#}chrY08-f}AW za>jtVS+sYa3uDgxGL#kkk_KNF38#h~-vi(|dLhR&)o*Ff_um`szehSE9Mw*RRFc&` za)a;!YVx03UfgFg12!1o*$akr0hRud5xTT=qZK?#L0LJLrdTB5Ifc*7Y+Pg&1=0sr z-BjJbsU?V=NBhy+Z0`y#H3`_QVeet=q6Zw@hz6rSPR_*$dBy2>d*Qkn6#CZ9fb7-O zmGr2vk^~`hkHaqy?a6Cy_RyK@#3dw<`UeI8OKBI6`;6Ql>cR&TF3PY!;L%)!>z zMA(MfjMh7!wGx7!R}Bku*L(fcvSBZ084ejdt?=D>{Y`1>YM>wbi|H-WxWB+zH9eHs zrDE75ieoUiq%N?xCo!$sT*v1r04w6q0{JDn{{ z{Tx)AkqA_<%eYp-*gXHhr{dd=T|upJ&b&88PGMCL^1BPH$+c6e*;<;TwTUs8^mILE zbcu6&tyDCbk1dr8qE43n!+1@i^HTe2lay`9fnvAk)7JAUeXQg(P+W^jT+`j74|J)P zmLmP}cQ_Tl3S|~oRj^#6jV069E=IP~1;|((M6h+7eq_>rftTxUoqJXBU|2GxA>+K zh}G12*OoY$PVWl(yBEsGuvNPDTgs^R#9UI!B)rySZNE(e<;yg?+Y6hUp=VDU356&%KLS{S3JAI@>kKIe#YKz?mlLhaOE%Z- z7%l3YT&l-!pSgBkL2(doW)Ix^xh2``bp{V3fbLE^AsOIv>HB6sx4u(!>Zw`bdH68Y zfp-`;{Y8B5@RrjVyp2%H38B@IlixPWh|>WndE`-M`9soBt6IDR@(W^_Qi@avbtg!2 zLYyjwr$6)#XS}m>%-Heg)8eIZLu^i(u^C$iK>wSt6l)&k0WGtj;71tUf9tk=f^*L| z40XW)?dun7_SC_b#_7`+nTG2VrLmv9+-x+rqZL-`x-b3vybR%Pj7jo(V8P8mvX}vO zXqqfzPBNd5(2$~wQQjX}D^WW_32kz04c1^)uu;pKSEgVQHWFHMczkJ;G>B27?KjW3 z#;D21&;RmvZu^v726hB1PTOBLEt~m9Y6B4NeSS)Z(u zEr#%D${@naVo~en<%$%f+lYck4`as3;@1X(BOftt|6cWQqxK2ha6=<~|L+z%%dT24 z7Gbo~UYszLDvaPfgbN?YJLML4AJg~Bw?S#G0)E~OKF8`?1_XBe-iLktx z3S5Qqq({|R3JhUy_sjOXG}c+JDd-9PXSnl(j^p`{?NO#X_%v~$F;}VfP4`;}XC%`o zX@m6!Xw6cFZfmIkfB!^Y`FdACmghqLlgohgmlICZ}%A@a= z-n1r8s*&nspv-1&TLB9-n`TUjnlqyp+UiJ6!bPhPAJkRh=jrBoDekKFZe5Q`+qJ@hmTK{q8R6a}`|eiW{W*vSGF(K43XXB!TW+ zkY!Di(ePZ(<7xFgI|My7V&kds?{7X3zEnUYVSo$rdVp^tggd-*JA+WE|prA-7;+!-p%m$nMM(5K}A({Sl&@q#A3e$iQq88_{j5|Xk$ zWdp(Qvi&~HVof~(EQQzA%4fqK#Lx1jWwnQ|9y~NQ@>ocmQ2e*Y#aS8*GvfXNb)XJX zakJxdq;S0aJ#imp(;Lt1tA8~WqlerSUF$FHIsivJ;8EG)SlF`->T6y<*$t! zX36?eq5u8Ux%>xe*E1yc&c0y$OVf;jEG`IjoMc-R-sY__d{xv6U^SS$#_e#u7cIZ` z-NeE-hi_Ezdto|L3fzW>G@EE1Q?puAP*CXgYS9%~ePx>w_efZIq|%euXx~A9G5sl8 zqO*QSz=B^!W2e0EjjLrhl@w{cjq#rk%T^|@fJHnRTp=JweOyk8~tqkyH5XVmdqr)T<*`p@bIeGaZD$nQ>T5_*9cEd zSuR}QZ+B9-4i}h^B(-otirnoR8WfwkNlONdqK=Y57Vl6uB;ob$^2J7a@kw;DV~>(k ztG)W$b)L>10^z6d2GIMwH>BXCS>I;X0?g;(bq$1ClgAnFYiRClmH#eF7bZj3y{rF& zZ1KD!>T)LE3S2n_mz*_K1Yz^5_;+sk7}_hg>9%ezV^AVC&Y=E%U~z{ppMf)YT)y4$ z;DSu4XSnjqBfOZ$Qgo4;zyS%~=$iZy;1R5V$e24>DZa?P5)A-<6@hq?C7lO=76@VhwKHUlH+% zZ?MC9nnq{n%{bGIlCG~i8Ut%{%cYeZE_`6%;>>fI6H?YzZh)Y%7k|7~=Vr0dTvz_{ zQ{-%+#xeu9k2gK&wf1Fh2F5$*-w1q|z4~)p1_P>5jIOiKY;j^oC~2yz4?Zv>p*({H zo_?~!OTVP#uEv8MTMn^UR^xY$r8iJlel&2$|X0{gq4vLsTvPe_UoFl9y>*1As(EYgcO)qUklqI?|x1!oc5$+v91cteY3U5##S8F@We#^LDF>S-qK*CD&o1~ zpjQjIE2B}#7`71u$j0yC12P#7wj)(b2aa4SV-DtwT;EQ6#Ls)@8j_kq>iC~k7Z-yU z7xwg&r)LjKyug#iNL1Kx?&4Letg5%n1fXnSU>myvE&xwlADOtgxPtv>{|;Ns+8QPJ zj2%%Z=I=*JripXBd2|g-SDqmj8bcHt1_T|5Zvw*r0;YI!^K?h8w9@Eeyiguw=z(7& z;#UA`>AZqYx|P#Y%I5h?kKX?q1iW~8TtRz*i_TM@RZ05H4}Q$kee>^MPCOpJkHfmp z;-ZR*lwWtY1h3;Fjl)#ab9U?PJ9AHW18jkIJ2I!a`GOeJWQk;1p0Kd<=Sf1efGi&~ zMnPI4wKwxqLAHsv50lyQGC0Ay;*AioERgU+6)P08haV}76hQPE_XeRwbDz#PunW7q zDP}5Tiy`&*_FA*2=q(2+wU4<@L=yyEJgUV7K}8TiwI8a}MoaEkqZ!{Wux delta 24442 zcmXtfbx<757wzIM!QCAKiv;)J9vp%@1a}{t;O-vWAvi&UOVAM9f;$A4g}2}Dy;rq= zOl@^d^>p99_ndQ2?+wFbkH93F131*wa;{?JkL=+Se*f>{1LnL-BB%s3HUdmK; z5a@u6o0}U1T5bKrwn7CD=;|h(smYkG(l2?s$e%T^w|pe-=)3G_SULLB&fe^q&*sja zrjVJN3lB=2uv}`5ySi#B1i|jV&zJxOWn~fTrXviPR4kq_rHxaCebYA-FH-yO*U|}+ zWKt;QI=&S|5AFZX#>UP*?#G4}zf;rF^k+(6$k0ZX)2+imrUlT)$jrw_{qw%=fkSWYP z{_o}KOH*50p#A;*K7ocm0uc%~s}2YS8rc7JvNj>K1JQto21bcsuDXO~OZOKLi%Tt1$v!5i8#X~GmtU59^bi9+$#7RU@-(NF~TVL{UNtpPZ5J!t3LeCm) zs%~@1uVU@~9AFevYWOZ)vEwFnpRh~)R)BQqy^lPajt8cK?My*W=lC;nSmF+i6uYAY zfBY!E#>MnC*;|{xqU}-vvLxcw)k%ZQ+lMDOkR|%{wHCh$?Uoh{44h;{5;WG>m%kHV zyInhr5NgYe_`EyC!|Pu;`}|6nbRxLDcYoOAqpd=oP^u87hObERk?hZ5uM7Zk21KBn zkFV*=;(hbmmW@plW}DGfd?@6g+S$-nZD2yiu0<<{KaAD?dP3z%1Z!i=ZhDRpyE!T~ za#*jocD7ffUH8a|gu!4@+CwxJ1};bfSpopWsumES0spP7jj+u2rykz>q?BH-5ya?w z{3_hMwv=$-We-xnD8Kj;^JWJaI-m8CTI^43aA1~-QYsK_ZIPC%U&z}3JH!=nAY+w& zVhyb`%>(t>}cz{{^eI^`c0(RFkL6ZY%jh+#f@zIqf<7+ z_#T&)wY4b`;`knp)zmM!&~WwW?QQC%Rt!noHs?;J?Y^WPu#zdYyzD5xHCY+*%u>;=hspebxai!FfFbwJ0$Lb&s*+S{~47;lnn2{+E@&XRXHo=SFX-E_>UJzo@QtUcF59n7+u zt~*;?y2@foCEk`qUs?h+j69+J+E;oQIK6*Zp`Q?tS;)*zl1`N)qZ9SCV!pB z-CaCr4ud~%g_}cY5NwJ0xB^+X(0_;Kc6imVva0w6pf|lZC+|NH`O#_)eNg5<74xUL zeGJfX7|A!BmVZ4{Og0@7`P}GrpY|>)Z^4R08H@cQF193#030c2?4T{O7JZDA z6b%XQ32aFdD=WmX8LG57m)sW&4&1{#<&Ec6pSl#z!39aWCD$vz`BMeDrF565=KC19 zerro}qvyo|2>@x+yUvo*_RgDC&HZ=Qd$hm2&M&Xs#$-SH`BV^aG$F5ltG%SoaqsOB z!1!Bv`!?#pq$1?O#^`X^tcT=!(}fJ0si65zNmO8qt6o!@1tb7n7!{qt_HFf

    4Gn6xf76zO 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 MbWL@t@3=($Ke;pKV*mgE diff --git a/yarn.lock b/yarn.lock index aaf16bf1b3..aa05219b9f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3495,14 +3495,14 @@ __metadata: languageName: node linkType: hard -"oldschooljs@npm:^2.5.9": - version: 2.5.9 - resolution: "oldschooljs@npm:2.5.9" +"oldschooljs@npm:^2.5.10": + version: 2.5.10 + resolution: "oldschooljs@npm:2.5.10" dependencies: deepmerge: "npm:4.3.1" e: "npm:^0.2.33" node-fetch: "npm:2.6.7" - checksum: 10c0/6e9f44153a876c2fe389a5c65d437b9526d0cfb0a574696823144f59dee84e36330dcb88a76fdaa3199f9c00c7ba9d57559bf6d6b511e792be90e0783091eac1 + checksum: 10c0/c8ff7fcaee60ebcc27c918fb315e226c3b0de93d45d2779733b6530156761a94aca760f7b247c8d4812cc46b865bc9a9916359be0fee996e018c37c927ec2696 languageName: node linkType: hard @@ -4115,7 +4115,7 @@ __metadata: node-cron: "npm:^3.0.3" node-fetch: "npm:^2.6.7" nodemon: "npm:^3.1.4" - oldschooljs: "npm:^2.5.9" + oldschooljs: "npm:^2.5.10" p-queue: "npm:^6.6.2" piscina: "npm:^4.6.1" prettier: "npm:^3.3.2" From 6b02a88d7ccdca31a82ec71526c363b0a248c450 Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Wed, 10 Jul 2024 12:19:02 +1000 Subject: [PATCH 008/145] Add item switches to fix dt2 rings --- src/lib/data/itemAliases.ts | 36 ++++++++++++++++++---- tests/unit/itemSwitches.test.ts | 25 +++++++++++++++ tests/unit/snapshots/itemSwitches.OSB.json | 18 +++++++++++ 3 files changed, 73 insertions(+), 6 deletions(-) create mode 100644 tests/unit/itemSwitches.test.ts create mode 100644 tests/unit/snapshots/itemSwitches.OSB.json diff --git a/src/lib/data/itemAliases.ts b/src/lib/data/itemAliases.ts index 33aa46bc96..ec44fda255 100644 --- a/src/lib/data/itemAliases.ts +++ b/src/lib/data/itemAliases.ts @@ -1,11 +1,10 @@ -import { modifyItem } from '@oldschoolgg/toolkit'; -import { Items } from 'oldschooljs'; +import { deepMerge, modifyItem } from '@oldschoolgg/toolkit'; +import { omit } from 'lodash'; +import { EItem, Items } from 'oldschooljs'; import { allTeamCapes } from 'oldschooljs/dist/data/itemConstants'; import { itemNameMap } from 'oldschooljs/dist/structures/Items'; import { cleanString } from 'oldschooljs/dist/util/cleanString'; -import { resolveItems } from 'oldschooljs/dist/util/util'; - -import { getOSItem } from '../util/getOSItem'; +import { getItemOrThrow, resolveItems } from 'oldschooljs/dist/util/util'; export function setItemAlias(id: number, name: string | string[], rename = true) { const existingItem = Items.get(id); @@ -386,7 +385,32 @@ for (const item of allTeamCapes) { modifyItem(item.id, { price: 100 }); - if (getOSItem(item.id).price !== 100) { + if (getItemOrThrow(item.id).price !== 100) { throw new Error(`Failed to modify price of item ${item.id}`); } } + +export const itemDataSwitches = [ + { + from: 25488, + to: EItem.BELLATOR_RING + }, + { + from: 25486, + to: EItem.MAGUS_RING + }, + { + from: 25487, + to: EItem.VENATOR_RING + }, + { + from: 25485, + to: EItem.ULTOR_RING + } +]; + +for (const items of itemDataSwitches) { + const from = getItemOrThrow(items.from); + const to = getItemOrThrow(items.to); + modifyItem(to.id, deepMerge(omit(to, 'id'), omit(from, 'id'))); +} diff --git a/tests/unit/itemSwitches.test.ts b/tests/unit/itemSwitches.test.ts new file mode 100644 index 0000000000..39882024bb --- /dev/null +++ b/tests/unit/itemSwitches.test.ts @@ -0,0 +1,25 @@ +import { writeFileSync } from 'node:fs'; +import { EquipmentSlot } from 'oldschooljs/dist/meta/types'; +import { getItemOrThrow } from 'oldschooljs/dist/util'; +import { expect, test } from 'vitest'; + +import { BOT_TYPE } from '../../src/lib/constants'; +import { itemDataSwitches } from '../../src/lib/data/itemAliases'; +import { itemNameFromID } from '../../src/lib/util/smallUtils'; + +test('Item Switches', () => { + writeFileSync( + `tests/unit/snapshots/itemSwitches.${BOT_TYPE}.json`, + `${JSON.stringify( + itemDataSwitches.map(a => ({ + from: `${itemNameFromID(a.from)} [${a.from}]`, + to: `${itemNameFromID(a.to)} [${a.to}]` + })), + null, + ' ' + )}\n` + ); + expect(getItemOrThrow('Ultor ring').equipment?.melee_strength).toBe(12); + expect(getItemOrThrow('Ultor ring').id).toBe(25485); + expect(getItemOrThrow('Ultor ring').equipment?.slot).toBe(EquipmentSlot.Ring); +}); diff --git a/tests/unit/snapshots/itemSwitches.OSB.json b/tests/unit/snapshots/itemSwitches.OSB.json new file mode 100644 index 0000000000..5ea6ab447e --- /dev/null +++ b/tests/unit/snapshots/itemSwitches.OSB.json @@ -0,0 +1,18 @@ +[ + { + "from": "Bellator ring [25488]", + "to": "Bellator ring [28316]" + }, + { + "from": "Magus ring [25486]", + "to": "Magus ring [28313]" + }, + { + "from": "Venator ring [25487]", + "to": "Venator ring [28310]" + }, + { + "from": "Ultor ring [25485]", + "to": "Ultor ring [28307]" + } +] From 1bba8859ae452da8b64f910f3f98d07ebc80d0a3 Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Thu, 11 Jul 2024 06:12:37 +1000 Subject: [PATCH 009/145] Cleanup toolkit imports --- package.json | 2 +- src/lib/MUser.ts | 2 +- src/lib/PaginatedMessage.ts | 2 +- src/lib/bankImage.ts | 2 +- src/lib/colosseum.ts | 4 ++-- src/lib/events.ts | 2 +- src/lib/globals.ts | 2 +- src/lib/minions/functions/removeFoodFromUser.ts | 2 +- src/lib/party.ts | 2 +- src/lib/structures/Bank.ts | 2 +- src/lib/util/addSubTaskToActivityTask.ts | 2 +- src/lib/util/interactionReply.ts | 2 +- src/lib/util/migrateUser.ts | 2 +- src/lib/util/parseStringBank.ts | 2 +- src/lib/util/userEvents.ts | 2 +- src/mahoji/commands/ge.ts | 2 +- .../lib/abstracted_commands/cancelGEListingCommand.ts | 2 +- src/mahoji/mahojiSettings.ts | 2 +- yarn.lock | 8 ++++---- 19 files changed, 23 insertions(+), 23 deletions(-) diff --git a/package.json b/package.json index 6f15f939cd..5ee0da12ee 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ }, "dependencies": { "@napi-rs/canvas": "^0.1.53", - "@oldschoolgg/toolkit": "git+https://github.com/oldschoolgg/toolkit.git#62f9f71274c6d36db6c56a2228ae3df9ab090dbd", + "@oldschoolgg/toolkit": "git+https://github.com/oldschoolgg/toolkit.git#21649ba236026062ea54cd030ff9060bcefa624a", "@prisma/client": "^5.16.1", "@sapphire/snowflake": "^3.5.3", "@sapphire/time-utilities": "^1.6.0", diff --git a/src/lib/MUser.ts b/src/lib/MUser.ts index 1135e1fa83..e7a266d116 100644 --- a/src/lib/MUser.ts +++ b/src/lib/MUser.ts @@ -1,5 +1,5 @@ import { mentionCommand } from '@oldschoolgg/toolkit'; -import { UserError } from '@oldschoolgg/toolkit/dist/lib/UserError'; +import { UserError } from '@oldschoolgg/toolkit'; import type { GearSetupType, Prisma, User, UserStats, xp_gains_skill_enum } from '@prisma/client'; import { userMention } from 'discord.js'; import { calcWhatPercent, objectEntries, percentChance, sumArr, uniqueArr } from 'e'; diff --git a/src/lib/PaginatedMessage.ts b/src/lib/PaginatedMessage.ts index 678cbf969d..8e572f15f6 100644 --- a/src/lib/PaginatedMessage.ts +++ b/src/lib/PaginatedMessage.ts @@ -1,4 +1,4 @@ -import { UserError } from '@oldschoolgg/toolkit/dist/lib/UserError'; +import { UserError } from '@oldschoolgg/toolkit'; import type { BaseMessageOptions, ComponentType, MessageEditOptions, TextChannel } from 'discord.js'; import { ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js'; import { Time } from 'e'; diff --git a/src/lib/bankImage.ts b/src/lib/bankImage.ts index 57457c9bd4..9ade1297ba 100644 --- a/src/lib/bankImage.ts +++ b/src/lib/bankImage.ts @@ -4,7 +4,7 @@ import * as path from 'node:path'; import type { SKRSContext2D } from '@napi-rs/canvas'; import { Canvas, GlobalFonts, Image, loadImage } from '@napi-rs/canvas'; import { cleanString, formatItemStackQuantity, generateHexColorForCashStack } from '@oldschoolgg/toolkit'; -import { UserError } from '@oldschoolgg/toolkit/dist/lib/UserError'; +import { UserError } from '@oldschoolgg/toolkit'; import { AttachmentBuilder } from 'discord.js'; import { chunk, randInt, sumArr } from 'e'; import fetch from 'node-fetch'; diff --git a/src/lib/colosseum.ts b/src/lib/colosseum.ts index 7badf5c38a..26022a0cd6 100644 --- a/src/lib/colosseum.ts +++ b/src/lib/colosseum.ts @@ -1,6 +1,6 @@ import { exponentialPercentScale, formatDuration, mentionCommand } from '@oldschoolgg/toolkit'; -import { GeneralBank, type GeneralBankType } from '@oldschoolgg/toolkit/dist/lib/GeneralBank'; -import { UserError } from '@oldschoolgg/toolkit/dist/lib/UserError'; +import { UserError } from '@oldschoolgg/toolkit'; +import { GeneralBank, type GeneralBankType } from '@oldschoolgg/toolkit'; import { Time, calcPercentOfNum, diff --git a/src/lib/events.ts b/src/lib/events.ts index 2eb6828f55..c3141b9b2e 100644 --- a/src/lib/events.ts +++ b/src/lib/events.ts @@ -1,5 +1,5 @@ import { channelIsSendable, mentionCommand } from '@oldschoolgg/toolkit'; -import { UserError } from '@oldschoolgg/toolkit/dist/lib/UserError'; +import { UserError } from '@oldschoolgg/toolkit'; import type { BaseMessageOptions, Message, TextChannel } from 'discord.js'; import { ButtonBuilder, ButtonStyle, EmbedBuilder, bold } from 'discord.js'; import { Time, isFunction, roll } from 'e'; diff --git a/src/lib/globals.ts b/src/lib/globals.ts index 8d3529dbd3..2a4236e185 100644 --- a/src/lib/globals.ts +++ b/src/lib/globals.ts @@ -1,5 +1,5 @@ import { isMainThread } from 'node:worker_threads'; -import { TSRedis } from '@oldschoolgg/toolkit/dist/lib/TSRedis'; +import { TSRedis } from '@oldschoolgg/toolkit/TSRedis'; import { PrismaClient } from '@prisma/client'; import { PrismaClient as RobochimpPrismaClient } from '@prisma/robochimp'; diff --git a/src/lib/minions/functions/removeFoodFromUser.ts b/src/lib/minions/functions/removeFoodFromUser.ts index a9174c2e4a..9c621544ae 100644 --- a/src/lib/minions/functions/removeFoodFromUser.ts +++ b/src/lib/minions/functions/removeFoodFromUser.ts @@ -1,4 +1,4 @@ -import { UserError } from '@oldschoolgg/toolkit/dist/lib/UserError'; +import { UserError } from '@oldschoolgg/toolkit'; import { objectEntries, reduceNumByPercent } from 'e'; import type { Bank } from 'oldschooljs'; import { itemID } from 'oldschooljs/dist/util'; diff --git a/src/lib/party.ts b/src/lib/party.ts index ee54831090..415abffb0e 100644 --- a/src/lib/party.ts +++ b/src/lib/party.ts @@ -1,5 +1,5 @@ import { makeComponents } from '@oldschoolgg/toolkit'; -import { UserError } from '@oldschoolgg/toolkit/dist/lib/UserError'; +import { UserError } from '@oldschoolgg/toolkit'; import type { TextChannel } from 'discord.js'; import { ButtonBuilder, ButtonStyle, ComponentType, InteractionCollector, userMention } from 'discord.js'; import { Time, debounce, noOp } from 'e'; diff --git a/src/lib/structures/Bank.ts b/src/lib/structures/Bank.ts index 97a5b71144..adc1d72763 100644 --- a/src/lib/structures/Bank.ts +++ b/src/lib/structures/Bank.ts @@ -1,4 +1,4 @@ -import { GeneralBank, type GeneralBankType } from '@oldschoolgg/toolkit/dist/lib/GeneralBank'; +import { GeneralBank, type GeneralBankType } from '@oldschoolgg/toolkit'; import type { DegradeableItem } from '../degradeableItems'; import { degradeableItems } from '../degradeableItems'; diff --git a/src/lib/util/addSubTaskToActivityTask.ts b/src/lib/util/addSubTaskToActivityTask.ts index 21c61a6342..e717d57794 100644 --- a/src/lib/util/addSubTaskToActivityTask.ts +++ b/src/lib/util/addSubTaskToActivityTask.ts @@ -1,4 +1,4 @@ -import { UserError } from '@oldschoolgg/toolkit/dist/lib/UserError'; +import { UserError } from '@oldschoolgg/toolkit'; import { activitySync } from '../settings/settings'; import type { ActivityTaskData, ActivityTaskOptions } from '../types/minions'; diff --git a/src/lib/util/interactionReply.ts b/src/lib/util/interactionReply.ts index c737c6a690..021ff723f7 100644 --- a/src/lib/util/interactionReply.ts +++ b/src/lib/util/interactionReply.ts @@ -1,4 +1,4 @@ -import { UserError } from '@oldschoolgg/toolkit/dist/lib/UserError'; +import { UserError } from '@oldschoolgg/toolkit'; import type { ButtonInteraction, ChatInputCommandInteraction, diff --git a/src/lib/util/migrateUser.ts b/src/lib/util/migrateUser.ts index eb7a5c3d41..d759cc7c95 100644 --- a/src/lib/util/migrateUser.ts +++ b/src/lib/util/migrateUser.ts @@ -1,4 +1,4 @@ -import { UserError } from '@oldschoolgg/toolkit/dist/lib/UserError'; +import { UserError } from '@oldschoolgg/toolkit'; import { cancelUsersListings } from '../../mahoji/lib/abstracted_commands/cancelGEListingCommand'; diff --git a/src/lib/util/parseStringBank.ts b/src/lib/util/parseStringBank.ts index 4ba8b87b1a..fe9933e470 100644 --- a/src/lib/util/parseStringBank.ts +++ b/src/lib/util/parseStringBank.ts @@ -1,4 +1,4 @@ -import { evalMathExpression } from '@oldschoolgg/toolkit/dist/util/expressionParser'; +import { evalMathExpression } from '@oldschoolgg/toolkit'; import { notEmpty } from 'e'; import { Bank, Items } from 'oldschooljs'; import type { Item } from 'oldschooljs/dist/meta/types'; diff --git a/src/lib/util/userEvents.ts b/src/lib/util/userEvents.ts index 3ace8beb6f..a80e986844 100644 --- a/src/lib/util/userEvents.ts +++ b/src/lib/util/userEvents.ts @@ -1,4 +1,4 @@ -import { dateFm } from '@oldschoolgg/toolkit/dist/util/misc'; +import { dateFm } from '@oldschoolgg/toolkit'; import type { Prisma, UserEvent, xp_gains_skill_enum } from '@prisma/client'; import { UserEventType } from '@prisma/client'; diff --git a/src/mahoji/commands/ge.ts b/src/mahoji/commands/ge.ts index b116e852d3..f5b39721c7 100644 --- a/src/mahoji/commands/ge.ts +++ b/src/mahoji/commands/ge.ts @@ -1,6 +1,6 @@ import type { CommandRunOptions } from '@oldschoolgg/toolkit'; import type { CommandOption } from '@oldschoolgg/toolkit'; -import { evalMathExpression } from '@oldschoolgg/toolkit/dist/util/expressionParser'; +import { evalMathExpression } from '@oldschoolgg/toolkit'; import type { GEListing, GETransaction } from '@prisma/client'; import { ApplicationCommandOptionType } from 'discord.js'; import { sumArr, uniqueArr } from 'e'; diff --git a/src/mahoji/lib/abstracted_commands/cancelGEListingCommand.ts b/src/mahoji/lib/abstracted_commands/cancelGEListingCommand.ts index 9b62654073..e8a1dd14ba 100644 --- a/src/mahoji/lib/abstracted_commands/cancelGEListingCommand.ts +++ b/src/mahoji/lib/abstracted_commands/cancelGEListingCommand.ts @@ -1,4 +1,4 @@ -import { UserError } from '@oldschoolgg/toolkit/dist/lib/UserError'; +import { UserError } from '@oldschoolgg/toolkit'; import { Bank } from 'oldschooljs'; import { GrandExchange } from '../../../lib/grandExchange'; diff --git a/src/mahoji/mahojiSettings.ts b/src/mahoji/mahojiSettings.ts index 1e6a3284d6..4235a76244 100644 --- a/src/mahoji/mahojiSettings.ts +++ b/src/mahoji/mahojiSettings.ts @@ -1,4 +1,4 @@ -import { evalMathExpression } from '@oldschoolgg/toolkit/dist/util/expressionParser'; +import { evalMathExpression } from '@oldschoolgg/toolkit'; import type { Prisma, User, UserStats } from '@prisma/client'; import { isFunction, objectEntries, round } from 'e'; import { Bank } from 'oldschooljs'; diff --git a/yarn.lock b/yarn.lock index aa05219b9f..94a6b28100 100644 --- a/yarn.lock +++ b/yarn.lock @@ -679,9 +679,9 @@ __metadata: languageName: node linkType: hard -"@oldschoolgg/toolkit@git+https://github.com/oldschoolgg/toolkit.git#62f9f71274c6d36db6c56a2228ae3df9ab090dbd": +"@oldschoolgg/toolkit@git+https://github.com/oldschoolgg/toolkit.git#21649ba236026062ea54cd030ff9060bcefa624a": version: 0.0.24 - resolution: "@oldschoolgg/toolkit@https://github.com/oldschoolgg/toolkit.git#commit=62f9f71274c6d36db6c56a2228ae3df9ab090dbd" + resolution: "@oldschoolgg/toolkit@https://github.com/oldschoolgg/toolkit.git#commit=21649ba236026062ea54cd030ff9060bcefa624a" dependencies: decimal.js: "npm:^10.4.3" deepmerge: "npm:4.3.1" @@ -695,7 +695,7 @@ __metadata: peerDependencies: discord.js: ^14.15.3 oldschooljs: ^2.5.9 - checksum: 10c0/d23b39e8dfd6773b56ca1339a5a08c75b1173c4aa9f9d9140ee5339c131a5d111dca5f631992c973e406a4154eba82c5de348f7ad2003d3d08cf6f02ed12a08d + checksum: 10c0/23688df68408a728a0cd7e965a37b2638add3169024a1a800c48c9dc288e8392f15015fae482dd773794070fb5e607151044fc311928e1e7f3d3e17439ba2ef2 languageName: node linkType: hard @@ -4090,7 +4090,7 @@ __metadata: dependencies: "@biomejs/biome": "npm:^1.8.3" "@napi-rs/canvas": "npm:^0.1.53" - "@oldschoolgg/toolkit": "git+https://github.com/oldschoolgg/toolkit.git#62f9f71274c6d36db6c56a2228ae3df9ab090dbd" + "@oldschoolgg/toolkit": "git+https://github.com/oldschoolgg/toolkit.git#21649ba236026062ea54cd030ff9060bcefa624a" "@prisma/client": "npm:^5.16.1" "@sapphire/snowflake": "npm:^3.5.3" "@sapphire/time-utilities": "npm:^1.6.0" From 3e57a8bc563760043ec37d42cff6f9b7f22c25dd Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Fri, 12 Jul 2024 05:35:41 +1000 Subject: [PATCH 010/145] Remove unused subcommands from admin command --- src/mahoji/commands/admin.ts | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/src/mahoji/commands/admin.ts b/src/mahoji/commands/admin.ts index 8425910bef..024f7e5b03 100644 --- a/src/mahoji/commands/admin.ts +++ b/src/mahoji/commands/admin.ts @@ -422,11 +422,6 @@ export const adminCommand: OSBMahojiCommand = { name: 'reboot', description: 'Reboot the bot.' }, - { - type: ApplicationCommandOptionType.Subcommand, - name: 'debug_patreon', - description: 'Debug patreon.' - }, { type: ApplicationCommandOptionType.Subcommand, name: 'sync_commands', @@ -495,11 +490,6 @@ export const adminCommand: OSBMahojiCommand = { name: 'sync_roles', description: 'Sync roles' }, - { - type: ApplicationCommandOptionType.Subcommand, - name: 'sync_patreon', - description: 'Sync patreon' - }, { type: ApplicationCommandOptionType.Subcommand, name: 'badges', @@ -640,11 +630,6 @@ export const adminCommand: OSBMahojiCommand = { } ] }, - // { - // type: ApplicationCommandOptionType.Subcommand, - // name: 'wipe_bingo_temp_cls', - // description: 'Wipe all temp cls of bingo users' - // }, { type: ApplicationCommandOptionType.Subcommand, name: 'give_items', @@ -678,14 +663,12 @@ export const adminCommand: OSBMahojiCommand = { }: CommandRunOptions<{ reboot?: {}; shut_down?: {}; - debug_patreon?: {}; sync_commands?: {}; item_stats?: { item: string }; sync_blacklist?: {}; loot_track?: { name: string }; cancel_task?: { user: MahojiUserOption }; sync_roles?: {}; - sync_patreon?: {}; badges?: { user: MahojiUserOption; add?: string; remove?: string }; bypass_age?: { user: MahojiUserOption }; command?: { enable?: string; disable?: string }; @@ -693,7 +676,6 @@ export const adminCommand: OSBMahojiCommand = { bitfield?: { user: MahojiUserOption; add?: string; remove?: string }; ltc?: { item?: string }; view?: { thing: string }; - wipe_bingo_temp_cls?: {}; give_items?: { user: MahojiUserOption; items: string; reason?: string }; }>) => { await deferInteraction(interaction); From 699f47f137b2b8fb1d305cf57698e890957918c0 Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Fri, 12 Jul 2024 05:35:59 +1000 Subject: [PATCH 011/145] Reduce length of logging on tasks --- src/lib/Task.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/Task.ts b/src/lib/Task.ts index 88eeda2ba4..374ffc4982 100644 --- a/src/lib/Task.ts +++ b/src/lib/Task.ts @@ -237,7 +237,7 @@ const ActivityTaskOptionsSchema = z.object({ async function completeActivity(_activity: Activity) { const activity = convertStoredActivityToFlatActivity(_activity); - debugLog(`Attemping to complete activity ID[${activity.id}] TYPE[${activity.type}] USER[${activity.userID}]`); + debugLog(`Attemping to complete activity ID[${activity.id}]`); if (_activity.completed) { throw new Error('Tried to complete an already completed task.'); @@ -263,7 +263,7 @@ async function completeActivity(_activity: Activity) { } finally { modifyBusyCounter(activity.userID, -1); minionActivityCacheDelete(activity.userID); - debugLog(`Finished completing activity ID[${activity.id}] TYPE[${activity.type}] USER[${activity.userID}]`); + debugLog(`Finished completing activity ID[${activity.id}]`); } } From 916c7a4db5653bd67a6288f05496988f0c6db7f5 Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Fri, 12 Jul 2024 05:36:14 +1000 Subject: [PATCH 012/145] Update toolkit --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 5ee0da12ee..12f92c1863 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ }, "dependencies": { "@napi-rs/canvas": "^0.1.53", - "@oldschoolgg/toolkit": "git+https://github.com/oldschoolgg/toolkit.git#21649ba236026062ea54cd030ff9060bcefa624a", + "@oldschoolgg/toolkit": "git+https://github.com/oldschoolgg/toolkit.git#e148e18bec1be9bbb82151dced9e3f83ea0d4e85", "@prisma/client": "^5.16.1", "@sapphire/snowflake": "^3.5.3", "@sapphire/time-utilities": "^1.6.0", diff --git a/yarn.lock b/yarn.lock index 94a6b28100..857b56bed8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -679,9 +679,9 @@ __metadata: languageName: node linkType: hard -"@oldschoolgg/toolkit@git+https://github.com/oldschoolgg/toolkit.git#21649ba236026062ea54cd030ff9060bcefa624a": +"@oldschoolgg/toolkit@git+https://github.com/oldschoolgg/toolkit.git#e148e18bec1be9bbb82151dced9e3f83ea0d4e85": version: 0.0.24 - resolution: "@oldschoolgg/toolkit@https://github.com/oldschoolgg/toolkit.git#commit=21649ba236026062ea54cd030ff9060bcefa624a" + resolution: "@oldschoolgg/toolkit@https://github.com/oldschoolgg/toolkit.git#commit=e148e18bec1be9bbb82151dced9e3f83ea0d4e85" dependencies: decimal.js: "npm:^10.4.3" deepmerge: "npm:4.3.1" @@ -695,7 +695,7 @@ __metadata: peerDependencies: discord.js: ^14.15.3 oldschooljs: ^2.5.9 - checksum: 10c0/23688df68408a728a0cd7e965a37b2638add3169024a1a800c48c9dc288e8392f15015fae482dd773794070fb5e607151044fc311928e1e7f3d3e17439ba2ef2 + checksum: 10c0/f2ea9b78ae87f7e0817319b9b78a743c48a7e9a9de76e5a9aaedc07710d728b2ab8ccbed0f31dc8a8be1ea93b39f2355aa61b2a0aa806d8d7faf59cd2977ad99 languageName: node linkType: hard @@ -4090,7 +4090,7 @@ __metadata: dependencies: "@biomejs/biome": "npm:^1.8.3" "@napi-rs/canvas": "npm:^0.1.53" - "@oldschoolgg/toolkit": "git+https://github.com/oldschoolgg/toolkit.git#21649ba236026062ea54cd030ff9060bcefa624a" + "@oldschoolgg/toolkit": "git+https://github.com/oldschoolgg/toolkit.git#e148e18bec1be9bbb82151dced9e3f83ea0d4e85" "@prisma/client": "npm:^5.16.1" "@sapphire/snowflake": "npm:^3.5.3" "@sapphire/time-utilities": "npm:^1.6.0" From a69f84c5a16ac45a1e62290faf5edd69a4773860 Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Fri, 12 Jul 2024 06:02:47 +1000 Subject: [PATCH 013/145] Remove unnecessary g.e verification --- src/lib/grandExchange.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/lib/grandExchange.ts b/src/lib/grandExchange.ts index debfc78c78..427fb6c5d2 100644 --- a/src/lib/grandExchange.ts +++ b/src/lib/grandExchange.ts @@ -164,7 +164,6 @@ class GrandExchangeSingleton { try { await this.fetchOwnedBank(); await this.extensiveVerification(); - await this.checkGECanFullFilAllListings(); } catch (err: any) { await this.lockGE(err.message); } finally { From 8954d008dfd4e66aa6d4fe47a00f20229c33a502 Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Fri, 12 Jul 2024 06:03:05 +1000 Subject: [PATCH 014/145] Log timing of g.e verification --- src/lib/grandExchange.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/lib/grandExchange.ts b/src/lib/grandExchange.ts index 427fb6c5d2..18512de730 100644 --- a/src/lib/grandExchange.ts +++ b/src/lib/grandExchange.ts @@ -6,6 +6,7 @@ import { LRUCache } from 'lru-cache'; import { Bank } from 'oldschooljs'; import type { Item, ItemBank } from 'oldschooljs/dist/meta/types'; import PQueue from 'p-queue'; +import { Stopwatch } from '@oldschoolgg/toolkit'; import { ADMIN_IDS, OWNER_IDS, production } from '../config'; import { BLACKLISTED_USERS } from './blacklists'; @@ -767,12 +768,14 @@ ${type} ${toKMB(quantity)} ${item.name} for ${toKMB(price)} each, for a total of } async extensiveVerification() { + const stopwatch = new Stopwatch(); + stopwatch.check("extensiveVerification start"); await Promise.all([ prisma.gETransaction.findMany().then(txs => txs.map(tx => sanityCheckTransaction(tx))), prisma.gEListing.findMany().then(listings => listings.map(listing => sanityCheckListing(listing))), this.checkGECanFullFilAllListings() ]); - debugLog('Validated GE and found no issues.'); + stopwatch.check("extensiveVerification finish"); return true; } From f91cbc772501cc6ca9166f937b99584cdc2ee09a Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Fri, 12 Jul 2024 10:01:42 +1000 Subject: [PATCH 015/145] Dont error if pg version command fails --- src/lib/systemInfo.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/lib/systemInfo.ts b/src/lib/systemInfo.ts index 077d62c7d1..846ae04116 100644 --- a/src/lib/systemInfo.ts +++ b/src/lib/systemInfo.ts @@ -9,9 +9,7 @@ async function getPostgresVersion() { const version = result[0].version.split(',')[0]; return version; } catch (err) { - await client.$disconnect(); - console.log('Failed to execute postgres query. Is postgres running?'); - process.exit(1); + return 'UNKNOWN'; } finally { await client.$disconnect(); } From 8fd6f9700fa6c03296e1b44365af4c6359edf496 Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Fri, 12 Jul 2024 10:02:09 +1000 Subject: [PATCH 016/145] Lint --- src/lib/grandExchange.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/lib/grandExchange.ts b/src/lib/grandExchange.ts index 18512de730..b575080fde 100644 --- a/src/lib/grandExchange.ts +++ b/src/lib/grandExchange.ts @@ -1,3 +1,4 @@ +import { Stopwatch } from '@oldschoolgg/toolkit'; import type { GEListing, GETransaction } from '@prisma/client'; import { GEListingType } from '@prisma/client'; import { ButtonBuilder, ButtonStyle, bold, userMention } from 'discord.js'; @@ -6,7 +7,6 @@ import { LRUCache } from 'lru-cache'; import { Bank } from 'oldschooljs'; import type { Item, ItemBank } from 'oldschooljs/dist/meta/types'; import PQueue from 'p-queue'; -import { Stopwatch } from '@oldschoolgg/toolkit'; import { ADMIN_IDS, OWNER_IDS, production } from '../config'; import { BLACKLISTED_USERS } from './blacklists'; @@ -769,13 +769,13 @@ ${type} ${toKMB(quantity)} ${item.name} for ${toKMB(price)} each, for a total of async extensiveVerification() { const stopwatch = new Stopwatch(); - stopwatch.check("extensiveVerification start"); + stopwatch.check('extensiveVerification start'); await Promise.all([ prisma.gETransaction.findMany().then(txs => txs.map(tx => sanityCheckTransaction(tx))), prisma.gEListing.findMany().then(listings => listings.map(listing => sanityCheckListing(listing))), this.checkGECanFullFilAllListings() ]); - stopwatch.check("extensiveVerification finish"); + stopwatch.check('extensiveVerification finish'); return true; } From f289ec5652a2ffff2177e78dc01d142ed3bb67eb Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Fri, 12 Jul 2024 10:10:56 +1000 Subject: [PATCH 017/145] Add more ignored docker files --- .dockerignore | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.dockerignore b/.dockerignore index 24983d9bf6..5e4edee99e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,4 +1,8 @@ .env icon_cache logs -dist \ No newline at end of file +dist +node_modules +coverage +.yarn +.tests \ No newline at end of file From 79d83175acecaded2e4df462ea7aef231461e82a Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Fri, 12 Jul 2024 11:11:34 +1000 Subject: [PATCH 018/145] Rename clue/monster data keys --- src/lib/MUser.ts | 6 +++--- src/lib/combat_achievements/caUtils.ts | 2 +- src/lib/combat_achievements/master.ts | 2 +- src/lib/types/minions.ts | 11 +++++------ src/lib/util/activityInArea.ts | 2 +- src/lib/util/minionStatus.ts | 12 ++++++------ src/lib/util/repeatStoredTrip.ts | 10 +++++----- src/mahoji/commands/clue.ts | 4 ++-- src/mahoji/commands/k.ts | 2 +- src/mahoji/commands/mass.ts | 4 ++-- src/mahoji/commands/tools.ts | 8 ++++---- src/mahoji/lib/abstracted_commands/minionKill.ts | 6 +++--- src/mahoji/lib/abstracted_commands/statCommand.ts | 4 ++-- src/tasks/minions/clueActivity.ts | 2 +- src/tasks/minions/farmingActivity.ts | 4 ++-- src/tasks/minions/groupMonsterActivity.ts | 2 +- src/tasks/minions/monsterActivity.ts | 6 +++--- tests/integration/MUser.test.ts | 4 ++-- 18 files changed, 45 insertions(+), 46 deletions(-) diff --git a/src/lib/MUser.ts b/src/lib/MUser.ts index e7a266d116..0bedb50310 100644 --- a/src/lib/MUser.ts +++ b/src/lib/MUser.ts @@ -274,13 +274,13 @@ export class MUserClass { } async calcActualClues() { - const result: { id: number; qty: number }[] = await prisma.$queryRawUnsafe(`SELECT (data->>'clueID')::int AS id, SUM((data->>'quantity')::int)::int AS qty + const result: { id: number; qty: number }[] = await prisma.$queryRawUnsafe(`SELECT (data->>'ci')::int AS id, SUM((data->>'q')::int)::int AS qty FROM activity WHERE type = 'ClueCompletion' AND user_id = '${this.id}'::bigint -AND data->>'clueID' IS NOT NULL +AND data->>'ci' IS NOT NULL AND completed = true -GROUP BY data->>'clueID';`); +GROUP BY data->>'ci';`); const casketsCompleted = new Bank(); for (const res of result) { const item = getItem(res.id); diff --git a/src/lib/combat_achievements/caUtils.ts b/src/lib/combat_achievements/caUtils.ts index a0f68d9191..fee5ce5feb 100644 --- a/src/lib/combat_achievements/caUtils.ts +++ b/src/lib/combat_achievements/caUtils.ts @@ -4,7 +4,7 @@ import type { CombatAchievement } from './combatAchievements'; export function isCertainMonsterTrip(monsterID: number) { return (data: ActivityTaskData) => - data.type === 'MonsterKilling' && (data as MonsterActivityTaskOptions).monsterID === monsterID; + data.type === 'MonsterKilling' && (data as MonsterActivityTaskOptions).mi === monsterID; } interface CombatAchievementGroup { diff --git a/src/lib/combat_achievements/master.ts b/src/lib/combat_achievements/master.ts index a364a74d4e..10947c56ff 100644 --- a/src/lib/combat_achievements/master.ts +++ b/src/lib/combat_achievements/master.ts @@ -1403,7 +1403,7 @@ export const masterCombatAchievements: CombatAchievement[] = [ rng: { chancePerKill: 33, hasChance: data => - isCertainMonsterTrip(Monsters.Vorkath.id)(data) && (data as MonsterActivityTaskOptions).quantity >= 5 + isCertainMonsterTrip(Monsters.Vorkath.id)(data) && (data as MonsterActivityTaskOptions).q >= 5 } }, { diff --git a/src/lib/types/minions.ts b/src/lib/types/minions.ts index 583d39b8cf..13d9532c7c 100644 --- a/src/lib/types/minions.ts +++ b/src/lib/types/minions.ts @@ -124,13 +124,13 @@ export interface ConstructionActivityTaskOptions extends ActivityTaskOptions { export interface MonsterActivityTaskOptions extends ActivityTaskOptions { type: 'MonsterKilling'; - monsterID: number; - quantity: number; + mi: number; + q: number; iQty?: number; usingCannon?: boolean; cannonMulti?: boolean; chinning?: boolean; - burstOrBarrage?: number; + bob?: number; died?: boolean; pkEncounters?: number; hasWildySupplies?: boolean; @@ -139,9 +139,8 @@ export interface MonsterActivityTaskOptions extends ActivityTaskOptions { export interface ClueActivityTaskOptions extends ActivityTaskOptions { type: 'ClueCompletion'; - - clueID: number; - quantity: number; + ci: number; + q: number; implingID?: number; implingClues?: number; } diff --git a/src/lib/util/activityInArea.ts b/src/lib/util/activityInArea.ts index c0e05fe3b3..a953d048e0 100644 --- a/src/lib/util/activityInArea.ts +++ b/src/lib/util/activityInArea.ts @@ -25,7 +25,7 @@ const WorldLocationsChecker = [ if ( activity.type === 'MonsterKilling' && [Monsters.DarkBeast.id, Monsters.PrifddinasElf.id].includes( - (activity as MonsterActivityTaskOptions).monsterID + (activity as MonsterActivityTaskOptions).mi ) ) { return true; diff --git a/src/lib/util/minionStatus.ts b/src/lib/util/minionStatus.ts index ca06c68a9f..6be916359d 100644 --- a/src/lib/util/minionStatus.ts +++ b/src/lib/util/minionStatus.ts @@ -94,16 +94,16 @@ export function minionStatus(user: MUser) { switch (currentTask.type) { case 'MonsterKilling': { const data = currentTask as MonsterActivityTaskOptions; - const monster = killableMonsters.find(mon => mon.id === data.monsterID); + const monster = killableMonsters.find(mon => mon.id === data.mi); - return `${name} is currently killing ${data.quantity}x ${monster?.name}. ${formattedDuration}`; + return `${name} is currently killing ${data.q}x ${monster?.name}. ${formattedDuration}`; } case 'GroupMonsterKilling': { const data = currentTask as GroupMonsterActivityTaskOptions; - const monster = killableMonsters.find(mon => mon.id === data.monsterID); + const monster = killableMonsters.find(mon => mon.id === data.mi); - return `${name} is currently killing ${data.quantity}x ${monster?.name} with a party of ${ + return `${name} is currently killing ${data.q}x ${monster?.name} with a party of ${ data.users.length }. ${formattedDuration}`; } @@ -111,9 +111,9 @@ export function minionStatus(user: MUser) { case 'ClueCompletion': { const data = currentTask as ClueActivityTaskOptions; - const clueTier = ClueTiers.find(tier => tier.id === data.clueID); + const clueTier = ClueTiers.find(tier => tier.id === data.ci); - return `${name} is currently completing ${data.quantity}x ${clueTier?.name} clues. ${formattedDuration}`; + return `${name} is currently completing ${data.q}x ${clueTier?.name} clues. ${formattedDuration}`; } case 'Crafting': { diff --git a/src/lib/util/repeatStoredTrip.ts b/src/lib/util/repeatStoredTrip.ts index 25549dad68..e254df427c 100644 --- a/src/lib/util/repeatStoredTrip.ts +++ b/src/lib/util/repeatStoredTrip.ts @@ -88,7 +88,7 @@ const taskCanBeRepeated = (activity: Activity) => { const tripHandlers = { [activity_type_enum.ClueCompletion]: { commandName: 'clue', - args: (data: ClueActivityTaskOptions) => ({ tier: data.clueID, implings: getOSItem(data.implingID!).name }) + args: (data: ClueActivityTaskOptions) => ({ tier: data.ci, implings: getOSItem(data.implingID!).name }) }, [activity_type_enum.SpecificQuest]: { commandName: 'm', @@ -331,7 +331,7 @@ const tripHandlers = { [activity_type_enum.GroupMonsterKilling]: { commandName: 'mass', args: (data: GroupMonsterActivityTaskOptions) => ({ - monster: autocompleteMonsters.find(i => i.id === data.monsterID)?.name ?? data.monsterID.toString() + monster: autocompleteMonsters.find(i => i.id === data.mi)?.name ?? data.mi.toString() }) }, [activity_type_enum.Herblore]: { @@ -403,10 +403,10 @@ const tripHandlers = { let method: PvMMethod = 'none'; if (data.usingCannon) method = 'cannon'; if (data.chinning) method = 'chinning'; - else if (data.burstOrBarrage === SlayerActivityConstants.IceBarrage) method = 'barrage'; - else if (data.burstOrBarrage === SlayerActivityConstants.IceBurst) method = 'burst'; + else if (data.bob === SlayerActivityConstants.IceBarrage) method = 'barrage'; + else if (data.bob === SlayerActivityConstants.IceBurst) method = 'burst'; return { - name: autocompleteMonsters.find(i => i.id === data.monsterID)?.name ?? data.monsterID.toString(), + name: autocompleteMonsters.find(i => i.id === data.mi)?.name ?? data.mi.toString(), quantity: data.iQty, method, wilderness: data.isInWilderness diff --git a/src/mahoji/commands/clue.ts b/src/mahoji/commands/clue.ts index 31a0f0518d..18a44557df 100644 --- a/src/mahoji/commands/clue.ts +++ b/src/mahoji/commands/clue.ts @@ -367,12 +367,12 @@ export const clueCommand: OSBMahojiCommand = { duration = timeToFinish * quantity; await addSubTaskToActivityTask({ - clueID: clueTier.id, + ci: clueTier.id, implingID: clueImpling ? clueImpling.id : undefined, implingClues: clueImpling ? implingClues : undefined, userID: user.id, channelID: channelID.toString(), - quantity, + q: quantity, duration, type: 'ClueCompletion' }); diff --git a/src/mahoji/commands/k.ts b/src/mahoji/commands/k.ts index 34998c7a02..1842c9b894 100644 --- a/src/mahoji/commands/k.ts +++ b/src/mahoji/commands/k.ts @@ -47,7 +47,7 @@ export const autocompleteMonsters = [ async function fetchUsersRecentlyKilledMonsters(userID: string) { const res = await prisma.$queryRawUnsafe<{ mon_id: string; last_killed: Date }[]>( - `SELECT DISTINCT((data->>'monsterID')) AS mon_id, MAX(start_date) as last_killed + `SELECT DISTINCT((data->>'mi')) AS mon_id, MAX(start_date) as last_killed FROM activity WHERE user_id = $1 AND type = 'MonsterKilling' diff --git a/src/mahoji/commands/mass.ts b/src/mahoji/commands/mass.ts index abf8c088f9..66320bf354 100644 --- a/src/mahoji/commands/mass.ts +++ b/src/mahoji/commands/mass.ts @@ -156,10 +156,10 @@ export const massCommand: OSBMahojiCommand = { } await addSubTaskToActivityTask({ - monsterID: monster.id, + mi: monster.id, userID: user.id, channelID: channelID.toString(), - quantity, + q: quantity, duration, type: 'GroupMonsterKilling', leader: user.id, diff --git a/src/mahoji/commands/tools.ts b/src/mahoji/commands/tools.ts index 886739a859..f375368be0 100644 --- a/src/mahoji/commands/tools.ts +++ b/src/mahoji/commands/tools.ts @@ -147,13 +147,13 @@ async function clueGains(interval: string, tier?: string, ironmanOnly?: boolean) const clueTier = ClueTiers.find(t => t.name.toLowerCase() === tier.toLowerCase()); if (!clueTier) return 'Invalid clue scroll tier.'; const tierId = clueTier.id; - tierFilter = `AND (a."data"->>'clueID')::int = ${tierId}`; + tierFilter = `AND (a."data"->>'ci')::int = ${tierId}`; title = `Highest ${clueTier.name} clue scroll completions in the past ${interval}`; } else { title = `Highest All clue scroll completions in the past ${interval}`; } - const query = `SELECT a.user_id::text, SUM((a."data"->>'quantity')::int) AS qty, MAX(a.finish_date) AS lastDate + const query = `SELECT a.user_id::text, SUM((a."data"->>'q')::int) AS qty, MAX(a.finish_date) AS lastDate FROM activity a JOIN users u ON a.user_id::text = u.id WHERE a.type = 'ClueCompletion' @@ -285,10 +285,10 @@ async function kcGains(interval: string, monsterName: string, ironmanOnly?: bool } const query = ` - SELECT a.user_id::text, SUM((a."data"->>'quantity')::int) AS qty, MAX(a.finish_date) AS lastDate + SELECT a.user_id::text, SUM((a."data"->>'q')::int) AS qty, MAX(a.finish_date) AS lastDate FROM activity a JOIN users u ON a.user_id::text = u.id - WHERE a.type = 'MonsterKilling' AND (a."data"->>'monsterID')::int = ${monster.id} + WHERE a.type = 'MonsterKilling' AND (a."data"->>'mi')::int = ${monster.id} AND a.finish_date >= now() - interval '1 ${intervalValue}' -- Corrected interval usage AND a.completed = true ${ironmanOnly ? ' AND u."minion.ironman" = true' : ''} diff --git a/src/mahoji/lib/abstracted_commands/minionKill.ts b/src/mahoji/lib/abstracted_commands/minionKill.ts index c7a231f728..10b4653186 100644 --- a/src/mahoji/lib/abstracted_commands/minionKill.ts +++ b/src/mahoji/lib/abstracted_commands/minionKill.ts @@ -948,17 +948,17 @@ export async function minionKillCommand( } await addSubTaskToActivityTask({ - monsterID: monster.id, + mi: monster.id, userID: user.id, channelID: channelID.toString(), - quantity, + q: quantity, iQty: inputQuantity, duration, type: 'MonsterKilling', usingCannon: !usingCannon ? undefined : usingCannon, cannonMulti: !cannonMulti ? undefined : cannonMulti, chinning: !chinning ? undefined : chinning, - burstOrBarrage: !burstOrBarrage ? undefined : burstOrBarrage, + bob: !burstOrBarrage ? undefined : burstOrBarrage, died: hasDied, pkEncounters: thePkCount, hasWildySupplies, diff --git a/src/mahoji/lib/abstracted_commands/statCommand.ts b/src/mahoji/lib/abstracted_commands/statCommand.ts index 17ce1582ff..5b98f8ae52 100644 --- a/src/mahoji/lib/abstracted_commands/statCommand.ts +++ b/src/mahoji/lib/abstracted_commands/statCommand.ts @@ -359,14 +359,14 @@ GROUP BY type;`); name: 'Personal Monster KC', perkTierNeeded: PerkTier.Four, run: async (user: MUser) => { - const result: { id: number; kc: number }[] = await prisma.$queryRawUnsafe(`SELECT (data->>'monsterID')::int as id, SUM((data->>'quantity')::int)::int AS kc + const result: { id: number; kc: number }[] = await prisma.$queryRawUnsafe(`SELECT (data->>'mi')::int as id, SUM((data->>'q')::int)::int AS kc FROM activity WHERE completed = true AND user_id = ${BigInt(user.id)} AND type = 'MonsterKilling' AND data IS NOT NULL AND data::text != '{}' -GROUP BY data->>'monsterID';`); +GROUP BY data->>'mi';`); const dataPoints: [string, number][] = result .sort((a, b) => b.kc - a.kc) .slice(0, 30) diff --git a/src/tasks/minions/clueActivity.ts b/src/tasks/minions/clueActivity.ts index 1a24d9f269..58f8272e40 100644 --- a/src/tasks/minions/clueActivity.ts +++ b/src/tasks/minions/clueActivity.ts @@ -7,7 +7,7 @@ import { handleTripFinish } from '../../lib/util/handleTripFinish'; export const clueTask: MinionTask = { type: 'ClueCompletion', async run(data: ClueActivityTaskOptions) { - const { clueID, userID, channelID, quantity, implingClues } = data; + const { ci: clueID, userID, channelID, q: quantity, implingClues } = data; const clueTier = ClueTiers.find(mon => mon.id === clueID)!; const user = await mUserFetch(userID); diff --git a/src/tasks/minions/farmingActivity.ts b/src/tasks/minions/farmingActivity.ts index c333b47179..7ab0a38dd3 100644 --- a/src/tasks/minions/farmingActivity.ts +++ b/src/tasks/minions/farmingActivity.ts @@ -340,8 +340,8 @@ export const farmingTask: MinionTask = { farmingLevel: currentFarmingLevel }); const fakeMonsterTaskOptions: MonsterActivityTaskOptions = { - monsterID: Monsters.Hespori.id, - quantity: patchType.lastQuantity, + mi: Monsters.Hespori.id, + q: patchType.lastQuantity, type: 'MonsterKilling', userID: user.id, duration: data.duration, diff --git a/src/tasks/minions/groupMonsterActivity.ts b/src/tasks/minions/groupMonsterActivity.ts index 9271e90808..a87a51dde9 100644 --- a/src/tasks/minions/groupMonsterActivity.ts +++ b/src/tasks/minions/groupMonsterActivity.ts @@ -12,7 +12,7 @@ import { handleTripFinish } from '../../lib/util/handleTripFinish'; export const groupoMonsterTask: MinionTask = { type: 'GroupMonsterKilling', async run(data: GroupMonsterActivityTaskOptions) { - const { monsterID, channelID, quantity, users, leader, duration } = data; + const { mi: monsterID, channelID, q: quantity, users, leader, duration } = data; const monster = killableMonsters.find(mon => mon.id === monsterID)!; const teamsLoot: { [key: string]: Bank } = {}; diff --git a/src/tasks/minions/monsterActivity.ts b/src/tasks/minions/monsterActivity.ts index b4205b5e1b..7af6832f7a 100644 --- a/src/tasks/minions/monsterActivity.ts +++ b/src/tasks/minions/monsterActivity.ts @@ -25,14 +25,14 @@ export const monsterTask: MinionTask = { type: 'MonsterKilling', async run(data: MonsterActivityTaskOptions) { let { - monsterID, + mi: monsterID, userID, channelID, - quantity, + q: quantity, duration, usingCannon, cannonMulti, - burstOrBarrage, + bob: burstOrBarrage, died, pkEncounters, hasWildySupplies, diff --git a/tests/integration/MUser.test.ts b/tests/integration/MUser.test.ts index 3f85e50690..476959cfbc 100644 --- a/tests/integration/MUser.test.ts +++ b/tests/integration/MUser.test.ts @@ -161,8 +161,8 @@ describe('MUser', () => { group_activity: false, data: { userID: user.id, - clueID: tier.id, - quantity: randInt(1, 10) + ci: tier.id, + q: randInt(1, 10) } }); } From bac20e30e741975ad9bc141b51ae6785081a8bb5 Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Fri, 12 Jul 2024 12:06:40 +1000 Subject: [PATCH 019/145] Change activity data to jsonb --- prisma/schema.prisma | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c2f03ebe69..7e94f2f0e2 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -18,7 +18,7 @@ model Activity { group_activity Boolean type activity_type_enum channel_id BigInt - data Json @db.Json + data Json @db.JsonB pinnedTrip PinnedTrip[] @@index([user_id, finish_date]) From aeb29ca296583ec9d96082064f5d69daeba357db Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Sat, 13 Jul 2024 10:11:25 +1000 Subject: [PATCH 020/145] Remove command usage status --- prisma/schema.prisma | 18 +++++------------- src/lib/util/commandUsage.ts | 2 -- 2 files changed, 5 insertions(+), 15 deletions(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7e94f2f0e2..495c0a91a8 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -583,14 +583,13 @@ model Minigame { } model CommandUsage { - id Int @id @default(autoincrement()) - date DateTime @default(now()) @db.Timestamp(6) + id Int @id @default(autoincrement()) + date DateTime @default(now()) @db.Timestamp(6) user_id BigInt - command_name String @db.VarChar(32) - status command_usage_status @default(value: Unknown) - is_continue Boolean @default(false) + command_name String @db.VarChar(32) + is_continue Boolean @default(false) flags Json? - inhibited Boolean? @default(false) + inhibited Boolean? @default(false) is_mention_command Boolean @default(false) @@ -854,13 +853,6 @@ model HistoricalData { @@map("historical_data") } -enum command_usage_status { - Unknown - Success - Error - Inhibited -} - enum activity_type_enum { Agility Cooking diff --git a/src/lib/util/commandUsage.ts b/src/lib/util/commandUsage.ts index 90821b3a48..365ec7b132 100644 --- a/src/lib/util/commandUsage.ts +++ b/src/lib/util/commandUsage.ts @@ -1,6 +1,5 @@ import type { CommandOptions } from '@oldschoolgg/toolkit'; import type { Prisma } from '@prisma/client'; -import { command_usage_status } from '@prisma/client'; import { getCommandArgs } from '../../mahoji/lib/util'; @@ -28,7 +27,6 @@ export function makeCommandUsage({ return { user_id: BigInt(userID), command_name: commandName, - status: command_usage_status.Unknown, args: getCommandArgs(commandName, args), channel_id: BigInt(channelID), guild_id: guildID ? BigInt(guildID) : null, From 0b897a0ba3d7ed2bd5635c08d556a6fc06eee15f Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Sat, 13 Jul 2024 11:37:44 +1000 Subject: [PATCH 021/145] Add smol heredit to pets --- src/lib/data/CollectionsExport.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/lib/data/CollectionsExport.ts b/src/lib/data/CollectionsExport.ts index c1881b77bd..1022377dff 100644 --- a/src/lib/data/CollectionsExport.ts +++ b/src/lib/data/CollectionsExport.ts @@ -1546,7 +1546,8 @@ export const allPetsCL = resolveItems([ "Lil'viathan", 'Butch', 'Baron', - 'Scurry' + 'Scurry', + 'Smol heredit' ]); export const camdozaalCL = resolveItems([ 'Barronite mace', From 4ed2def92cc498e705e3199644ced48d1cee9a97 Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Sat, 13 Jul 2024 11:37:56 +1000 Subject: [PATCH 022/145] Increase party waiting/timeout time to 5 minutes --- src/lib/party.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/party.ts b/src/lib/party.ts index 415abffb0e..aa246ddc24 100644 --- a/src/lib/party.ts +++ b/src/lib/party.ts @@ -77,7 +77,7 @@ export async function setupParty(channel: TextChannel, leaderUser: MUser, option new Promise(async (resolve, reject) => { let partyCancelled = false; const collector = new InteractionCollector(globalClient, { - time: Time.Minute * 2, + time: Time.Minute * 5, maxUsers: options.usersAllowed?.length ?? options.maxSize, dispose: true, channel, From 3bd9c34c4b737f406e80b91f113da2c9206fc902 Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Sat, 13 Jul 2024 11:38:10 +1000 Subject: [PATCH 023/145] Make G.E channel just hardcoded --- src/lib/constants.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 52860b3548..eb89c5b256 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -544,13 +544,15 @@ if (!process.env.BOT_TOKEN && !process.env.CI) { ); } +const isProduction = process.env.NODE_ENV === 'production'; + export const globalConfig = globalConfigSchema.parse({ clientID: process.env.CLIENT_ID, - geAdminChannelID: process.env.GE_ADMIN_CHANNEL_ID, + geAdminChannelID: isProduction ? '830145040495411210' : '1042760447830536212', redisPort: process.env.REDIS_PORT, botToken: process.env.BOT_TOKEN, isCI: process.env.CI, - isProduction: process.env.NODE_ENV === 'production' + isProduction }); if ((process.env.NODE_ENV === 'production') !== globalConfig.isProduction || production !== globalConfig.isProduction) { From 2fe71279b4f086ad1d76a593a7fb2821fb1f6561 Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Sat, 13 Jul 2024 11:42:40 +1000 Subject: [PATCH 024/145] Remove unneeded things from config example --- src/config.example.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/config.example.ts b/src/config.example.ts index 45610175d4..7c3d2c498f 100644 --- a/src/config.example.ts +++ b/src/config.example.ts @@ -2,12 +2,8 @@ import type { IDiscordSettings } from './lib/types'; export const production = false; export const SENTRY_DSN: string | null = null; -export const CLIENT_SECRET = ''; -export const DEV_SERVER_ID = ''; export const DISCORD_SETTINGS: Partial = {}; -// Add or replace these with your Discord ID: export const OWNER_IDS = ['157797566833098752']; export const ADMIN_IDS = ['425134194436341760']; export const MAXING_MESSAGE = 'Congratulations on maxing!'; -// Discord server where admin commands will be allowed: export const SupportServer = '940758552425955348'; From bf4518437aa874af2d159c3fb30ffb7d87ba9ff3 Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Sat, 13 Jul 2024 11:47:00 +1000 Subject: [PATCH 025/145] Cleanup logError --- src/lib/util/logError.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/lib/util/logError.ts b/src/lib/util/logError.ts index 2eb4da18f1..75a85c0fcf 100644 --- a/src/lib/util/logError.ts +++ b/src/lib/util/logError.ts @@ -1,9 +1,10 @@ -import { convertAPIOptionsToCommandOptions } from '@oldschoolgg/toolkit'; +import { convertAPIOptionsToCommandOptions, deepMerge } from '@oldschoolgg/toolkit'; import { captureException } from '@sentry/node'; import type { Interaction } from 'discord.js'; import { isObject } from 'e'; import { production } from '../../config'; +import { globalConfig } from '../constants'; export function assert(condition: boolean, desc?: string, context?: Record) { if (!condition) { @@ -16,16 +17,20 @@ export function assert(condition: boolean, desc?: string, context?: Record, extra?: Record) { - debugLog(`${(err as any)?.message ?? JSON.stringify(err)}`, { type: 'ERROR', raw: JSON.stringify(err) }); - if (production) { + const metaInfo = deepMerge(context ?? {}, extra ?? {}); + debugLog(`${(err as any)?.message ?? JSON.stringify(err)}`, { + type: 'ERROR', + raw: JSON.stringify(err), + metaInfo: JSON.stringify(metaInfo) + }); + if (globalConfig.isProduction) { captureException(err, { tags: context, - extra + extra: metaInfo }); } else { console.error(err); - console.log(context); - console.log(extra); + console.log(metaInfo); } } From f55a0b89236913def71f21fd90cba7953ba2eb48 Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Sat, 13 Jul 2024 11:47:12 +1000 Subject: [PATCH 026/145] Handle case when no trips at all to repeat --- src/lib/util/repeatStoredTrip.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/lib/util/repeatStoredTrip.ts b/src/lib/util/repeatStoredTrip.ts index e254df427c..85ed69c2b7 100644 --- a/src/lib/util/repeatStoredTrip.ts +++ b/src/lib/util/repeatStoredTrip.ts @@ -63,7 +63,7 @@ import { itemNameFromID } from '../util'; import { giantsFoundryAlloys } from './../../mahoji/lib/abstracted_commands/giantsFoundryCommand'; import type { NightmareZoneActivityTaskOptions, UnderwaterAgilityThievingTaskOptions } from './../types/minions'; import getOSItem from './getOSItem'; -import { deferInteraction } from './interactionReply'; +import { deferInteraction, interactionReply } from './interactionReply'; const taskCanBeRepeated = (activity: Activity) => { if (activity.type === activity_type_enum.ClueCompletion) { @@ -693,6 +693,9 @@ export async function repeatTrip( interaction: ButtonInteraction, data: { data: Prisma.JsonValue; type: activity_type_enum } ) { + if (!data || !data.data || !data.type) { + return interactionReply(interaction, { content: "Couldn't find any trip to repeat.", ephemeral: true }); + } await deferInteraction(interaction); const handler = tripHandlers[data.type]; return runCommand({ From 74be383e5ebb14e366a76f7f0dc64da386d3368d Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Sat, 13 Jul 2024 11:47:49 +1000 Subject: [PATCH 027/145] Add ephemeral option to runCommand --- src/lib/settings/settings.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/lib/settings/settings.ts b/src/lib/settings/settings.ts index 98d19f3121..f8b57b40ac 100644 --- a/src/lib/settings/settings.ts +++ b/src/lib/settings/settings.ts @@ -13,7 +13,7 @@ import { postCommand } from '../../mahoji/lib/postCommand'; import { preCommand } from '../../mahoji/lib/preCommand'; import { convertMahojiCommandToAbstractCommand } from '../../mahoji/lib/util'; import { minionActivityCache } from '../constants'; -import { channelIsSendable, isGroupActivity } from '../util'; +import { channelIsSendable, isGroupActivity, roughMergeMahojiResponse } from '../util'; import { handleInteractionError, interactionReply } from '../util/interactionReply'; import { logError } from '../util/logError'; import { convertStoredActivityToFlatActivity } from './prisma'; @@ -95,6 +95,7 @@ interface RunCommandArgs { guildID: string | undefined | null; interaction: ButtonInteraction | ChatInputCommandInteraction; continueDeltaMillis: number | null; + ephemeral?: boolean; } export async function runCommand({ commandName, @@ -106,7 +107,8 @@ export async function runCommand({ user, member, interaction, - continueDeltaMillis + continueDeltaMillis, + ephemeral }: RunCommandArgs): Promise { const channel = globalClient.channels.cache.get(channelID.toString()); if (!channel || !channelIsSendable(channel)) return null; @@ -152,7 +154,8 @@ export async function runCommand({ user, interaction }); - if (result && !interaction.replied) await interactionReply(interaction, result); + if (result && !interaction.replied) + await interactionReply(interaction, roughMergeMahojiResponse(result, { ephemeral })); return result; } catch (err: any) { await handleInteractionError(err, interaction); From 292fc67d3c6dd25ef4520f453cc7ca5a77aed7eb Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Sat, 13 Jul 2024 11:48:56 +1000 Subject: [PATCH 028/145] Button/interaction fixes --- src/lib/DynamicButtons.ts | 17 +++--- src/lib/InteractionID.ts | 17 ++++++ src/lib/PaginatedMessage.ts | 17 +++--- src/lib/util/globalInteractions.ts | 18 +++++-- src/lib/util/interactionHelpers.ts | 52 ------------------- src/lib/util/interactionReply.ts | 5 +- src/mahoji/commands/config.ts | 48 ++++++++++------- .../abstracted_commands/slayerTaskCommand.ts | 35 +++++++------ tests/unit/interactionid.test.ts | 14 +++++ tests/unit/snapshots/clsnapshots.test.ts.snap | 2 +- 10 files changed, 115 insertions(+), 110 deletions(-) create mode 100644 src/lib/InteractionID.ts delete mode 100644 src/lib/util/interactionHelpers.ts create mode 100644 tests/unit/interactionid.test.ts diff --git a/src/lib/DynamicButtons.ts b/src/lib/DynamicButtons.ts index f894c792ec..5d892ba99e 100644 --- a/src/lib/DynamicButtons.ts +++ b/src/lib/DynamicButtons.ts @@ -9,7 +9,7 @@ import type { ThreadChannel } from 'discord.js'; import { ButtonBuilder, ButtonStyle } from 'discord.js'; -import { Time, noOp } from 'e'; +import { Time, isFunction, noOp } from 'e'; import murmurhash from 'murmurhash'; import { BLACKLISTED_USERS } from './blacklists'; @@ -23,7 +23,7 @@ export class DynamicButtons { buttons: { name: string; id: string; - fn: DynamicButtonFn; + fn?: DynamicButtonFn; emoji: string | undefined; cantBeBusy: boolean; style?: ButtonStyle; @@ -109,18 +109,21 @@ export class DynamicButtons { for (const button of this.buttons) { if (collectedInteraction.customId === button.id) { if (minionIsBusy(collectedInteraction.user.id) && button.cantBeBusy) { - return collectedInteraction.reply({ + await collectedInteraction.reply({ content: "Your action couldn't be performed, because your minion is busy.", ephemeral: true }); + return null; } - await button.fn({ message: this.message!, interaction: collectedInteraction }); - return collectedInteraction; + if ('fn' in button && isFunction(button.fn)) { + await button.fn({ message: this.message!, interaction: collectedInteraction }); + } + return button; } } } - return collectedInteraction; + return null; } add({ @@ -131,7 +134,7 @@ export class DynamicButtons { style }: { name: string; - fn: DynamicButtonFn; + fn?: DynamicButtonFn; emoji?: string; cantBeBusy?: boolean; style?: ButtonStyle; diff --git a/src/lib/InteractionID.ts b/src/lib/InteractionID.ts new file mode 100644 index 0000000000..cff0e337de --- /dev/null +++ b/src/lib/InteractionID.ts @@ -0,0 +1,17 @@ +export const InteractionID = { + PaginatedMessage: { + FirstPage: 'PM_FIRST_PAGE', + PreviousPage: 'PM_PREVIOUS_PAGE', + NextPage: 'PM_NEXT_PAGE', + LastPage: 'PM_LAST_PAGE' + }, + Slayer: { + AutoSlaySaved: 'SLAYER_AUTO_SLAY_SAVED', + AutoSlayDefault: 'SLAYER_AUTO_SLAY_DEFAULT', + AutoSlayEHP: 'SLAYER_AUTO_SLAY_EHP', + AutoSlayBoss: 'SLAYER_AUTO_SLAY_BOSS', + SkipTask: 'SLAYER_SKIP_TASK', + CancelTask: 'SLAYER_CANCEL_TASK', + BlockTask: 'SLAYER_BLOCK_TASK' + } +} as const; diff --git a/src/lib/PaginatedMessage.ts b/src/lib/PaginatedMessage.ts index 8e572f15f6..447ea30e1e 100644 --- a/src/lib/PaginatedMessage.ts +++ b/src/lib/PaginatedMessage.ts @@ -3,8 +3,9 @@ import type { BaseMessageOptions, ComponentType, MessageEditOptions, TextChannel import { ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js'; import { Time } from 'e'; +import { InteractionID } from './InteractionID'; import type { PaginatedMessagePage } from './util'; -import { logError } from './util/logError'; +import { logError, logErrorForInteraction } from './util/logError'; const controlButtons: { customId: string; @@ -12,12 +13,12 @@ const controlButtons: { run: (opts: { paginatedMessage: PaginatedMessage }) => unknown; }[] = [ { - customId: 'pm-first-page', + customId: InteractionID.PaginatedMessage.FirstPage, emoji: '⏪', run: ({ paginatedMessage }) => (paginatedMessage.index = 0) }, { - customId: 'pm-previous-page', + customId: InteractionID.PaginatedMessage.PreviousPage, emoji: '◀️', run: ({ paginatedMessage }) => { if (paginatedMessage.index === 0) { @@ -28,7 +29,7 @@ const controlButtons: { } }, { - customId: 'pm-next-page', + customId: InteractionID.PaginatedMessage.NextPage, emoji: '▶️', run: ({ paginatedMessage }) => { if (paginatedMessage.index === paginatedMessage.totalPages - 1) { @@ -39,7 +40,7 @@ const controlButtons: { } }, { - customId: 'pm-last-page', + customId: InteractionID.PaginatedMessage.LastPage, emoji: '⏩', run: ({ paginatedMessage }) => (paginatedMessage.index = paginatedMessage.totalPages - 1) } @@ -121,7 +122,11 @@ export class PaginatedMessage { }); if (previousIndex !== this.index) { - await interaction.update(await this.render()); + try { + await interaction.update(await this.render()); + } catch (err) { + logErrorForInteraction(err, interaction); + } return; } } diff --git a/src/lib/util/globalInteractions.ts b/src/lib/util/globalInteractions.ts index b4f369c74b..9ff0c472b6 100644 --- a/src/lib/util/globalInteractions.ts +++ b/src/lib/util/globalInteractions.ts @@ -11,6 +11,7 @@ import { shootingStarsCommand, starCache } from '../../mahoji/lib/abstracted_com import type { ClueTier } from '../clues/clueTiers'; import { BitField, PerkTier } from '../constants'; +import { InteractionID } from '../InteractionID'; import { runCommand } from '../settings/settings'; import { toaHelpCommand } from '../simulation/toa'; import type { ItemBank } from '../types'; @@ -145,6 +146,7 @@ async function giveawayButtonHandler(user: MUser, customID: string, interaction: }); } + await deferInteraction(interaction); return runCommand({ commandName: 'giveaway', args: { @@ -310,7 +312,14 @@ async function handleGEButton(user: MUser, id: string, interaction: ButtonIntera export async function interactionHook(interaction: Interaction) { if (!interaction.isButton()) return; - if (['CONFIRM', 'CANCEL', 'PARTY_JOIN'].includes(interaction.customId)) return; + const ignoredInteractionIDs = [ + 'CONFIRM', + 'CANCEL', + 'PARTY_JOIN', + ...Object.values(InteractionID.PaginatedMessage), + ...Object.values(InteractionID.Slayer) + ]; + if (ignoredInteractionIDs.includes(interaction.customId)) return; if (['DYN_', 'LP_'].some(s => interaction.customId.startsWith(s))) return; if (globalClient.isShuttingDown) { @@ -336,9 +345,8 @@ export async function interactionHook(interaction: Interaction) { }); } - await deferInteraction(interaction); - const user = await mUserFetch(userID); + if (id.includes('GIVEAWAY_')) return giveawayButtonHandler(user, id, interaction); if (id.includes('REPEAT_TRIP')) return repeatTripHandler(user, interaction); if (id.startsWith('GPE_')) return handleGearPresetEquip(user, id, interaction); @@ -385,7 +393,7 @@ export async function interactionHook(interaction: Interaction) { } async function doClue(tier: ClueTier['name']) { - runCommand({ + return runCommand({ commandName: 'clue', args: { tier }, bypassInhibitors: true, @@ -394,7 +402,7 @@ export async function interactionHook(interaction: Interaction) { } async function openCasket(tier: ClueTier['name']) { - runCommand({ + return runCommand({ commandName: 'open', args: { name: tier, diff --git a/src/lib/util/interactionHelpers.ts b/src/lib/util/interactionHelpers.ts deleted file mode 100644 index e75e393f63..0000000000 --- a/src/lib/util/interactionHelpers.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { createHash } from 'node:crypto'; -import type { ChatInputCommandInteraction } from 'discord.js'; -import { ButtonBuilder, ButtonStyle, ComponentType } from 'discord.js'; -import { Time, chunk, randInt } from 'e'; - -import { deferInteraction, interactionReply } from './interactionReply'; - -export async function interactionReplyGetDuration( - interaction: ChatInputCommandInteraction, - prompt: string, - ...durations: { display: string; duration: number }[] -) { - await deferInteraction(interaction); - const unique = createHash('sha256') - .update(String(Date.now()) + String(randInt(10_000, 99_999))) - .digest('hex') - .slice(2, 12); - let x = 0; - const buttons = durations.map(d => ({ label: d.display, customId: `${unique}_DUR_BUTTON_${x++}` })); - buttons.push({ label: 'Cancel', customId: `${unique}_DUR_BUTTON_${x}` }); - const components = makePlainButtons(...buttons); - - const response = await interactionReply(interaction, { content: prompt, components }); - - if (response === undefined) return false; - try { - const selection = await response.awaitMessageComponent({ - filter: i => i.user.id === interaction.user.id, - time: 15 * Time.Second - }); - const id = Number(selection.customId.split('_')[3]); - if (durations[id]) { - await interaction.editReply({ content: `Selected: ${durations[id].display}`, components: [] }); - return durations[id]; - } - await interaction.editReply({ content: 'Cancelled.', components: [] }); - return false; - } catch (e) { - await interaction.editReply({ content: 'Did not choose a duration in time.', components: [] }); - return false; - } -} - -function makePlainButtons(...buttons: { label: string; customId: string }[]) { - const components: ButtonBuilder[] = []; - for (let i = 0; i < buttons.length; i++) { - components.push( - new ButtonBuilder({ label: buttons[i].label, customId: buttons[i].customId, style: ButtonStyle.Secondary }) - ); - } - return chunk(components, 5).map(i => ({ components: i, type: ComponentType.ActionRow })); -} diff --git a/src/lib/util/interactionReply.ts b/src/lib/util/interactionReply.ts index 021ff723f7..36118215a4 100644 --- a/src/lib/util/interactionReply.ts +++ b/src/lib/util/interactionReply.ts @@ -16,6 +16,7 @@ import { logErrorForInteraction } from './logError'; export async function interactionReply(interaction: RepliableInteraction, response: string | InteractionReplyOptions) { let i: Promise | Promise | undefined = undefined; + if (interaction.replied) { i = interaction.followUp(response); } else if (interaction.deferred) { @@ -24,8 +25,8 @@ export async function interactionReply(interaction: RepliableInteraction, respon i = interaction.reply(response); } try { - await i; - return i; + const result = await i; + return result; } catch (e: any) { if (e instanceof DiscordAPIError && e.code !== 10_008) { // 10_008 is unknown message, e.g. if someone deletes the message before it's replied to. diff --git a/src/mahoji/commands/config.ts b/src/mahoji/commands/config.ts index 91dcd090a1..46d145ebae 100644 --- a/src/mahoji/commands/config.ts +++ b/src/mahoji/commands/config.ts @@ -1,4 +1,4 @@ -import { hasBanMemberPerms, miniID } from '@oldschoolgg/toolkit'; +import { channelIsSendable, hasBanMemberPerms, miniID } from '@oldschoolgg/toolkit'; import type { CommandRunOptions } from '@oldschoolgg/toolkit'; import type { CommandResponse } from '@oldschoolgg/toolkit'; import type { activity_type_enum } from '@prisma/client'; @@ -14,6 +14,7 @@ import { BitField, ItemIconPacks, ParsedCustomEmojiWithGroups, PerkTier } from ' import { Eatables } from '../../lib/data/eatables'; import { CombatOptionsArray, CombatOptionsEnum } from '../../lib/minions/data/combatConstants'; +import { DynamicButtons } from '../../lib/DynamicButtons'; import { birdhouseSeeds } from '../../lib/skilling/skills/hunter/birdHouseTrapping'; import { autoslayChoices, slayerMasterChoices } from '../../lib/slayer/constants'; import { setDefaultAutoslay, setDefaultSlayerMaster } from '../../lib/slayer/slayerUtil'; @@ -21,7 +22,7 @@ import { BankSortMethods } from '../../lib/sorts'; import { formatDuration, isValidNickname, itemNameFromID, stringMatches } from '../../lib/util'; import { emojiServers } from '../../lib/util/cachedUserIDs'; import { getItem } from '../../lib/util/getOSItem'; -import { interactionReplyGetDuration } from '../../lib/util/interactionHelpers'; +import { deferInteraction } from '../../lib/util/interactionReply'; import { makeBankImage } from '../../lib/util/makeBankImage'; import { parseBank } from '../../lib/util/parseStringBank'; import { mahojiGuildSettingsFetch, mahojiGuildSettingsUpdate } from '../guildSettings'; @@ -88,32 +89,39 @@ const toggles: UserConfigToggle[] = [ return { result: true, message: 'Your Gambling lockout time has expired.' }; } else if (interaction) { const durations = [ - { display: '24 hours', duration: Time.Day }, + { display: '1 day', duration: Time.Day }, { display: '7 days', duration: Time.Day * 7 }, - { display: '2 weeks', duration: Time.Day * 14 }, { display: '1 month', duration: Time.Month }, - { display: '3 months', duration: Time.Month * 3 }, { display: '6 months', duration: Time.Month * 6 }, - { display: '1 year', duration: Time.Year }, - { display: '3 years', duration: Time.Year * 3 }, - { display: '5 years', duration: Time.Year * 5 } + { display: '1 year', duration: Time.Year } ]; - if (!production) { - durations.push({ display: '30 seconds', duration: Time.Second * 30 }); - durations.push({ display: '1 minute', duration: Time.Minute }); - durations.push({ display: '5 minutes', duration: Time.Minute * 5 }); + const channel = globalClient.channels.cache.get(interaction.channelId); + if (!channelIsSendable(channel)) return { result: false, message: 'Could not find channel.' }; + await deferInteraction(interaction); + const buttons = new DynamicButtons({ + channel: channel, + usersWhoCanInteract: [user.id], + deleteAfterConfirm: true + }); + for (const dur of durations) { + buttons.add({ + name: dur.display + }); } - const lockoutDuration = await interactionReplyGetDuration( - interaction, - `${user}, This will lockout your ability to gamble for the specified time. Choose carefully!`, - ...durations - ); + const pickedButton = await buttons.render({ + messageOptions: { + content: `${user}, This will lockout your ability to gamble for the specified time. Choose carefully!` + }, + isBusy: false + }); + + const pickedDuration = durations.find(d => stringMatches(d.display, pickedButton?.name ?? '')); - if (lockoutDuration !== false) { - await user.update({ gambling_lockout_expiry: new Date(Date.now() + lockoutDuration.duration) }); + if (pickedDuration) { + await user.update({ gambling_lockout_expiry: new Date(Date.now() + pickedDuration.duration) }); return { result: true, - message: `Locking out gambling for ${formatDuration(lockoutDuration.duration)}` + message: `Locking out gambling for ${formatDuration(pickedDuration.duration)}` }; } return { result: false, message: 'Cancelled.' }; diff --git a/src/mahoji/lib/abstracted_commands/slayerTaskCommand.ts b/src/mahoji/lib/abstracted_commands/slayerTaskCommand.ts index 69798b7833..a3ee9ebf69 100644 --- a/src/mahoji/lib/abstracted_commands/slayerTaskCommand.ts +++ b/src/mahoji/lib/abstracted_commands/slayerTaskCommand.ts @@ -6,6 +6,7 @@ import { Monsters } from 'oldschooljs'; import killableMonsters from '../../../lib/minions/data/killableMonsters'; +import { InteractionID } from '../../../lib/InteractionID'; import { runCommand } from '../../../lib/settings/settings'; import { slayerMasters } from '../../../lib/slayer/slayerMasters'; import { SlayerRewardsShop } from '../../../lib/slayer/slayerUnlocks'; @@ -29,39 +30,34 @@ const returnSuccessButtons = [ new ButtonBuilder({ label: 'Autoslay (Saved)', style: ButtonStyle.Secondary, - customId: 'assaved' + customId: InteractionID.Slayer.AutoSlaySaved }), new ButtonBuilder({ label: 'Autoslay (Default)', style: ButtonStyle.Secondary, - customId: 'asdef' + customId: InteractionID.Slayer.AutoSlayDefault }), new ButtonBuilder({ label: 'Autoslay (EHP)', style: ButtonStyle.Secondary, - customId: 'asehp' + customId: InteractionID.Slayer.AutoSlayEHP }), new ButtonBuilder({ label: 'Autoslay (Boss)', style: ButtonStyle.Secondary, - customId: 'asboss' + customId: InteractionID.Slayer.AutoSlayBoss }) ]), new ActionRowBuilder().addComponents([ new ButtonBuilder({ label: 'Cancel Task + New (30 points)', style: ButtonStyle.Danger, - customId: 'skip' + customId: InteractionID.Slayer.SkipTask }), new ButtonBuilder({ label: 'Block Task + New (100 points)', style: ButtonStyle.Danger, - customId: 'block' - }), - new ButtonBuilder({ - label: 'Do Nothing', - style: ButtonStyle.Secondary, - customId: 'doNothing' + customId: InteractionID.Slayer.BlockTask }) ]) ]; @@ -148,7 +144,7 @@ async function returnSuccess(channelID: string, user: MUser, content: string) { }); if (!selection.isButton()) return; switch (selection.customId) { - case 'assaved': { + case InteractionID.Slayer.AutoSlaySaved: { await runCommand({ commandName: 'slayer', args: { autoslay: {} }, @@ -158,7 +154,7 @@ async function returnSuccess(channelID: string, user: MUser, content: string) { }); return; } - case 'asdef': { + case InteractionID.Slayer.AutoSlayDefault: { await runCommand({ commandName: 'slayer', args: { autoslay: { mode: 'default' } }, @@ -168,7 +164,7 @@ async function returnSuccess(channelID: string, user: MUser, content: string) { }); return; } - case 'asehp': { + case InteractionID.Slayer.AutoSlayEHP: { await runCommand({ commandName: 'slayer', args: { autoslay: { mode: 'ehp' } }, @@ -178,7 +174,7 @@ async function returnSuccess(channelID: string, user: MUser, content: string) { }); return; } - case 'asboss': { + case InteractionID.Slayer.AutoSlayBoss: { await runCommand({ commandName: 'slayer', args: { autoslay: { mode: 'boss' } }, @@ -188,7 +184,7 @@ async function returnSuccess(channelID: string, user: MUser, content: string) { }); return; } - case 'skip': { + case InteractionID.Slayer.SkipTask: { await runCommand({ commandName: 'slayer', args: { manage: { command: 'skip', new: true } }, @@ -198,7 +194,7 @@ async function returnSuccess(channelID: string, user: MUser, content: string) { }); return; } - case 'block': { + case InteractionID.Slayer.BlockTask: { await runCommand({ commandName: 'slayer', args: { manage: { command: 'block', new: true } }, @@ -209,6 +205,11 @@ async function returnSuccess(channelID: string, user: MUser, content: string) { } } } catch (err: unknown) { + if ((err as any).message === 'time') return; + logError(err, { + user_id: user.id.toString(), + channel_id: channelID + }); } finally { await sentMessage.edit({ components: [] }); } diff --git a/tests/unit/interactionid.test.ts b/tests/unit/interactionid.test.ts new file mode 100644 index 0000000000..50187c493c --- /dev/null +++ b/tests/unit/interactionid.test.ts @@ -0,0 +1,14 @@ +import { test } from 'vitest'; + +import { InteractionID } from '../../src/lib/InteractionID'; + +test('InteractionID', () => { + const allStrings = Object.values(InteractionID) + .map(obj => Object.values(obj)) + .flat(2); + for (const string of allStrings) { + if (string.length < 1 || string.length > 100) { + throw new Error(`String ${string} has length ${string.length} which is not between 1 and 100`); + } + } +}); diff --git a/tests/unit/snapshots/clsnapshots.test.ts.snap b/tests/unit/snapshots/clsnapshots.test.ts.snap index e8c30a8a43..1a39a3fc00 100644 --- a/tests/unit/snapshots/clsnapshots.test.ts.snap +++ b/tests/unit/snapshots/clsnapshots.test.ts.snap @@ -5,7 +5,7 @@ exports[`OSB Collection Log Groups/Categories 1`] = ` Achievement Diary (48) Aerial Fishing (9) Alchemical Hydra (11) -All Pets (57) +All Pets (58) Barbarian Assault (11) Barrows Chests (25) Beginner Treasure Trails (16) From 852f30e078a27a149fb116829d4450b2de250b96 Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Sat, 13 Jul 2024 12:35:50 +1000 Subject: [PATCH 029/145] Update toolkit to fix formatDuration --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 12f92c1863..47342ddc0d 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ }, "dependencies": { "@napi-rs/canvas": "^0.1.53", - "@oldschoolgg/toolkit": "git+https://github.com/oldschoolgg/toolkit.git#e148e18bec1be9bbb82151dced9e3f83ea0d4e85", + "@oldschoolgg/toolkit": "git+https://github.com/oldschoolgg/toolkit.git#8570a6a8b2def1bfcafdcc5e30bbf5dd614c12c7", "@prisma/client": "^5.16.1", "@sapphire/snowflake": "^3.5.3", "@sapphire/time-utilities": "^1.6.0", diff --git a/yarn.lock b/yarn.lock index 857b56bed8..d3a1a5c3d0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -679,9 +679,9 @@ __metadata: languageName: node linkType: hard -"@oldschoolgg/toolkit@git+https://github.com/oldschoolgg/toolkit.git#e148e18bec1be9bbb82151dced9e3f83ea0d4e85": +"@oldschoolgg/toolkit@git+https://github.com/oldschoolgg/toolkit.git#8570a6a8b2def1bfcafdcc5e30bbf5dd614c12c7": version: 0.0.24 - resolution: "@oldschoolgg/toolkit@https://github.com/oldschoolgg/toolkit.git#commit=e148e18bec1be9bbb82151dced9e3f83ea0d4e85" + resolution: "@oldschoolgg/toolkit@https://github.com/oldschoolgg/toolkit.git#commit=8570a6a8b2def1bfcafdcc5e30bbf5dd614c12c7" dependencies: decimal.js: "npm:^10.4.3" deepmerge: "npm:4.3.1" @@ -695,7 +695,7 @@ __metadata: peerDependencies: discord.js: ^14.15.3 oldschooljs: ^2.5.9 - checksum: 10c0/f2ea9b78ae87f7e0817319b9b78a743c48a7e9a9de76e5a9aaedc07710d728b2ab8ccbed0f31dc8a8be1ea93b39f2355aa61b2a0aa806d8d7faf59cd2977ad99 + checksum: 10c0/8e13388aeaf1b76dc1445e71e8b120344805f1c2e8990895e26303155d27fd521c6881bbe50265c4c84b9c7281cb8c7264811f295c267c095cf35423ebdc2601 languageName: node linkType: hard @@ -4090,7 +4090,7 @@ __metadata: dependencies: "@biomejs/biome": "npm:^1.8.3" "@napi-rs/canvas": "npm:^0.1.53" - "@oldschoolgg/toolkit": "git+https://github.com/oldschoolgg/toolkit.git#e148e18bec1be9bbb82151dced9e3f83ea0d4e85" + "@oldschoolgg/toolkit": "git+https://github.com/oldschoolgg/toolkit.git#8570a6a8b2def1bfcafdcc5e30bbf5dd614c12c7" "@prisma/client": "npm:^5.16.1" "@sapphire/snowflake": "npm:^3.5.3" "@sapphire/time-utilities": "npm:^1.6.0" From 8298ab1337342240ab468e8585d6703e9bfd3bf9 Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Sat, 13 Jul 2024 12:36:34 +1000 Subject: [PATCH 030/145] Move testingServerID to env --- .env.example | 5 ++++- src/index.ts | 4 ++-- src/lib/constants.ts | 7 +++++-- src/mahoji/lib/events.ts | 6 +++--- 4 files changed, 14 insertions(+), 8 deletions(-) diff --git a/.env.example b/.env.example index c2947ea926..37cda13863 100644 --- a/.env.example +++ b/.env.example @@ -5,7 +5,10 @@ BOT_TOKEN=PUT_YOUR_TOKEN_HERE # You may need to change these: ROBOCHIMP_DATABASE_URL=postgresql://postgres:postgres@localhost:5436/robochimp_test DATABASE_URL=postgresql://postgres:postgres@localhost:5435/osb_test -#REDIS_PORT=6379 #OPTIONAL + +# Optional +#REDIS_PORT=6379 +#TESTING_SERVER_ID=123456789012345678 # Dont change these: TZ="UTC" \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 0601c2fdb3..e65d51f4c0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -92,7 +92,7 @@ const client = new OldSchoolBotClient({ }); export const mahojiClient = new MahojiClient({ - developmentServerID: DEV_SERVER_ID, + developmentServerID: globalConfig.testingServerID, applicationID: globalConfig.clientID, commands: allCommands, handlers: { @@ -138,7 +138,7 @@ client.on('interactionCreate', async interaction => { if (interaction.guildId && BLACKLISTED_GUILDS.has(interaction.guildId)) return; if (!client.isReady()) { - if (interaction.isChatInputCommand()) { + if (interaction.isRepliable()) { await interaction.reply({ content: 'Old School Bot is currently down for maintenance/updates, please try again in a couple minutes! Thank you <3', diff --git a/src/lib/constants.ts b/src/lib/constants.ts index eb89c5b256..46bf0d9d2c 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -534,7 +534,8 @@ const globalConfigSchema = z.object({ redisPort: z.coerce.number().int().optional(), botToken: z.string().min(1), isCI: z.coerce.boolean().default(false), - isProduction: z.coerce.boolean().default(production) + isProduction: z.coerce.boolean().default(production), + testingServerID: z.string() }); dotenv.config({ path: path.resolve(process.cwd(), process.env.TEST ? '.env.test' : '.env') }); @@ -544,6 +545,7 @@ if (!process.env.BOT_TOKEN && !process.env.CI) { ); } +const OLDSCHOOLGG_TESTING_SERVER_ID = '940758552425955348'; const isProduction = process.env.NODE_ENV === 'production'; export const globalConfig = globalConfigSchema.parse({ @@ -552,7 +554,8 @@ export const globalConfig = globalConfigSchema.parse({ redisPort: process.env.REDIS_PORT, botToken: process.env.BOT_TOKEN, isCI: process.env.CI, - isProduction + isProduction, + testingServerID: process.env.TESTING_SERVER_ID ?? OLDSCHOOLGG_TESTING_SERVER_ID }); if ((process.env.NODE_ENV === 'production') !== globalConfig.isProduction || production !== globalConfig.isProduction) { diff --git a/src/mahoji/lib/events.ts b/src/mahoji/lib/events.ts index db954c8fd4..d76fd843a8 100644 --- a/src/mahoji/lib/events.ts +++ b/src/mahoji/lib/events.ts @@ -1,7 +1,7 @@ import type { ItemBank } from 'oldschooljs/dist/meta/types'; import { bulkUpdateCommands } from '@oldschoolgg/toolkit'; -import { DEV_SERVER_ID, production } from '../../config'; +import { production } from '../../config'; import { cacheBadges } from '../../lib/badges'; import { syncBlacklists } from '../../lib/blacklists'; import { Channel, DISABLED_COMMANDS, META_CONSTANTS, globalConfig } from '../../lib/constants'; @@ -25,7 +25,7 @@ export async function syncCustomPrices() { } export async function onStartup() { - globalClient.application.commands.fetch({ guildId: production ? undefined : DEV_SERVER_ID }); + globalClient.application.commands.fetch({ guildId: production ? undefined : globalConfig.testingServerID }); // Sync disabled commands const disabledCommands = await prisma.clientStorage.upsert({ @@ -53,7 +53,7 @@ export async function onStartup() { await bulkUpdateCommands({ client: globalClient.mahojiClient, commands: Array.from(globalClient.mahojiClient.commands.values()), - guildID: DEV_SERVER_ID + guildID: globalConfig.testingServerID }); } From 5ec9de95a8c8ba3634f510f95a832b7847643835 Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Sat, 13 Jul 2024 12:37:30 +1000 Subject: [PATCH 031/145] Remove unneeded log --- src/index.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/index.ts b/src/index.ts index e65d51f4c0..4a00323a5a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,7 +11,7 @@ import type { TextChannel } from 'discord.js'; import { GatewayIntentBits, Options, Partials } from 'discord.js'; import { isObject } from 'e'; -import { DEV_SERVER_ID, SENTRY_DSN, SupportServer } from './config'; +import { SENTRY_DSN, SupportServer } from './config'; import { syncActivityCache } from './lib/Task'; import { BLACKLISTED_GUILDS, BLACKLISTED_USERS } from './lib/blacklists'; import { Channel, Events, META_CONSTANTS, gitHash, globalConfig } from './lib/constants'; @@ -71,7 +71,10 @@ const client = new OldSchoolBotClient({ maxSize: 200, keepOverLimit: member => CACHED_ACTIVE_USER_IDS.has(member.user.id) }, - GuildEmojiManager: { maxSize: 1, keepOverLimit: i => [DEV_SERVER_ID, SupportServer].includes(i.guild.id) }, + GuildEmojiManager: { + maxSize: 1, + keepOverLimit: i => [globalConfig.testingServerID, SupportServer].includes(i.guild.id) + }, GuildStickerManager: { maxSize: 0 }, PresenceManager: { maxSize: 0 }, VoiceStateManager: { maxSize: 0 }, @@ -145,6 +148,7 @@ client.on('interactionCreate', async interaction => { ephemeral: true }); } + return; } @@ -180,7 +184,6 @@ client.on('guildCreate', guild => { } }); -client.on('shardDisconnect', ({ wasClean, code, reason }) => debugLog('Shard Disconnect', { wasClean, code, reason })); client.on('shardError', err => debugLog('Shard Error', { error: err.message })); client.once('ready', () => runTimedLoggedFn('OnStartup', async () => onStartup())); From 296acbfc77f697aba614cc195cfb497c895da449 Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Sat, 13 Jul 2024 12:37:39 +1000 Subject: [PATCH 032/145] Handle case when no items in stat command --- src/mahoji/lib/abstracted_commands/statCommand.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/mahoji/lib/abstracted_commands/statCommand.ts b/src/mahoji/lib/abstracted_commands/statCommand.ts index 5b98f8ae52..eda099d7cd 100644 --- a/src/mahoji/lib/abstracted_commands/statCommand.ts +++ b/src/mahoji/lib/abstracted_commands/statCommand.ts @@ -288,6 +288,9 @@ GROUP BY data->>'collectableID';`); async function makeResponseForBank(bank: Bank, title: string, content?: string) { sanitizeBank(bank); + if (bank.length === 0) { + return { content: 'No results.' }; + } const image = await makeBankImage({ title, bank From e66f1a6f670fd3513af8f326484ed4de49178657 Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Sat, 13 Jul 2024 12:37:46 +1000 Subject: [PATCH 033/145] Dont try to add 0gp for luckypick --- src/mahoji/lib/abstracted_commands/luckyPickCommand.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/mahoji/lib/abstracted_commands/luckyPickCommand.ts b/src/mahoji/lib/abstracted_commands/luckyPickCommand.ts index a2eb72196c..e6712eb792 100644 --- a/src/mahoji/lib/abstracted_commands/luckyPickCommand.ts +++ b/src/mahoji/lib/abstracted_commands/luckyPickCommand.ts @@ -147,7 +147,9 @@ export async function luckyPickCommand(user: MUser, luckypickamount: string, int button: ButtonInstance; }) => { const amountReceived = Math.floor(button.mod(amount)); - await user.addItemsToBank({ items: new Bank().add('Coins', amountReceived) }); + if (amountReceived > 0) { + await user.addItemsToBank({ items: new Bank().add('Coins', amountReceived) }); + } await updateClientGPTrackSetting('gp_luckypick', amountReceived - amount); await updateGPTrackSetting('gp_luckypick', amountReceived - amount, user); await sentMessage.edit({ components: getCurrentButtons({ showTrueNames: true }) }).catch(noOp); From 2e85a60691beabceee5c1ecca31fabf902077e8f Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Sat, 13 Jul 2024 12:38:02 +1000 Subject: [PATCH 034/145] Await Defer interaction in data command --- src/mahoji/commands/data.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mahoji/commands/data.ts b/src/mahoji/commands/data.ts index 2ba45ad0e3..4e1d5c88d4 100644 --- a/src/mahoji/commands/data.ts +++ b/src/mahoji/commands/data.ts @@ -30,7 +30,7 @@ export const dataCommand: OSBMahojiCommand = { ], run: async ({ interaction, options, userID }: CommandRunOptions<{ name: string }>) => { const user = await mUserFetch(userID); - deferInteraction(interaction); + await deferInteraction(interaction); return statsCommand(user, options.name); } }; From f8e8ec3eb358264b010d095f766ebbda34238734 Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Sat, 13 Jul 2024 12:41:59 +1000 Subject: [PATCH 035/145] Use faster query for caching active users --- src/lib/util/cachedUserIDs.ts | 21 +++++---------------- 1 file changed, 5 insertions(+), 16 deletions(-) diff --git a/src/lib/util/cachedUserIDs.ts b/src/lib/util/cachedUserIDs.ts index 7d87fb5f48..c57f7b1941 100644 --- a/src/lib/util/cachedUserIDs.ts +++ b/src/lib/util/cachedUserIDs.ts @@ -1,4 +1,3 @@ -import { Stopwatch } from '@oldschoolgg/toolkit'; import { ChannelType } from 'discord.js'; import { objectEntries } from 'e'; @@ -11,15 +10,15 @@ CACHED_ACTIVE_USER_IDS.add(globalConfig.clientID); for (const id of OWNER_IDS) CACHED_ACTIVE_USER_IDS.add(id); export async function syncActiveUserIDs() { - const [users, otherUsers] = await Promise.all([ + const [users, otherUsers] = await prisma.$transaction([ prisma.$queryRaw<{ user_id: string }[]>`SELECT DISTINCT(user_id::text) -FROM command_usage -WHERE date > now() - INTERVAL '72 hours';`, +FROM activity +WHERE finish_date > now() - INTERVAL '48 hours'`, prisma.$queryRaw<{ id: string }[]>`SELECT id FROM users WHERE main_account IS NOT NULL - OR CARDINALITY(ironman_alts) > 0 - OR bitfield && ARRAY[2,3,4,5,6,7,8,12,11,21,19];` +OR CARDINALITY(ironman_alts) > 0 +OR bitfield && ARRAY[2,3,4,5,6,7,8,12,11,21,19];` ]); for (const id of [...users.map(i => i.user_id), ...otherUsers.map(i => i.id)]) { @@ -97,11 +96,6 @@ export const emojiServers = new Set([ export function cacheCleanup() { if (!globalClient.isReady()) return; - const stopwatch = new Stopwatch(); - stopwatch.start(); - debugLog('Cache Cleanup Start', { - type: 'CACHE_CLEANUP' - }); return runTimedLoggedFn('Cache Cleanup', async () => { await runTimedLoggedFn('Clear Channels', async () => { for (const channel of globalClient.channels.cache.values()) { @@ -168,10 +162,5 @@ export function cacheCleanup() { } } }); - - stopwatch.stop(); - debugLog(`Cache Cleanup Finish After ${stopwatch.toString()}`, { - type: 'CACHE_CLEANUP' - }); }); } From f204a545e9fc1f2ebde0a8afb31616afbd85f2cb Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Sat, 13 Jul 2024 12:46:19 +1000 Subject: [PATCH 036/145] Typesafety on active user queries --- src/lib/util/cachedUserIDs.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/lib/util/cachedUserIDs.ts b/src/lib/util/cachedUserIDs.ts index c57f7b1941..3158f7c3d2 100644 --- a/src/lib/util/cachedUserIDs.ts +++ b/src/lib/util/cachedUserIDs.ts @@ -1,3 +1,4 @@ +import { Prisma } from '@prisma/client'; import { ChannelType } from 'discord.js'; import { objectEntries } from 'e'; @@ -11,14 +12,14 @@ for (const id of OWNER_IDS) CACHED_ACTIVE_USER_IDS.add(id); export async function syncActiveUserIDs() { const [users, otherUsers] = await prisma.$transaction([ - prisma.$queryRaw<{ user_id: string }[]>`SELECT DISTINCT(user_id::text) + prisma.$queryRaw<{ user_id: string }[]>`SELECT DISTINCT(${Prisma.ActivityScalarFieldEnum.user_id}::text) FROM activity WHERE finish_date > now() - INTERVAL '48 hours'`, prisma.$queryRaw<{ id: string }[]>`SELECT id FROM users -WHERE main_account IS NOT NULL -OR CARDINALITY(ironman_alts) > 0 -OR bitfield && ARRAY[2,3,4,5,6,7,8,12,11,21,19];` +WHERE ${Prisma.UserScalarFieldEnum.main_account} IS NOT NULL +OR CARDINALITY(${Prisma.UserScalarFieldEnum.ironman_alts}) > 0 +OR ${Prisma.UserScalarFieldEnum.bitfield} && ARRAY[2,3,4,5,6,7,8,12,11,21,19];` ]); for (const id of [...users.map(i => i.user_id), ...otherUsers.map(i => i.id)]) { From 95e6f5276d4a1e17198f41a17ecb7d4c01364fbe Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Sat, 13 Jul 2024 15:16:16 +1000 Subject: [PATCH 037/145] More reworking/refactoring --- package.json | 2 +- prisma/schema.prisma | 2 + src/index.ts | 6 +- src/lib/MUser.ts | 30 +- src/lib/Task.ts | 1 - src/lib/constants.ts | 3 +- src/lib/grandExchange.ts | 4 - src/lib/party.ts | 25 +- src/lib/perkTiers.ts | 1 + src/lib/rolesTask.ts | 6 +- src/lib/util.ts | 43 ++- src/lib/util/cachedUserIDs.ts | 8 +- src/lib/util/makeBadgeString.ts | 9 + src/mahoji/commands/bingo.ts | 31 +- src/mahoji/commands/botleagues.ts | 18 +- src/mahoji/commands/leaderboard.ts | 338 +++++++----------- src/mahoji/commands/minion.ts | 11 +- src/mahoji/commands/rp.ts | 115 +++++- src/mahoji/commands/tools.ts | 64 ++-- .../lib/abstracted_commands/statCommand.ts | 24 +- src/mahoji/lib/events.ts | 16 +- src/mahoji/lib/postCommand.ts | 7 - src/mahoji/lib/preCommand.ts | 61 +--- tests/integration/grandExchange.test.ts | 4 - tests/integration/killSimulator.test.ts | 9 + tests/integration/migrateUser.test.ts | 9 - tests/unit/setup.ts | 3 + tests/unit/snapshots/cl.OSB.png | Bin 48247 -> 49444 bytes tests/unit/snapshots/cox.OSB.png | Bin 38596 -> 38735 bytes tests/unit/snapshots/toa.OSB.png | Bin 25445 -> 25594 bytes tests/unit/utils.ts | 3 +- yarn.lock | 8 +- 32 files changed, 449 insertions(+), 412 deletions(-) create mode 100644 src/lib/util/makeBadgeString.ts create mode 100644 tests/integration/killSimulator.test.ts diff --git a/package.json b/package.json index 47342ddc0d..4efdb48704 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ }, "dependencies": { "@napi-rs/canvas": "^0.1.53", - "@oldschoolgg/toolkit": "git+https://github.com/oldschoolgg/toolkit.git#8570a6a8b2def1bfcafdcc5e30bbf5dd614c12c7", + "@oldschoolgg/toolkit": "git+https://github.com/oldschoolgg/toolkit.git#87450a60c73136601714c77092793d2f432b70b5", "@prisma/client": "^5.16.1", "@sapphire/snowflake": "^3.5.3", "@sapphire/time-utilities": "^1.6.0", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 495c0a91a8..19b3e0e739 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -434,6 +434,8 @@ model User { cached_networth_value BigInt? + username String? @db.VarChar(32) + geListings GEListing[] bingo_participant BingoParticipant[] bingo Bingo[] diff --git a/src/index.ts b/src/index.ts index 4a00323a5a..f33252c2d2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -191,11 +191,11 @@ async function main() { if (process.env.TEST) return; await Promise.all([ runTimedLoggedFn('Sync Active User IDs', syncActiveUserIDs), - runTimedLoggedFn('Sync Activity Cache', syncActivityCache) + runTimedLoggedFn('Sync Activity Cache', syncActivityCache), + runTimedLoggedFn('Startup Scripts', runStartupScripts) ]); - await runTimedLoggedFn('Startup Scripts', runStartupScripts); - await runTimedLoggedFn('Log In', () => client.login(globalConfig.botToken)); + console.log(`Logged in as ${globalClient.user.username}`); } process.on('uncaughtException', err => { diff --git a/src/lib/MUser.ts b/src/lib/MUser.ts index 0bedb50310..f250588d47 100644 --- a/src/lib/MUser.ts +++ b/src/lib/MUser.ts @@ -15,7 +15,7 @@ import { userIsBusy } from './busyCounterCache'; import { ClueTiers } from './clues/clueTiers'; import type { CATier } from './combat_achievements/combatAchievements'; import { CombatAchievements } from './combat_achievements/combatAchievements'; -import { BitField, Emoji, badges, projectiles, usernameCache } from './constants'; +import { BitField, projectiles } from './constants'; import { bossCLItems } from './data/Collections'; import { allPetIDs } from './data/CollectionsExport'; import { getSimilarItems } from './data/similarItems'; @@ -41,11 +41,12 @@ import type { BankSortMethod } from './sorts'; import type { ChargeBank } from './structures/Bank'; import { Gear, defaultGear } from './structures/Gear'; import type { ItemBank, Skills } from './types'; -import { addItemToBank, convertXPtoLVL, itemNameFromID } from './util'; +import { addItemToBank, cacheUsername, convertXPtoLVL, itemNameFromID } from './util'; import { determineRunes } from './util/determineRunes'; import { getKCByName } from './util/getKCByName'; import getOSItem, { getItem } from './util/getOSItem'; import { logError } from './util/logError'; +import { makeBadgeString } from './util/makeBadgeString'; import { minionIsBusy } from './util/minionIsBusy'; import { minionName } from './util/minionUtils'; import type { TransactItemsArgs } from './util/transactItemsFromBank'; @@ -94,6 +95,7 @@ export class MUserClass { gear!: UserFullGearSetup; skillsAsXP!: Required; skillsAsLevels!: Required; + badgesString!: string; constructor(user: User) { this.user = user; @@ -101,6 +103,10 @@ export class MUserClass { this.updateProperties(); syncPerkTierOfUser(this); + + if (this.user.username) { + cacheUsername(this.id, this.user.username, this.badgesString); + } } private updateProperties() { @@ -130,6 +136,8 @@ export class MUserClass { this.skillsAsXP = this.getSkills(false); this.skillsAsLevels = this.getSkills(true); + + this.badgesString = makeBadgeString(this.user.badges, this.isIronman); } countSkillsAtleast99() { @@ -213,23 +221,15 @@ export class MUserClass { } get rawUsername() { - return globalClient.users.cache.get(this.id)?.username ?? usernameCache.get(this.id) ?? 'Unknown'; + return globalClient.users.cache.get(this.id)?.username ?? this.user.username ?? 'Unknown'; } get usernameOrMention() { - return usernameCache.get(this.id) ?? this.mention; - } - - get badgeString() { - const rawBadges = this.user.badges.map(num => badges[num]); - if (this.isIronman) { - rawBadges.push(Emoji.Ironman); - } - return rawBadges.join(' '); + return this.user.username ?? this.mention; } get badgedUsername() { - return `${this.badgeString} ${this.usernameOrMention}`; + return `${this.badgesString} ${this.usernameOrMention}`; } toString() { @@ -950,12 +950,12 @@ declare global { var GlobalMUserClass: typeof MUserClass; } -async function srcMUserFetch(userID: string) { +async function srcMUserFetch(userID: string, updates: Prisma.UserUpdateInput = {}) { const user = await prisma.user.upsert({ create: { id: userID }, - update: {}, + update: updates, where: { id: userID } diff --git a/src/lib/Task.ts b/src/lib/Task.ts index 374ffc4982..fce856a374 100644 --- a/src/lib/Task.ts +++ b/src/lib/Task.ts @@ -220,7 +220,6 @@ export async function processPendingActivities() { } export async function syncActivityCache() { const tasks = await prisma.activity.findMany({ where: { completed: false } }); - minionActivityCache.clear(); for (const task of tasks) { activitySync(task); diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 46bf0d9d2c..235f257d6f 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -19,6 +19,7 @@ export { PerkTier }; const TestingMainChannelID = DISCORD_SETTINGS.Channels?.TestingMain ?? '940760643525570591'; export const BOT_TYPE: 'BSO' | 'OSB' = 'OSB' as 'BSO' | 'OSB'; +export const BOT_TYPE_LOWERCASE: 'bso' | 'osb' = BOT_TYPE.toLowerCase() as 'bso' | 'osb'; export const Channel = { General: DISCORD_SETTINGS.Channels?.General ?? '342983479501389826', @@ -478,8 +479,6 @@ export const TWITCHERS_GLOVES = ['egg', 'ring', 'seed', 'clue'] as const; export type TwitcherGloves = (typeof TWITCHERS_GLOVES)[number]; export const busyImmuneCommands = ['admin', 'rp']; -export const usernameCache = new Map(); -export const badgesCache = new Map(); export const minionBuyButton = new ButtonBuilder() .setCustomId('BUY_MINION') .setLabel('Buy Minion') diff --git a/src/lib/grandExchange.ts b/src/lib/grandExchange.ts index b575080fde..c2581bdbb6 100644 --- a/src/lib/grandExchange.ts +++ b/src/lib/grandExchange.ts @@ -1,4 +1,3 @@ -import { Stopwatch } from '@oldschoolgg/toolkit'; import type { GEListing, GETransaction } from '@prisma/client'; import { GEListingType } from '@prisma/client'; import { ButtonBuilder, ButtonStyle, bold, userMention } from 'discord.js'; @@ -768,14 +767,11 @@ ${type} ${toKMB(quantity)} ${item.name} for ${toKMB(price)} each, for a total of } async extensiveVerification() { - const stopwatch = new Stopwatch(); - stopwatch.check('extensiveVerification start'); await Promise.all([ prisma.gETransaction.findMany().then(txs => txs.map(tx => sanityCheckTransaction(tx))), prisma.gEListing.findMany().then(listings => listings.map(listing => sanityCheckListing(listing))), this.checkGECanFullFilAllListings() ]); - stopwatch.check('extensiveVerification finish'); return true; } diff --git a/src/lib/party.ts b/src/lib/party.ts index aa246ddc24..dd42268c21 100644 --- a/src/lib/party.ts +++ b/src/lib/party.ts @@ -1,13 +1,14 @@ import { makeComponents } from '@oldschoolgg/toolkit'; import { UserError } from '@oldschoolgg/toolkit'; import type { TextChannel } from 'discord.js'; -import { ButtonBuilder, ButtonStyle, ComponentType, InteractionCollector, userMention } from 'discord.js'; +import { ButtonBuilder, ButtonStyle, ComponentType, InteractionCollector } from 'discord.js'; import { Time, debounce, noOp } from 'e'; import { production } from '../config'; import { BLACKLISTED_USERS } from './blacklists'; -import { SILENT_ERROR, usernameCache } from './constants'; +import { SILENT_ERROR } from './constants'; import type { MakePartyOptions } from './types'; +import { getUsername } from './util'; import { CACHED_ACTIVE_USER_IDS } from './util/cachedUserIDs'; const partyLockCache = new Set(); @@ -42,13 +43,13 @@ export async function setupParty(channel: TextChannel, leaderUser: MUser, option let deleted = false; let massStarted = false; - function getMessageContent() { + async function getMessageContent() { return { - content: `${options.message}\n\n**Users Joined:** ${usersWhoConfirmed - .map(u => usernameCache.get(u) ?? userMention(u)) - .join( - ', ' - )}\n\nThis party will automatically depart in 2 minutes, or if the leader clicks the start (start early) or stop button.`, + content: `${options.message}\n\n**Users Joined:** ${( + await Promise.all(usersWhoConfirmed.map(u => getUsername(u))) + ).join( + ', ' + )}\n\nThis party will automatically depart in 2 minutes, or if the leader clicks the start (start early) or stop button.`, components: makeComponents(buttons.map(i => i.button)), allowedMentions: { users: [] @@ -56,12 +57,12 @@ export async function setupParty(channel: TextChannel, leaderUser: MUser, option }; } - const confirmMessage = await channel.send(getMessageContent()); + const confirmMessage = await channel.send(await getMessageContent()); // Debounce message edits to prevent spam. - const updateUsersIn = debounce(() => { + const updateUsersIn = debounce(async () => { if (deleted) return; - confirmMessage.edit(getMessageContent()); + confirmMessage.edit(await getMessageContent()); }, 500); const removeUser = (userID: string) => { @@ -138,7 +139,7 @@ export async function setupParty(channel: TextChannel, leaderUser: MUser, option return; } - resolve(await Promise.all(usersWhoConfirmed.map(mUserFetch))); + resolve(await Promise.all(usersWhoConfirmed.map(id => mUserFetch(id)))); } collector.on('collect', async interaction => { diff --git a/src/lib/perkTiers.ts b/src/lib/perkTiers.ts index ea63dd3c98..6582178ce9 100644 --- a/src/lib/perkTiers.ts +++ b/src/lib/perkTiers.ts @@ -103,5 +103,6 @@ export function getUsersPerkTier( export function syncPerkTierOfUser(user: MUser) { const perkTier = getUsersPerkTier(user, true); perkTierCache.set(user.id, perkTier); + redis.setUser(user.id, { perk_tier: perkTier }); return perkTier; } diff --git a/src/lib/rolesTask.ts b/src/lib/rolesTask.ts index 317fc988a6..dc694bb83c 100644 --- a/src/lib/rolesTask.ts +++ b/src/lib/rolesTask.ts @@ -3,12 +3,12 @@ import { noOp, notEmpty } from 'e'; import { SupportServer, production } from '../config'; import { ClueTiers } from '../lib/clues/clueTiers'; -import { Roles, usernameCache } from '../lib/constants'; +import { Roles } from '../lib/constants'; import { getCollectionItems } from '../lib/data/Collections'; import { Minigames } from '../lib/settings/minigames'; import Skills from '../lib/skilling/skills'; -import { convertXPtoLVL } from '../lib/util'; +import { convertXPtoLVL, getUsername } from '../lib/util'; import { logError } from '../lib/util/logError'; import { TeamLoot } from './simulation/TeamLoot'; import type { ItemBank } from './types'; @@ -110,7 +110,7 @@ async function addRoles({ if (userMap) { const userArr = []; for (const [id, arr] of Object.entries(userMap)) { - const username = usernameCache.get(id) ?? 'Unknown'; + const username = await getUsername(id); userArr.push(`${username}(${arr.join(', ')})`); } str += `\n${userArr.join(',')}`; diff --git a/src/lib/util.ts b/src/lib/util.ts index 768fcf0338..265d8c8363 100644 --- a/src/lib/util.ts +++ b/src/lib/util.ts @@ -1,4 +1,4 @@ -import { Stopwatch, stripEmojis } from '@oldschoolgg/toolkit'; +import { Stopwatch, cleanUsername, stripEmojis } from '@oldschoolgg/toolkit'; import type { CommandResponse } from '@oldschoolgg/toolkit'; import type { BaseMessageOptions, @@ -18,10 +18,11 @@ import { Time, objectEntries } from 'e'; import type { Bank } from 'oldschooljs'; import { bool, integer, nativeMath, nodeCrypto, real } from 'random-js'; +import { LRUCache } from 'lru-cache'; import { ADMIN_IDS, OWNER_IDS, SupportServer } from '../config'; import type { MUserClass } from './MUser'; import { PaginatedMessage } from './PaginatedMessage'; -import { BitField, badgesCache, projectiles, usernameCache } from './constants'; +import { BOT_TYPE_LOWERCASE, BitField, globalConfig, projectiles } from './constants'; import { getSimilarItems } from './data/similarItems'; import type { DefenceGearStat, GearSetupType, OffenceGearStat } from './gear/types'; import { GearSetupTypes, GearStat } from './gear/types'; @@ -210,7 +211,7 @@ export function isValidNickname(str?: string) { ); } -export type PaginatedMessagePage = MessageEditOptions; +export type PaginatedMessagePage = MessageEditOptions | (() => Promise); export async function makePaginatedMessage(channel: TextChannel, pages: PaginatedMessagePage[], target?: string) { const m = new PaginatedMessage({ pages, channel }); @@ -329,17 +330,29 @@ export function skillingPetDropRate( return { petDropRate: dropRate }; } -function getBadges(user: MUser | string | bigint) { - if (typeof user === 'string' || typeof user === 'bigint') { - return badgesCache.get(user.toString()) ?? ''; +const badgesKey = `${BOT_TYPE_LOWERCASE.toLowerCase()}_badges` as 'osb_badges' | 'bso_badges'; + +const usernameWithBadgesCache = new LRUCache({ max: 2000 }); +export function cacheUsername(id: string, username: string, badges: string) { + const current = usernameWithBadgesCache.get(id); + const newValue = `${badges} ${username}`; + if (!current || current !== newValue) { + usernameWithBadgesCache.set(id, newValue); + redis.setUser(id, { username: cleanUsername(username), [badgesKey]: badges }); } - return user.badgeString; } - -export function getUsername(id: string | bigint, withBadges = true) { - let username = usernameCache.get(id.toString()) ?? 'Unknown'; - if (withBadges) username = `${getBadges(id)} ${username}`; - return username; +export async function getUsername(_id: string | bigint): Promise { + const id = _id.toString(); + const cached = usernameWithBadgesCache.get(id); + if (cached) return cached; + const user = await redis.getUser(id); + if (!user.username) return 'Unknown'; + const newValue = `${user[badgesKey]} ${user.username}`; + usernameWithBadgesCache.set(id, newValue); + return newValue; +} +export function getUsernameSync(_id: string | bigint) { + return usernameWithBadgesCache.get(_id.toString()) ?? 'Unknown'; } export function awaitMessageComponentInteraction({ @@ -367,12 +380,14 @@ export function awaitMessageComponentInteraction({ } export async function runTimedLoggedFn(name: string, fn: () => Promise) { - debugLog(`Starting ${name}...`); + const logger = globalConfig.isProduction ? debugLog : console.log; const stopwatch = new Stopwatch(); stopwatch.start(); await fn(); stopwatch.stop(); - debugLog(`Finished ${name} in ${stopwatch.toString()}`); + if (!globalConfig.isProduction || stopwatch.duration > 50) { + logger(`Took ${stopwatch} to do ${name}`); + } } export function isModOrAdmin(user: MUser) { diff --git a/src/lib/util/cachedUserIDs.ts b/src/lib/util/cachedUserIDs.ts index 3158f7c3d2..bf2d394118 100644 --- a/src/lib/util/cachedUserIDs.ts +++ b/src/lib/util/cachedUserIDs.ts @@ -12,14 +12,14 @@ for (const id of OWNER_IDS) CACHED_ACTIVE_USER_IDS.add(id); export async function syncActiveUserIDs() { const [users, otherUsers] = await prisma.$transaction([ - prisma.$queryRaw<{ user_id: string }[]>`SELECT DISTINCT(${Prisma.ActivityScalarFieldEnum.user_id}::text) + prisma.$queryRawUnsafe<{ user_id: string }[]>(`SELECT DISTINCT(${Prisma.ActivityScalarFieldEnum.user_id}::text) FROM activity -WHERE finish_date > now() - INTERVAL '48 hours'`, - prisma.$queryRaw<{ id: string }[]>`SELECT id +WHERE finish_date > now() - INTERVAL '48 hours'`), + prisma.$queryRawUnsafe<{ id: string }[]>(`SELECT id FROM users WHERE ${Prisma.UserScalarFieldEnum.main_account} IS NOT NULL OR CARDINALITY(${Prisma.UserScalarFieldEnum.ironman_alts}) > 0 -OR ${Prisma.UserScalarFieldEnum.bitfield} && ARRAY[2,3,4,5,6,7,8,12,11,21,19];` +OR ${Prisma.UserScalarFieldEnum.bitfield} && ARRAY[2,3,4,5,6,7,8,12,11,21,19];`) ]); for (const id of [...users.map(i => i.user_id), ...otherUsers.map(i => i.id)]) { diff --git a/src/lib/util/makeBadgeString.ts b/src/lib/util/makeBadgeString.ts new file mode 100644 index 0000000000..d8f5742b15 --- /dev/null +++ b/src/lib/util/makeBadgeString.ts @@ -0,0 +1,9 @@ +import { Emoji, badges } from '../constants'; + +export function makeBadgeString(badgeIDs: number[] | null | undefined, isIronman: boolean) { + const rawBadges: string[] = (badgeIDs ?? []).map(num => badges[num]); + if (isIronman) { + rawBadges.push(Emoji.Ironman); + } + return rawBadges.join(' '); +} diff --git a/src/mahoji/commands/bingo.ts b/src/mahoji/commands/bingo.ts index faa95659ce..6f56fdc656 100644 --- a/src/mahoji/commands/bingo.ts +++ b/src/mahoji/commands/bingo.ts @@ -13,9 +13,18 @@ import type { ItemBank } from 'oldschooljs/dist/meta/types'; import { production } from '../../config'; import { BLACKLISTED_USERS } from '../../lib/blacklists'; import { clImageGenerator } from '../../lib/collectionLogTask'; -import { BOT_TYPE, Emoji, usernameCache } from '../../lib/constants'; - -import { channelIsSendable, dateFm, isValidDiscordSnowflake, isValidNickname, md5sum, toKMB } from '../../lib/util'; +import { BOT_TYPE, Emoji } from '../../lib/constants'; + +import { + channelIsSendable, + dateFm, + getUsername, + getUsernameSync, + isValidDiscordSnowflake, + isValidNickname, + md5sum, + toKMB +} from '../../lib/util'; import { getItem } from '../../lib/util/getOSItem'; import { handleMahojiConfirmation } from '../../lib/util/handleMahojiConfirmation'; import { parseBank } from '../../lib/util/parseStringBank'; @@ -86,7 +95,7 @@ async function bingoTeamLeaderboard( .map( (team, j) => `${getPos(i, j)}** ${`${team.trophy?.emoji} ` ?? ''}${team.participants - .map(pt => usernameCache.get(pt.user_id) ?? '?') + .map(pt => getUsernameSync(pt.user_id)) .join(', ')}:** ${team.tilesCompletedCount.toLocaleString()}` ) .join('\n') @@ -804,9 +813,7 @@ The creator of the bingo (${userMention( teams .map(team => [ - team.participants - .map(u => usernameCache.get(u.user_id) ?? u.user_id) - .join(','), + team.participants.map(u => getUsernameSync(u.user_id)).join(','), team.tilesCompletedCount, team.trophy?.item.name ?? 'No Trophy' ].join('\t') @@ -946,22 +953,20 @@ Example: \`add_tile:Coal|Trout|Egg\` is a tile where you have to receive a coal ); for (const userID of team.participants.map(t => t.user_id)) { - const reclaimableItems: Prisma.ReclaimableItemCreateManyInput[] = trophiesToReceive.map( - trophy => ({ + const reclaimableItems: Prisma.ReclaimableItemCreateManyInput[] = await Promise.all( + trophiesToReceive.map(async trophy => ({ name: `Bingo Trophy (${trophy.item.name})`, quantity: 1, key: `bso-bingo-2-${trophy.item.id}`, item_id: trophy.item.id, description: `Awarded for placing in the top ${trophy.percentile}% of ${ bingo.title - }. Your team (${team.participants - .map(t => usernameCache.get(t.user_id) ?? t.user_id) - .join(', ')}) placed ${formatOrdinal(team.rank)} with ${ + }. Your team (${(await Promise.all(team.participants.map(async t => await getUsername(t.user_id)))).join(', ')}) placed ${formatOrdinal(team.rank)} with ${ team.tilesCompletedCount } tiles completed.`, date: bingo.endDate.toISOString(), user_id: userID - }) + })) ); toInsert.push(...reclaimableItems); } diff --git a/src/mahoji/commands/botleagues.ts b/src/mahoji/commands/botleagues.ts index 69b6ef567d..a6bda61cc3 100644 --- a/src/mahoji/commands/botleagues.ts +++ b/src/mahoji/commands/botleagues.ts @@ -169,13 +169,17 @@ ${leaguesTrophiesBuyables interaction, user, channelID, - chunk(result, 10).map(subList => - subList - .map( - ({ id, leagues_points_total }) => - `**${getUsername(id)}:** ${leagues_points_total.toLocaleString()} Pts` - ) - .join('\n') + await Promise.all( + chunk(result, 10).map(async subList => + ( + await Promise.all( + subList.map( + async ({ id, leagues_points_total }) => + `**${await getUsername(id)}:** ${leagues_points_total.toLocaleString()} Pts` + ) + ) + ).join('\n') + ) ), 'Leagues Points Leaderboard' ); diff --git a/src/mahoji/commands/leaderboard.ts b/src/mahoji/commands/leaderboard.ts index 6edf5b07fd..20a783f512 100644 --- a/src/mahoji/commands/leaderboard.ts +++ b/src/mahoji/commands/leaderboard.ts @@ -1,14 +1,13 @@ import { toTitleCase } from '@oldschoolgg/toolkit'; import type { CommandRunOptions } from '@oldschoolgg/toolkit'; -import type { Prisma } from '@prisma/client'; import type { ChatInputCommandInteraction } from 'discord.js'; import { EmbedBuilder } from 'discord.js'; import { ApplicationCommandOptionType } from 'discord.js'; -import { Time, calcWhatPercent, chunk, objectValues } from 'e'; +import { calcWhatPercent, chunk, isFunction } from 'e'; import type { ClueTier } from '../../lib/clues/clueTiers'; import { ClueTiers } from '../../lib/clues/clueTiers'; -import { Emoji, badges, badgesCache, masteryKey, usernameCache } from '../../lib/constants'; +import { masteryKey } from '../../lib/constants'; import { allClNames, getCollectionItems } from '../../lib/data/Collections'; import { effectiveMonsters } from '../../lib/minions/data/killableMonsters'; import { allOpenables } from '../../lib/openables'; @@ -23,9 +22,9 @@ import { convertXPtoLVL, formatDuration, getUsername, + getUsernameSync, makePaginatedMessage, - stringMatches, - stripEmojis + stringMatches } from '../../lib/util'; import { fetchCLLeaderboard } from '../../lib/util/clLeaderboard'; import { deferInteraction } from '../../lib/util/interactionReply'; @@ -52,7 +51,7 @@ export async function doMenu( interaction: ChatInputCommandInteraction, user: MUser, channelID: string, - pages: string[], + pages: string[] | (() => Promise)[], title: string ) { if (pages.length === 0) { @@ -63,11 +62,50 @@ export async function doMenu( makePaginatedMessage( channel, - pages.map(p => ({ embeds: [new EmbedBuilder().setTitle(title).setDescription(p)] })), + pages.map(p => { + if (isFunction(p)) { + return async () => ({ embeds: [new EmbedBuilder().setTitle(title).setDescription(await p())] }); + } + + return { embeds: [new EmbedBuilder().setTitle(title).setDescription(p)] }; + }), user.id ); } +function doMenuWrapper({ + user, + interaction, + channelID, + users, + title, + ironmanOnly, + formatter +}: { + ironmanOnly: boolean; + users: { id: string; score: number }[]; + title: string; + interaction: ChatInputCommandInteraction; + user: MUser; + channelID: string; + formatter?: (val: number) => string; +}) { + const chunked = chunk(users, LB_PAGE_SIZE); + const pages = []; + for (const chnk of chunked) { + const page = chnk + .map( + (user, i) => async () => + `${getPos(i, i)}**${await getUsername(user.id)}:** ${formatter ? formatter(user.score) : user.score.toLocaleString()}` + ) + .join('\n'); + pages.push(page); + } + doMenu(interaction, user, channelID, pages, title); + + return lbMsg(title, ironmanOnly); +} + async function kcLb( interaction: ChatInputCommandInteraction, user: MUser, @@ -77,8 +115,8 @@ async function kcLb( ) { const monster = effectiveMonsters.find(mon => [mon.name, ...mon.aliases].some(alias => stringMatches(alias, name))); if (!monster) return "That's not a valid monster!"; - const list = await prisma.$queryRawUnsafe<{ id: string; kc: number }[]>( - `SELECT user_id::text AS id, CAST("monster_scores"->>'${monster.id}' AS INTEGER) as kc + const list = await prisma.$queryRawUnsafe<{ id: string; score: number }[]>( + `SELECT user_id::text AS id, CAST("monster_scores"->>'${monster.id}' AS INTEGER) as score FROM user_stats ${ironmanOnly ? 'INNER JOIN "users" on "users"."id" = "user_stats"."user_id"::text' : ''} WHERE CAST("monster_scores"->>'${monster.id}' AS INTEGER) > 5 @@ -87,19 +125,14 @@ async function kcLb( LIMIT 2000;` ); - doMenu( - interaction, + return doMenuWrapper({ + ironmanOnly, user, + interaction, channelID, - chunk(list, LB_PAGE_SIZE).map((subList, i) => - subList - .map((user, j) => `${getPos(i, j)}**${getUsername(user.id)}:** ${user.kc.toLocaleString()}`) - .join('\n') - ), - `KC Leaderboard for ${monster.name}` - ); - - return lbMsg(`${monster.name} KC `, ironmanOnly); + users: list, + title: `KC Leaderboard for ${monster.name}` + }); } async function farmingContractLb( @@ -124,7 +157,7 @@ async function farmingContractLb( chunk(list, LB_PAGE_SIZE).map((subList, i) => subList .map(({ id, count }, j) => { - return `${getPos(i, j)}**${getUsername(id)}:** ${count.toLocaleString()}`; + return `${getPos(i, j)}**${getUsernameSync(id)}:** ${count.toLocaleString()}`; }) .join('\n') ), @@ -147,7 +180,7 @@ LIMIT 10;`); } return `**Inferno Records**\n\n${res - .map((e, i) => `${i + 1}. **${getUsername(e.user_id)}:** ${formatDuration(e.duration)}`) + .map((e, i) => `${i + 1}. **${getUsernameSync(e.user_id)}:** ${formatDuration(e.duration)}`) .join('\n')}`; } @@ -176,7 +209,10 @@ async function sacrificeLb( channelID, chunk(list, LB_PAGE_SIZE).map((subList, i) => subList - .map(({ id, amount }, j) => `${getPos(i, j)}**${getUsername(id)}:** ${amount.toLocaleString()} GP `) + .map( + ({ id, amount }, j) => + `${getPos(i, j)}**${getUsernameSync(id)}:** ${amount.toLocaleString()} GP ` + ) .join('\n') ), 'Sacrifice Leaderboard' @@ -202,7 +238,7 @@ async function sacrificeLb( subList .map( ({ id, sacbanklength }, j) => - `${getPos(i, j)}**${getUsername(id)}:** ${sacbanklength.toLocaleString()} Unique Sac's` + `${getPos(i, j)}**${getUsernameSync(id)}:** ${sacbanklength.toLocaleString()} Unique Sac's` ) .join('\n') ), @@ -231,7 +267,7 @@ async function minigamesLb(interaction: ChatInputCommandInteraction, user: MUser channelID, chunk(titheCompletions, LB_PAGE_SIZE).map((subList, i) => subList - .map(({ id, amount }, j) => `${getPos(i, j)}**${getUsername(id)}:** ${amount.toLocaleString()}`) + .map(({ id, amount }, j) => `${getPos(i, j)}**${getUsernameSync(id)}:** ${amount.toLocaleString()}`) .join('\n') ), 'Tithe farm Leaderboard' @@ -250,18 +286,14 @@ async function minigamesLb(interaction: ChatInputCommandInteraction, user: MUser take: 10 }); - doMenu( - interaction, + return doMenuWrapper({ + ironmanOnly: false, user, + interaction, channelID, - chunk(res, LB_PAGE_SIZE).map((subList, i) => - subList - .map((u, j) => `${getPos(i, j)}**${getUsername(u.user_id)}:** ${u[minigame.column].toLocaleString()}`) - .join('\n') - ), - `${minigame.name} Leaderboard` - ); - return lbMsg(`${minigame.name} Leaderboard`); + users: res.map(u => ({ id: u.user_id, score: u[minigame.column] })), + title: `${minigame.name} Leaderboard` + }); } async function clLb( @@ -288,24 +320,16 @@ async function clLb( const users = await fetchCLLeaderboard({ ironmenOnly, items, resultLimit: 200, userEvents: userEventOrders }); inputType = toTitleCase(inputType.toLowerCase()); - doMenu( - interaction, + + return doMenuWrapper({ + ironmanOnly: ironmenOnly, user, + interaction, channelID, - chunk(users, LB_PAGE_SIZE).map((subList, i) => - subList - .map( - ({ id, qty }, j) => - `${getPos(i, j)}**${getUsername(id)}:** ${qty.toLocaleString()} (${calcWhatPercent( - qty, - items.length - ).toFixed(1)}%)` - ) - .join('\n') - ), - `${inputType} Collection Log Leaderboard (${items.length} slots)` - ); - return lbMsg(`${inputType} Collection Log Leaderboard`, ironmenOnly); + users: users.map(u => ({ id: u.id, score: u.qty })), + title: `${inputType} Collection Log Leaderboard`, + formatter: val => `${val.toLocaleString()} (${calcWhatPercent(val, items.length).toFixed(1)}%)` + }); } async function creaturesLb( @@ -332,7 +356,7 @@ async function creaturesLb( channelID, chunk(data, LB_PAGE_SIZE).map((subList, i) => subList - .map(({ id, count }, j) => `${getPos(i, j)}**${getUsername(id)}:** ${count.toLocaleString()}`) + .map(({ id, count }, j) => `${getPos(i, j)}**${getUsernameSync(id)}:** ${count.toLocaleString()}`) .join('\n') ), `Catch Leaderboard for ${creature.name}` @@ -357,7 +381,7 @@ async function lapsLb(interaction: ChatInputCommandInteraction, user: MUser, cha channelID, chunk(data, LB_PAGE_SIZE).map((subList, i) => subList - .map(({ id, count }, j) => `${getPos(i, j)}**${getUsername(id)}:** ${count.toLocaleString()}`) + .map(({ id, count }, j) => `${getPos(i, j)}**${getUsernameSync(id)}:** ${count.toLocaleString()}`) .join('\n') ), `${course.name} Laps Leaderboard` @@ -403,18 +427,14 @@ async function openLb( ORDER BY qty DESC LIMIT 30;` ); - doMenu( - interaction, + return doMenuWrapper({ + ironmanOnly, user, + interaction, channelID, - chunk(list, LB_PAGE_SIZE).map((subList, i) => - subList - .map((user, j) => `${getPos(i, j)}**${getUsername(user.id)}:** ${user.qty.toLocaleString()}`) - .join('\n') - ), - `Open Leaderboard for ${openableName}` - ); - return lbMsg(`${openableName} Opening`); + users: list.map(u => ({ id: u.id, score: u.qty })), + title: `${openableName} Opening Leaderboard` + }); } async function gpLb(interaction: ChatInputCommandInteraction, user: MUser, channelID: string, ironmanOnly: boolean) { @@ -425,22 +445,19 @@ async function gpLb(interaction: ChatInputCommandInteraction, user: MUser, chann WHERE "GP" > 1000000 ${ironmanOnly ? ' AND "minion.ironman" = true ' : ''} ORDER BY "GP" DESC - LIMIT 500;` + LIMIT 100;` ) - ).map(res => ({ ...res, GP: Number(res.GP) })); + ).map(res => ({ ...res, score: Number(res.GP) })); - doMenu( - interaction, + return doMenuWrapper({ + ironmanOnly, user, + interaction, channelID, - chunk(users, LB_PAGE_SIZE).map((subList, i) => - subList - .map(({ id, GP }, j) => `${getPos(i, j)}**${getUsername(id)}:** ${GP.toLocaleString()} GP`) - .join('\n') - ), - 'GP Leaderboard' - ); - return lbMsg('GP Leaderboard', ironmanOnly); + users, + title: 'GP Leaderboard', + formatter: val => `${val.toLocaleString()} GP` + }); } async function skillsLb( @@ -575,7 +592,7 @@ async function skillsLb( chunk(overallUsers, LB_PAGE_SIZE).map((subList, i) => subList .map((obj, j) => { - return `${getPos(i, j)}**${getUsername( + return `${getPos(i, j)}**${getUsernameSync( obj.id )}:** ${obj.totalLevel.toLocaleString()} (${obj.totalXP.toLocaleString()} XP)`; }) @@ -596,7 +613,7 @@ async function skillsLb( const objKey = `skills.${skill?.id}`; const skillXP = Number(obj[objKey] ?? 0); - return `${getPos(i, j)}**${getUsername(obj.id)}:** ${skillXP.toLocaleString()} XP (${convertXPtoLVL( + return `${getPos(i, j)}**${getUsernameSync(obj.id)}:** ${skillXP.toLocaleString()} XP (${convertXPtoLVL( skillXP )})`; }) @@ -635,7 +652,10 @@ LIMIT 50;` channelID, chunk(users, LB_PAGE_SIZE).map((subList, i) => subList - .map(({ id, score }, j) => `${getPos(i, j)}**${getUsername(id)}:** ${score.toLocaleString()} Completed`) + .map( + ({ id, score }, j) => + `${getPos(i, j)}**${getUsernameSync(id)}:** ${score.toLocaleString()} Completed` + ) .join('\n') ), `${clueTier.name} Clue Leaderboard` @@ -643,105 +663,6 @@ LIMIT 50;` return lbMsg('Clue Leaderboard', ironmanOnly); } -export async function cacheUsernames() { - const roboChimpUseresToCache = await roboChimpClient.user.findMany({ - where: { - OR: [ - { - osb_cl_percent: { - gte: 80 - } - }, - { - bso_total_level: { - gte: 80 - } - }, - { - osb_total_level: { - gte: 1500 - } - }, - { - bso_total_level: { - gte: 1500 - } - }, - { - leagues_points_total: { - gte: 20_000 - } - } - ] - }, - select: { - id: true - } - }); - - const orConditions: Prisma.UserWhereInput[] = []; - for (const skill of objectValues(SkillsEnum)) { - orConditions.push({ - [`skills_${skill}`]: { - gte: 15_000_000 - } - }); - } - const usersToCache = await prisma.user.findMany({ - where: { - OR: [ - ...orConditions, - { - last_command_date: { - gt: new Date(Date.now() - Number(Time.Month)) - } - } - ], - id: { - notIn: roboChimpUseresToCache.map(i => i.id.toString()) - } - }, - select: { - id: true - } - }); - - const userIDsToCache = [...usersToCache, ...roboChimpUseresToCache].map(i => i.id.toString()); - debugLog(`Caching usernames of ${userIDsToCache.length} users`); - - const allNewUsers = await prisma.newUser.findMany({ - where: { - username: { - not: null - }, - id: { - in: userIDsToCache - } - }, - select: { - id: true, - username: true - } - }); - - const arrayOfIronmenAndBadges: { badges: number[]; id: string; ironman: boolean }[] = await prisma.$queryRawUnsafe( - 'SELECT "badges", "id", "minion.ironman" as "ironman" FROM users WHERE ARRAY_LENGTH(badges, 1) > 0 OR "minion.ironman" = true;' - ); - - for (const user of allNewUsers) { - const badgeUser = arrayOfIronmenAndBadges.find(i => i.id === user.id); - const name = stripEmojis(user.username!); - usernameCache.set(user.id, name); - if (badgeUser) { - const rawBadges = badgeUser.badges.map(num => badges[num]); - if (badgeUser.ironman) { - rawBadges.push(Emoji.Ironman); - } - badgesCache.set(user.id, rawBadges.join(' ')); - } - } -} - const globalLbTypes = ['xp', 'cl', 'mastery'] as const; type GlobalLbType = (typeof globalLbTypes)[number]; async function globalLb(interaction: ChatInputCommandInteraction, user: MUser, channelID: string, type: GlobalLbType) { @@ -772,7 +693,7 @@ LIMIT 10; subList .map( ({ id, osb_xp_percent, bso_xp_percent }, j) => - `${getPos(i, j)}**${getUsername(id)}:** ${osb_xp_percent.toFixed( + `${getPos(i, j)}**${getUsernameSync(id)}:** ${osb_xp_percent.toFixed( 2 )}% OSB, ${bso_xp_percent.toFixed(2)}% BSO` ) @@ -800,7 +721,9 @@ LIMIT 10; user, channelID, chunk(result, LB_PAGE_SIZE).map((subList, i) => - subList.map(({ id, avg }, j) => `${getPos(i, j)}**${getUsername(id)}:** ${avg.toFixed(2)}%`).join('\n') + subList + .map(({ id, avg }, j) => `${getPos(i, j)}**${getUsernameSync(id)}:** ${avg.toFixed(2)}%`) + .join('\n') ), 'Global (OSB+BSO) Mastery Leaderboard' ); @@ -821,7 +744,7 @@ LIMIT 20;`; subList .map( ({ id, total_cl_percent }, j) => - `${getPos(i, j)}**${getUsername(id)}:** ${total_cl_percent.toLocaleString()}%` + `${getPos(i, j)}**${getUsernameSync(id)}:** ${total_cl_percent.toLocaleString()}%` ) .join('\n') ), @@ -901,7 +824,7 @@ LIMIT 10; subList .map( ({ user_id, cl_completion_count, cl_global_rank, count_increase, rank_difference }, j) => - `${getPos(i, j)}**${getUsername( + `${getPos(i, j)}**${getUsernameSync( user_id )}:** Gained ${count_increase} CL slots, from ${cl_completion_count} to ${ cl_completion_count + count_increase @@ -932,7 +855,8 @@ LIMIT 50;` chunk(users, LB_PAGE_SIZE).map((subList, i) => subList .map( - ({ id, qty }, j) => `${getPos(i, j)}**${getUsername(id)}:** ${qty.toLocaleString()} Tasks Completed` + ({ id, qty }, j) => + `${getPos(i, j)}**${getUsernameSync(id)}:** ${qty.toLocaleString()} Tasks Completed` ) .join('\n') ), @@ -942,36 +866,32 @@ LIMIT 50;` } async function masteryLb(interaction: ChatInputCommandInteraction, user: MUser, channelID: string) { - const users = await roboChimpClient.user.findMany({ - where: { - [masteryKey]: { not: null } - }, - orderBy: { - [masteryKey]: 'desc' - }, - take: 50, - select: { - id: true, - osb_mastery: true, - bso_mastery: true - } - }); + const users = ( + await roboChimpClient.user.findMany({ + where: { + [masteryKey]: { not: null } + }, + orderBy: { + [masteryKey]: 'desc' + }, + take: 50, + select: { + id: true, + osb_mastery: true, + bso_mastery: true + } + }) + ).map(u => ({ id: u.id.toString(), score: u[masteryKey] ?? 0 })); - doMenu( + return doMenuWrapper({ interaction, - user, + title: 'Mastery Leaderboard', channelID, - chunk(users, LB_PAGE_SIZE).map((subList, i) => - subList - .map( - (lUser, j) => - `${getPos(i, j)}**${getUsername(lUser.id)}:** ${lUser[masteryKey]?.toFixed(3)}% mastery` - ) - .join('\n') - ), - 'Mastery Leaderboard' - ); - return lbMsg('Mastery Leaderboard'); + ironmanOnly: false, + user, + users, + formatter: val => `${val.toFixed(3)}% mastery` + }); } const ironmanOnlyOption = { diff --git a/src/mahoji/commands/minion.ts b/src/mahoji/commands/minion.ts index c136cc9b88..ef993a5d43 100644 --- a/src/mahoji/commands/minion.ts +++ b/src/mahoji/commands/minion.ts @@ -12,7 +12,6 @@ import { FormattedCustomEmoji, MAX_LEVEL, PerkTier, - badges, minionActivityCache } from '../../lib/constants'; import { degradeableItems } from '../../lib/degradeableItems'; @@ -82,20 +81,18 @@ export async function getUserInfo(user: MUser) { const task = minionActivityCache.get(user.id); const taskText = task ? `${task.type}` : 'None'; - const userBadges = user.user.badges.map(i => badges[i]); - const premiumDate = Number(user.user.premium_balance_expiry_date); const premiumTier = user.user.premium_balance_tier; const result = { perkTier: user.perkTier(), isBlacklisted: BLACKLISTED_USERS.has(user.id), - badges: userBadges, + badges: user.badgesString, mainAccount: user.user.main_account !== null - ? `${getUsername(user.user.main_account)}[${user.user.main_account}]` + ? `${user.user.username ?? 'Unknown Username'}[${user.user.main_account}]` : 'None', - ironmanAlts: user.user.ironman_alts.map(id => `${getUsername(id)}[${id}]`), + ironmanAlts: user.user.ironman_alts.map(async id => `${await getUsername(id)}[${id}]`), premiumBalance: `${premiumDate ? new Date(premiumDate).toLocaleString() : ''} ${ premiumTier ? `Tier ${premiumTier}` : '' }`, @@ -116,7 +113,7 @@ export async function getUserInfo(user: MUser) { **Current Trip:** ${taskText} **Perk Tier:** ${result.perkTier} **Blacklisted:** ${result.isBlacklisted} -**Badges:** ${result.badges.join(' ')} +**Badges:** ${result.badges} **Main Account:** ${result.mainAccount} **Ironman Alts:** ${result.ironmanAlts} **Patron Balance:** ${result.premiumBalance} diff --git a/src/mahoji/commands/rp.ts b/src/mahoji/commands/rp.ts index 32a11433f3..c000ca9c45 100644 --- a/src/mahoji/commands/rp.ts +++ b/src/mahoji/commands/rp.ts @@ -1,12 +1,12 @@ import { toTitleCase } from '@oldschoolgg/toolkit'; import type { CommandRunOptions } from '@oldschoolgg/toolkit'; import type { MahojiUserOption } from '@oldschoolgg/toolkit'; -import { UserEventType, xp_gains_skill_enum } from '@prisma/client'; +import { type Prisma, UserEventType, xp_gains_skill_enum } from '@prisma/client'; import { DiscordSnowflake } from '@sapphire/snowflake'; import { Duration } from '@sapphire/time-utilities'; import { SnowflakeUtil, codeBlock } from 'discord.js'; import { ApplicationCommandOptionType } from 'discord.js'; -import { Time, randArrItem, sumArr } from 'e'; +import { Time, objectValues, randArrItem, sumArr } from 'e'; import { Bank } from 'oldschooljs'; import type { Item } from 'oldschooljs/dist/meta/types'; @@ -24,6 +24,7 @@ import { allPerkBitfields } from '../../lib/perkTiers'; import { premiumPatronTime } from '../../lib/premiumPatronTime'; import { TeamLoot } from '../../lib/simulation/TeamLoot'; +import { SkillsEnum } from '../../lib/skilling/types'; import type { ItemBank } from '../../lib/types'; import { dateFm, isValidDiscordSnowflake, returnStringOrFile } from '../../lib/util'; import getOSItem from '../../lib/util/getOSItem'; @@ -31,6 +32,7 @@ import { handleMahojiConfirmation } from '../../lib/util/handleMahojiConfirmatio import { deferInteraction } from '../../lib/util/interactionReply'; import itemIsTradeable from '../../lib/util/itemIsTradeable'; import { syncLinkedAccounts } from '../../lib/util/linkedAccountsUtil'; +import { makeBadgeString } from '../../lib/util/makeBadgeString'; import { makeBankImage } from '../../lib/util/makeBankImage'; import { migrateUser } from '../../lib/util/migrateUser'; import { parseBank } from '../../lib/util/parseStringBank'; @@ -51,6 +53,104 @@ const itemFilters = [ } ]; +async function redisSync() { + const roboChimpUsersToCache = ( + await roboChimpClient.user.findMany({ + where: { + OR: [ + { + osb_cl_percent: { + gte: 80 + } + }, + { + bso_total_level: { + gte: 80 + } + }, + { + osb_total_level: { + gte: 1500 + } + }, + { + bso_total_level: { + gte: 1500 + } + }, + { + leagues_points_total: { + gte: 20_000 + } + } + ] + }, + select: { + id: true + } + }) + ).map(i => i.id.toString()); + + const orConditions: Prisma.UserWhereInput[] = []; + for (const skill of objectValues(SkillsEnum)) { + orConditions.push({ + [`skills_${skill}`]: { + gte: 15_000_000 + } + }); + } + const usersToCache = ( + await prisma.user.findMany({ + where: { + OR: [ + ...orConditions, + { + last_command_date: { + gt: new Date(Date.now() - Number(Time.Month)) + } + } + ], + id: { + notIn: roboChimpUsersToCache + } + }, + select: { + id: true + } + }) + ).map(i => i.id); + + const response: string[] = []; + const allNewUsers = await prisma.newUser.findMany({ + where: { + username: { + not: null + }, + id: { + in: [...usersToCache, ...roboChimpUsersToCache] + } + }, + select: { + id: true, + username: true + } + }); + + for (const user of allNewUsers) { + redis.setUser(user.id, { username: user.username }); + } + response.push(`Cached ${allNewUsers.length} usernames.`); + + const arrayOfIronmenAndBadges: { badges: number[]; id: string; ironman: boolean }[] = await prisma.$queryRawUnsafe( + 'SELECT "badges", "id", "minion.ironman" as "ironman" FROM users WHERE ARRAY_LENGTH(badges, 1) > 0 OR "minion.ironman" = true;' + ); + for (const user of arrayOfIronmenAndBadges) { + redis.setUser(user.id, { osb_badges: makeBadgeString(user.badges, user.ironman) }); + } + response.push(`Cached ${arrayOfIronmenAndBadges.length} badges.`); + return response.join(', '); +} + function isProtectedAccount(user: MUser) { const botAccounts = ['303730326692429825', '729244028989603850', '969542224058654790']; if ([...ADMIN_IDS, ...OWNER_IDS, ...botAccounts].includes(user.id)) return true; @@ -97,6 +197,12 @@ export const rpCommand: OSBMahojiCommand = { name: 'networth_sync', description: 'networth_sync.', options: [] + }, + { + type: ApplicationCommandOptionType.Subcommand, + name: 'redis_sync', + description: 'redis sync.', + options: [] } ] }, @@ -455,6 +561,7 @@ export const rpCommand: OSBMahojiCommand = { view_all_items?: {}; analytics_tick?: {}; networth_sync?: {}; + redis_sync?: {}; }; player?: { viewbank?: { user: MahojiUserOption; json?: boolean }; @@ -563,6 +670,10 @@ Date: ${dateFm(date)}`; await analyticsTick(); return 'Finished.'; } + if (options.action?.redis_sync) { + const result = await redisSync(); + return result; + } if (options.action?.networth_sync) { const users = await prisma.user.findMany({ where: { diff --git a/src/mahoji/commands/tools.ts b/src/mahoji/commands/tools.ts index f375368be0..d86763f7c4 100644 --- a/src/mahoji/commands/tools.ts +++ b/src/mahoji/commands/tools.ts @@ -174,9 +174,14 @@ async function clueGains(interval: string, tier?: string, ironmanOnly?: boolean) const embed = new EmbedBuilder() .setTitle(title) .setDescription( - res - .map((i: any) => `${++place}. **${getUsername(i.user_id)}**: ${Number(i.qty).toLocaleString()}`) - .join('\n') + ( + await Promise.all( + res.map( + async (i: any) => + `${++place}. **${await getUsername(i.user_id)}**: ${Number(i.qty).toLocaleString()}` + ) + ) + ).join('\n') ); return { embeds: [embed] }; @@ -249,12 +254,14 @@ async function xpGains(interval: string, skill?: string, ironmanOnly?: boolean) const embed = new EmbedBuilder() .setTitle(`Highest ${skillObj ? skillObj.name : 'Overall'} XP Gains in the past ${interval}`) .setDescription( - xpRecords - .map( - record => - `${++place}. **${getUsername(record.user)}**: ${Number(record.total_xp).toLocaleString()} XP` + ( + await Promise.all( + xpRecords.map( + async record => + `${++place}. **${await getUsername(record.user)}**: ${Number(record.total_xp).toLocaleString()} XP` + ) ) - .join('\n') + ).join('\n') ); return { embeds: [embed] }; @@ -305,9 +312,14 @@ async function kcGains(interval: string, monsterName: string, ironmanOnly?: bool const embed = new EmbedBuilder() .setTitle(`Highest ${monster.name} KC gains in the past ${interval}`) .setDescription( - res - .map((i: any) => `${++place}. **${getUsername(i.user_id)}**: ${Number(i.qty).toLocaleString()}`) - .join('\n') + ( + await Promise.all( + res.map( + async (i: any) => + `${++place}. **${await getUsername(i.user_id)}**: ${Number(i.qty).toLocaleString()}` + ) + ) + ).join('\n') ); return { embeds: [embed] }; @@ -601,9 +613,11 @@ async function dryStreakCommand(monsterName: string, itemName: string, ironmanOn if (result.length === 0) return 'No results found.'; if (typeof result === 'string') return result; - return `**Dry Streaks for ${item.name} from ${entity.name}:**\n${result - .map(({ id, val }) => `${getUsername(id)}: ${entity.format(val || -1)}`) - .join('\n')}`; + return `**Dry Streaks for ${item.name} from ${entity.name}:**\n${( + await Promise.all( + result.map(async ({ id, val }) => `${await getUsername(id)}: ${entity.format(val || -1)}`) + ) + ).join('\n')}`; } const mon = effectiveMonsters.find(mon => mon.aliases.some(alias => stringMatches(alias, monsterName))); @@ -633,9 +647,13 @@ async function dryStreakCommand(monsterName: string, itemName: string, ironmanOn if (result.length === 0) return 'No results found.'; - return `**Dry Streaks for ${item.name} from ${mon.name}:**\n${result - .map(({ id, KC }) => `${getUsername(id) as string}: ${Number.parseInt(KC).toLocaleString()}`) - .join('\n')}`; + return `**Dry Streaks for ${item.name} from ${mon.name}:**\n${( + await Promise.all( + result.map( + async ({ id, KC }) => `${(await getUsername(id)) as string}: ${Number.parseInt(KC).toLocaleString()}` + ) + ) + ).join('\n')}`; } async function mostDrops(user: MUser, itemName: string, filter: string) { @@ -663,12 +681,14 @@ async function mostDrops(user: MUser, itemName: string, filter: string) { if (result.length === 0) return 'No results found.'; - return `**Most '${item.name}' received:**\n${result - .map( - ({ id, qty }) => - `${result.length < 10 ? '(Anonymous)' : getUsername(id)}: ${Number.parseInt(qty).toLocaleString()}` + return `**Most '${item.name}' received:**\n${( + await Promise.all( + result.map( + async ({ id, qty }) => + `${result.length < 10 ? '(Anonymous)' : await getUsername(id)}: ${Number.parseInt(qty).toLocaleString()}` + ) ) - .join('\n')}`; + ).join('\n')}`; } async function checkMassesCommand(guildID: string | undefined) { diff --git a/src/mahoji/lib/abstracted_commands/statCommand.ts b/src/mahoji/lib/abstracted_commands/statCommand.ts index eda099d7cd..8d4092a356 100644 --- a/src/mahoji/lib/abstracted_commands/statCommand.ts +++ b/src/mahoji/lib/abstracted_commands/statCommand.ts @@ -1216,20 +1216,24 @@ LIMIT 5;` }[][]; const response = `**Luckiest CoX Raiders** -${luckiest - .map( - i => - `${getUsername(i.id)}: ${i.points_per_item.toLocaleString()} points per item / 1 in ${(i.raids_total_kc / i.total_cox_items).toFixed(1)} raids` +${( + await Promise.all( + luckiest.map( + async i => + `${await getUsername(i.id)}: ${i.points_per_item.toLocaleString()} points per item / 1 in ${(i.raids_total_kc / i.total_cox_items).toFixed(1)} raids` + ) ) - .join('\n')} +).join('\n')} **Unluckiest CoX Raiders** -${unluckiest - .map( - i => - `${getUsername(i.id)}: ${i.points_per_item.toLocaleString()} points per item / 1 in ${(i.raids_total_kc / i.total_cox_items).toFixed(1)} raids` +${( + await Promise.all( + unluckiest.map( + async i => + `${await getUsername(i.id)}: ${i.points_per_item.toLocaleString()} points per item / 1 in ${(i.raids_total_kc / i.total_cox_items).toFixed(1)} raids` + ) ) - .join('\n')}`; +).join('\n')}`; return { content: response }; diff --git a/src/mahoji/lib/events.ts b/src/mahoji/lib/events.ts index d76fd843a8..43f5cee05c 100644 --- a/src/mahoji/lib/events.ts +++ b/src/mahoji/lib/events.ts @@ -14,7 +14,6 @@ import { cacheCleanup } from '../../lib/util/cachedUserIDs'; import { mahojiClientSettingsFetch } from '../../lib/util/clientSettings'; import { syncLinkedAccounts } from '../../lib/util/linkedAccountsUtil'; import { sendToChannelID } from '../../lib/util/webhook'; -import { cacheUsernames } from '../commands/leaderboard'; import { CUSTOM_PRICE_CACHE } from '../commands/sell'; export async function syncCustomPrices() { @@ -24,10 +23,7 @@ export async function syncCustomPrices() { } } -export async function onStartup() { - globalClient.application.commands.fetch({ guildId: production ? undefined : globalConfig.testingServerID }); - - // Sync disabled commands +async function syncDisabledCommands() { const disabledCommands = await prisma.clientStorage.upsert({ where: { id: globalConfig.clientID @@ -44,9 +40,10 @@ export async function onStartup() { DISABLED_COMMANDS.add(command); } } +} - // Sync blacklists - await syncBlacklists(); +export async function onStartup() { + globalClient.application.commands.fetch({ guildId: production ? undefined : globalConfig.testingServerID }); if (!production) { console.log('Syncing commands locally...'); @@ -57,10 +54,13 @@ export async function onStartup() { }); } + syncDisabledCommands(); + + syncBlacklists(); + runTimedLoggedFn('Syncing prices', syncCustomPrices); runTimedLoggedFn('Caching badges', cacheBadges); - runTimedLoggedFn('Cache Usernames', cacheUsernames); cacheCleanup(); runTimedLoggedFn('Sync Linked Accounts', syncLinkedAccounts); diff --git a/src/mahoji/lib/postCommand.ts b/src/mahoji/lib/postCommand.ts index cfa61abad4..a602a345f4 100644 --- a/src/mahoji/lib/postCommand.ts +++ b/src/mahoji/lib/postCommand.ts @@ -30,13 +30,6 @@ export async function postCommand({ if (!busyImmuneCommands.includes(abstractCommand.name)) { setTimeout(() => modifyBusyCounter(userID, -1), 1000); } - debugLog('Postcommand', { - type: 'RUN_COMMAND', - command_name: abstractCommand.name, - user_id: userID, - guild_id: guildID, - channel_id: channelID - }); if (shouldTrackCommand(abstractCommand, args)) { const commandUsage = makeCommandUsage({ userID, diff --git a/src/mahoji/lib/preCommand.ts b/src/mahoji/lib/preCommand.ts index ba8bafbdb1..bc5895b79f 100644 --- a/src/mahoji/lib/preCommand.ts +++ b/src/mahoji/lib/preCommand.ts @@ -1,54 +1,20 @@ -import type { CommandOptions } from '@oldschoolgg/toolkit'; -import { type InteractionReplyOptions, type TextChannel, type User, escapeMarkdown } from 'discord.js'; +import { type CommandOptions, cleanUsername } from '@oldschoolgg/toolkit'; +import type { InteractionReplyOptions, TextChannel, User } from 'discord.js'; import { modifyBusyCounter, userIsBusy } from '../../lib/busyCounterCache'; -import { Emoji, badges, badgesCache, busyImmuneCommands, usernameCache } from '../../lib/constants'; +import { busyImmuneCommands } from '../../lib/constants'; -import { stripEmojis } from '../../lib/util'; import { CACHED_ACTIVE_USER_IDS } from '../../lib/util/cachedUserIDs'; import type { AbstractCommand } from './inhibitors'; import { runInhibitors } from './inhibitors'; -function cleanUsername(username: string) { - return escapeMarkdown(stripEmojis(username)).substring(0, 32); -} - -export async function syncNewUserUsername(user: MUser, username: string) { - const newUsername = cleanUsername(username); - const newUser = await prisma.newUser.findUnique({ - where: { id: user.id } - }); - if (!newUser || newUser.username !== newUsername) { - await prisma.newUser.upsert({ - where: { - id: user.id - }, - update: { - username - }, - create: { - id: user.id, - username - } - }); - } - const name = stripEmojis(username); - usernameCache.set(user.id, name); - const rawBadges = user.user.badges.map(num => badges[num]); - if (user.isIronman) { - rawBadges.push(Emoji.Ironman); - } - badgesCache.set(user.id, rawBadges.join(' ')); -} - export async function preCommand({ abstractCommand, userID, guildID, channelID, bypassInhibitors, - apiUser, - options + apiUser }: { apiUser: User | null; abstractCommand: AbstractCommand; @@ -65,14 +31,6 @@ export async function preCommand({ dontRunPostCommand?: boolean; } > { - debugLog('Attempt to run command', { - type: 'RUN_COMMAND', - command_name: abstractCommand.name, - user_id: userID, - guild_id: guildID, - channel_id: channelID, - options - }); CACHED_ACTIVE_USER_IDS.add(userID); if (globalClient.isShuttingDown) { return { @@ -81,7 +39,12 @@ export async function preCommand({ dontRunPostCommand: true }; } - const user = await mUserFetch(userID); + + const username = apiUser?.username ? cleanUsername(apiUser?.username) : undefined; + const user = await mUserFetch(userID, { + username + }); + user.checkBankBackground(); if (userIsBusy(userID) && !bypassInhibitors && !busyImmuneCommands.includes(abstractCommand.name)) { return { silent: true, reason: { content: 'You cannot use a command right now.' }, dontRunPostCommand: true }; @@ -91,9 +54,7 @@ export async function preCommand({ const guild = guildID ? globalClient.guilds.cache.get(guildID.toString()) : null; const member = guild?.members.cache.get(userID.toString()); const channel = globalClient.channels.cache.get(channelID.toString()) as TextChannel; - if (apiUser) { - await syncNewUserUsername(user, apiUser.username); - } + const inhibitResult = await runInhibitors({ user, APIUser: await globalClient.fetchUser(user.id), diff --git a/tests/integration/grandExchange.test.ts b/tests/integration/grandExchange.test.ts index 3028fd6efb..add00c8921 100644 --- a/tests/integration/grandExchange.test.ts +++ b/tests/integration/grandExchange.test.ts @@ -3,7 +3,6 @@ import { Bank } from 'oldschooljs'; import { resolveItems } from 'oldschooljs/dist/util/util'; import { describe, expect, test } from 'vitest'; -import { usernameCache } from '../../src/lib/constants'; import { GrandExchange } from '../../src/lib/grandExchange'; import PQueue from 'p-queue'; @@ -173,9 +172,6 @@ Based on G.E data, we should have received ${data.totalTax} tax`; const wes = await createTestUser(); const magnaboy = await createTestUser(); - usernameCache.set(wes.id, 'Wes'); - usernameCache.set(magnaboy.id, 'Magnaboy'); - await magnaboy.addItemsToBank({ items: sampleBank }); await wes.addItemsToBank({ items: sampleBank }); assert(magnaboy.bankWithGP.equals(sampleBank), 'Test users bank should match sample bank'); diff --git a/tests/integration/killSimulator.test.ts b/tests/integration/killSimulator.test.ts new file mode 100644 index 0000000000..3337d87087 --- /dev/null +++ b/tests/integration/killSimulator.test.ts @@ -0,0 +1,9 @@ +import { expect, test } from 'vitest'; + +import { killCommand } from '../../src/mahoji/commands/kill'; +import { createTestUser } from './util'; + +test('killSimulator.test', async () => { + const user = await createTestUser(); + expect(async () => await user.runCommand(killCommand, { name: 'man', quantity: 100 })).to.not.throw(); +}); diff --git a/tests/integration/migrateUser.test.ts b/tests/integration/migrateUser.test.ts index 167f500138..0df552d40f 100644 --- a/tests/integration/migrateUser.test.ts +++ b/tests/integration/migrateUser.test.ts @@ -52,7 +52,6 @@ import { stashUnitBuildAllCommand, stashUnitFillAllCommand } from '../../src/mahoji/lib/abstracted_commands/stashUnitsCommand'; -import { syncNewUserUsername } from '../../src/mahoji/lib/preCommand'; import type { OSBMahojiCommand } from '../../src/mahoji/lib/util'; import { updateClientGPTrackSetting, userStatsUpdate } from '../../src/mahoji/mahojiSettings'; import { calculateResultOfLMSGames, getUsersLMSStats } from '../../src/tasks/minions/minigames/lmsActivity'; @@ -689,13 +688,6 @@ const allTableCommands: TestCommand[] = [ await pohWallkitCommand(user, 'Hosidius'); } }, - { - name: 'Create new_users entry', - cmd: async user => { - await syncNewUserUsername(user, `testUser${randInt(1000, 9999).toString()}`); - }, - priority: true - }, { name: 'Buy command transaction', cmd: async user => { @@ -1062,7 +1054,6 @@ const allTableCommands: TestCommand[] = [ data: { user_id: BigInt(user.id), channel_id: 1_111_111_111n, - status: 'Unknown', args: {}, command_name: randArrItem(randCommands), guild_id: null, diff --git a/tests/unit/setup.ts b/tests/unit/setup.ts index a0de646af7..9d71430269 100644 --- a/tests/unit/setup.ts +++ b/tests/unit/setup.ts @@ -1,9 +1,12 @@ import '../globalSetup'; +import { TSRedis } from '@oldschoolgg/toolkit/TSRedis'; import { vi } from 'vitest'; import { mockMUser, mockUserMap } from './utils'; +global.redis = new TSRedis({ mocked: true }); + vi.mock('../../src/lib/settings/prisma.ts', () => ({ __esModule: true, prisma: {} diff --git a/tests/unit/snapshots/cl.OSB.png b/tests/unit/snapshots/cl.OSB.png index b7daf0d5dadd0a920c4dc49baa37a682b3118e24..ba03eddb79f35a590563e29265af75ab01c62d08 100644 GIT binary patch literal 49444 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

    dsHd#V6#E}_ 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 V#+?BF4Y(s5b!Ba(ihE`O{|6Nw!>|AV literal 48247 zcmZU)1zb~a-#<=+bO{JZm`H<2cM2*cEsY={!a!ow=#)@WKvF`wySuxjVT3eez!;3} zcl_S>bN`>`{_o{pdvV;(xz2U2PrT!b&{S6;BW5JV!ong`eW|F8g@t_%yrqZ;fFrj8 zahJdcwyU;^JXYBl^B(XAo~yj74iWJ3A$lK3;jI@XKN;%b@huM66FqGjKU4xM(`}@utGYdusJku+G7o^R3zC zgpzjHi+4=Fg%8-7+JwAqe{q!lj+xp2R4EZfYnb?5_5rIbR(RM+XE!bjd93XEcS9x2 zkmemlPZaEi9&!amtgQ@gS|-Y44-0jwYbu(_5=D%^#mYkA$;#$HX%hl(zr_@9@eA0C zN#6c+;mAAmAV< z*)2g>no2%hn?679UX9w5LVWg9yh#SnZO8IL@{uhW`DgtUIECie9edLS>rK2yrF2+X zdc^_SPYS0&8%?}e=3wx>Pzx-qE`G)8-*{M}F+tjc*zB^+qgxF5*!|DCL25LE0tJuy z`3LVz@4g@WuGglX$~;=_$EU8`CN!=w+H;`8&px3s9ehCg?vW3Qg4kEqYRAEi&`vn;2qswmhLpcuG=Rt~|UP$*Oexa{?7hFp4>KErO?Fd|pD=gh(~j zHo&Gr!s9XTXlZ}MM187{0v_}@+#>yXKo(zcJtf=wFO<|P+^pH0@*H_YKW|P?mX+jw zdA0rEc=#)=X>i`jO+bXbJ#*pOVvmT!8u=jjh5P1SKyWQb?+tzc{g2`KnZvc%zPEH+ zsHh2)0~)?Xn2O0p=-!`3QS6un?_l1aIi=FY#2B5nm>PS$)ilWZxM^(l$ni z-RSOZy>85?PK~kqdo1(wO|pd{ozI%eL7@!ceJoOWqNL=%N5p@Sb0!jhT%sd-*_11z^5jCJG#jV>UbbpI5o^bO87%sp)TH`!*)sC6$S8Lrmc!3FW$*{R;hoghC zo1%ljFbF2s3|E661OCiJxQL!bz6^3)0%G>hXK^47hlhu;*h|7bF%a0-zA#9_w`2f^ zDP^b)lr{;4^Kgp?M!ob&$z?oT^UJ(21@qx!9k+IR@y>rL-~J|Ge+`CSzpHH!d42~Y znHhH`{srSHsBaCpx$d(WaCo#f^b>)Hwo4DQ<=+keMeK z^iK2F^&2rGt;enjb2A)gDv4s;zIwydT6~X7Rnq8PuH(BfEAt+%BEnNwyCDou z8@@aiR-g2B#4(|2TVEx{+iB)4$pZKkQ=N&~S6pm2_!V9^irkLoL`zb^AczZPltk?9 zhrF+jp(32RUS~o%tDL7kLsipk!y46hwWP~Orpth5NVYXvdesfuy(RpG+*n@oZKZPM zQ%J`2>!Uw(%F;5nH@^!zXv(q}5Ed23wtASm&p#q1$f+|Q@gCk7w)sKlXJcwx3Cavo z9D2j*`$gZhMTidLvN1?fQ`3bK^&DHeN~OasNdn<*K+&48Pd)c3x-743^&1oLX06 zK(e{W-BLZEGTdo9sU9`rI8tephzU}w4b1I+2xMKFK`|FB?7Tz1j(3#P?{-v__$7qD zV6|yebnK89z0;M2JkHz-;{W-1&x+$?dJmRtj+;9&y|+z5#rO*wPi9W>6Vib9F4VWj zyFu{Ggj^N@t;W46h{8{8uADm zO|Z+RZx{=6s~Cw0D?4bOVEZ!qq^0hMsPxBWZRv3@wTr7OAmp{MTkg{DRFT5O3cYGS z`bf$U=G?a56p72&q0E|Fg>`Q}+$~s`K3clTYF-Q7`mAWW_eMTFzwa{#Od%%A% zuKK!pWQq754Q+d>Usi0eeh;egxLQ1Vv90;l03F@{UnBvsipOQjql!MZy@dJcO>0rK?38?K29g}UDoA>)~WI_$FVclPCFkols_KA8aO7)=AruDgt(wNu5nsvWRf{crXNHI!v zT7w(%ei@9R|Bl#QZw0H_S$_GEd`Hr0^W>@lxYJtHt{u;G*Tx1F%O@$t0%#YA*&mAl zTkhAExuYZ5w#&U)`^ToNdTv2bpOlny+DFxs0=?Tvz-g zn+)QQnABMnG@QQdG!;nt?$6m2sqmDdvg?Jd_>zfTPKfx1SaV_s6(!|Gx>x!}JA3$TK%X5}v?a!_UB!?0+pUI+ z)5zrCZ+0zg$1bx43ms-x-FqoKGS8k>FPxiA?55*YM6TZ!Z-^c^VE*}npr>UR#v@sw zJ@c%zY6sDV2?{-HA^!J`ggO>8cEKejz0Mhq>PU!*gS4||?K`Wu(}!c1^BC>6OkuG z>uT5h{)*Mo>y*7)83FDP34Q_MK2Tm1{;ijLB$xkaGXdk8fi2|(OP%ZPl$Oad1 zM;f~qxsEXIU{p`zw@lTHKI4otm5)NC8;HJC+${ZD?C-lts(NH5`?|fY&RJ%~f3NY(k%wmR1p@ zw?pQs47XAAiOa3A=~;1{nYU3fI;sM6eTgr9Wzc0Cz(5lq=~v&u&!2zI&w%1I(B z3LYS)=sR^qZ&KFXSF8)&u=62@nmxtn}9EGMd5J}T;W-7D+T1mxfju8`6gxL z2eyOZqs#+ty7L_A_yx$~Li-R4dxA`^@zW%ks@teIM`c}f_@zvVVDeg~B5? zm9?8Wx_6k(!GMl0b>Cr&?lHUM#+Iw-3(PGv74!R)@0c}CkJlDGdu@9YwgXW{RgZb# z0|gmt;^1PvZt0`NJ+%NfBFm|5C6Xujs&_czNQhFa&dBduz`SQ$cgeK$83bx zZP70UT=zA+jgs+DuwLGQxr0MabyZi$g@GK}Bg@_guJzO=( zBeo{)Y>${`vSplzaP<4zrM``O-s}Uj#=(A_Q2zTM{_zJR$#HSB+@X7q%`^@GqBmUGLF)L``sb#KOJBz9=85_i&jwn=1wj7~2v$)q)l)bq#GiNTwD70o=Hu(G;{GgtUx)K< z$|Mn_c}2d`K%PPS@PxJFAJBb!iyO?YRA~@NA0qOG256)5?Yact06+K_aQ_#6w_z*L z{*^Vo*HOg{oDes-gIgkxHId7jo0k`W$X!*Ao~so3-? z+~KMe=V5sUJOIqfQ3hfGAU!H7DuT&P{QgxN#To^G|8M_zs)3xH!{s}bD-CA;!#6PV zd>C@!wpf-X!f7ZYscNcHOx91Dw6CqKsR{4xRRWNI7eCSK>0s8Y3Ld69$0)0-Q_0w) zLHjBKS;|su4FxvZy2R&XxS<>c=KW~&M={#$8d+IW-|M%hL|vNcxP=oc&-!D}Vg1AO z6ip0anReS;#H(4Blmnps_;=(vj(6@X3H@0BD_f@ZU`u>C-!LvXh_BhzI!o+@bDV?} zj%$h)vE@8#J!v~p!Jj^d=MA%(pndKQWwH@fwy*Vcc4A*5C^Ua1OfEZI<}Tk6a7LrV zJTt7KX0Brz!<=#9x=faoBtW&HDtOnj@H{aEhL2tUG~C{v$af=;GsJh&upZOsVzE| zJ{1F%qr@xJ?#n1SKQ0^!e`N_QnO=dpVN50cW39xVOAb(4~hSp z@0qR5J@C}2%6JBLaG*aEuSe9tdf&}te|u(e#?yOzre5R>mOYe4TTCD5utqI#(z{ED zBebaz=~vAqVuwMl4Hrt!GxQShM>p86-95wEP6@rTAEjzIG7xm}&unMobPp+vmzX;^ zki)=cDpVsp{B#D79w}qF;LimW|A=`-GQ{2q7YbO<#zzc6)tE&^GyA;1B;= zP@l{705}BpT1i!SaqT{^`u5n5qMP5{0&dkLsq;_+{E|qnbyb#9TU7lXo4=gwy6EE> zdk2Ge@09Yi2TNw$tjj_NhAS!`IJVe->5AIXjOI4o0x6GXGo~j?TIB)f6y-~@2DnOw z(64{KN7WUI=~yi^8~@U&sH=-Ka^zWzh_1+XQ8Q#{*#&1!=EDVfGu}-lA}LYF@hab2 zb4d#5YhmP_Dy$gkYF#hb^0yslBI)ZZ#CP{XF=ld`Fs@&_FT%QEz)bc(FQB7dRjUj` zkNg5Ait=Y-Sg3`+Ht{$-hwRmhk0 z=mKoZ*q8K$xmF2wT8w*f;2C3e0-4jw3Qd5LB~W`zSayIz3)FFc-Tl&BI-krV0-8&k z;t4_sBCY2nxeYP;d-e1cRwFgK50X-HioSvPgqO>-3uNnQJP}QP5Y~RnB`8qPAFyS- z2s_u`zE$0}qJ1r{;Y}TW;z~FE^j7RtNkg$P)U5!2w)v)}v+mmI5#&?EzeCw**-0-DW6vKamU?WyAY+MpxHxCeXOoLX_VWnSPI? z=oUT>LjCkaJ?ZZVn{DOH{sB4O3U!8G2{%sKbv7_JAc9PA{2#Y92irq6lCG~|`W*ms{sp9mqU z7t($Zs>{pnR3LPF?N$*+*2&ND@ogEp+)NIS=YHo%VUcP^+Em|JLuhd(PT1G3gI9eL zhMXHAS5a%^%#c0rwH{#kF=?vN%;()#(E-0zgH0jY#B|2Eotc$U(HL1dC&wqm2A@8* z%zoc#dyC%{F<(RsH{4YH61i<0fxiQC7Ij*R=L)kZvz(mMk<2sU!t|z#%Ge2W?5gzE zN78dJqRjM2tEpi1L#6^fydUt#;lL<>{$~9~I(77kY>1QrbH7}kQGm7KDGI6kCs%$J zIwsD0+0`2K+=tgyKI_P_6E0VN8OyHEV2o?;%;Zhvk9GKt*AosM77@tp8nV z$9fNVrENi^w|OVN&!wNT&~ursLfg((oPM&h61TWFvI*^yh=oxwC~me#7d`^b>O7SYjz2 zjm<`j>8h&tBQ%4hDj$Z;7Ms&9+wy;hkAN00`tgTroIO2*m2(3=)systCO*|$PyxLh zdC@EU6S!Z^kNX?`-IoN#B9aHdQSWU*!8TvydgqJCGzn(MYnFb?l zp@kE!y7NYZSYTYT-OzZjRsMT9blo{sxPY@~OU)+XgwpdH*Cn?^A|~JDIoH_kZ;A0K zvofC^X!m<&m5>=o=B$LKIt_UB73{}YH)!snsJ&YGzw3TrNg4O#Y^^RWamYfiVezav zQsoY|7Z1YcD)+-UN4*wVHzoae>7Uvt*Y)04;~N{fw=<<5{-);5yxr4rh%Kj>cSJz-O>k)^hV`nz3w`<1R&kN*DB;vfpwVj#zCW6wj3)wJb&xkI?9 zO}F>>_)|-e$-ndzhsOFPUdCuCpbP=2C=|B!<#GD!isTG!MQ@Gx>>Ex8Ad^1@m*~4k zON$Z3z6*Ft&W+A%Pr8Yx3v;Hn05AvxKrsNp0tASxoP`!KAbHV7DgC7wQ~;Gq!K;O4 zCeyjl_(#8#wSoflC;RA&BG|cQhC-ai^R766`zpy453zA&1={{#N&`H93r2pjz@z?H zKu;{1-3J}qC_;bo$ip7V*RQuTPuxW&<2Es;^K#@B&L7y;FY%M%Obf{>5LZ=Iy&T>q zmK=`~e#2*FWrgcLp++>Ym}{i)&&x_Uu75(RIxviaHD}g7NSk=6Zt_#TK|(V$p`xaS zNb#m5|03pnHgduY<2l#L3qIN1+E@G?MC3A|Y--Z$>-T&2=LNS0Ifj0mCvP!s2jXIT zqc8W5M5RHj`yth>jf*2u4a?uOoUneyW~o4XsB68O|BDBG>XV(?hRDOYuhB1`q-%>; zz{jWm>`6UC6;C;!3m~lIju=v)D-79zT=SFSCTM$R(EEPq=OK<%?^mi{0uWK~E>hHE zY$W}*c;$pKS2q3HiUHO8eADim>KEa;q>uNf8+m1)b6$IY8)vaYuq(o4C~VP$*~m?U)e)BQK2Lz;{M~3@2kg(%434cL{W3^? zR^j`HjXri>vm)d?YMdk$v)4`G!_BRuWm32~9hRBB@(G)EWShJ~trZYLPi_FzV!>y! zz+tr)L=iKp>SL6Xaz_9fVx|5-ofiXW6}8@VyBk3B8w2h=U+A9GAjS- zg@1SayTFo^9rX3{{8M3Tj2r>f2Ov6tBKUkaF+egbjuK}G%x=G}+A^r@^Ii}9+W#P> zK1bKFEv_KsaZER!TwGl8Zh%d#PNp(iDF$nOmC1YPz^UzRcXRo1-&*x?eDYt#1RD!b zeXLn%tSO)vfcE`055+snByhw`hwQXNqIYC&u0dDz|CC`uLc)*nNox1Jz27iAVl*$G zom&?LDlyh1&b3R2zogoID&_z20+{lvk-T3mRVovqID17h@gU-UD?t#)ne3K4I!#}L zsBZ>x=Sz=*&lFOB10bj(e$-waYkI6gC|PORi}^V*E!6ZNfR=r_i9xHd7OWls<9Zqh zam2ubb|9Ml;7z+-Ry?*)?0%4DacN}@rSY+y$b(?g8W-xGpEV;}q;T^faFfydD$gjD zM-p5)5L2k+gZUy?4H67x&T}!+?vChWKHP=q*vhq+%xS{Mty-*qbYpuV>HsTcWenN- zR)-;#%5=Y(2lUD6^Vd)_>EyilfB~M$e=9#k6PZTx_BsHlC8^tq+oP)m z9MH2O|0$~X>an=ho&m*+NC2o~Ks8$^0%=WP<%JWVzE%kYP$Aqsu~nTO_tTz0&IQni z8tpW-F+>YCkAo;KSM)ue_rEH@|N2O`*fE1GSk09+-s>ExG0nG@ip?$hI1K}q>xIxQ zT&C{B&OOXl)KTh8!|KbJQJ#r+X}C-7)bb$s52LNkwJun#pd6@X83&D-O~A73|d-0JH^~ z78G>A6Z)kGPz#c4uNuMEp53>x(tTWfrd_~WP3L&C_T?9R%IzZYO;0^Lp zQwAu|$R>mv4Hc5l3rk>Yl)0Hc=~k=oorRI1%3lJ ztv%F=yMQ9--E{V(oPX3|tU>@B4EuTJ^qr2JBK8yj>Eid3b@%c3iHLx<-oe3vBY7AA z#zY~fH;SF9u=TQb-2;UDPZr|V+6Z9SoANQue+JXu;ohxW9$pWb9QCjVWj`+1*v+5J zrDx3%-ElSUOHRgE@Ffn}76jNu^F?X4D$biVC1jxIo*ZMcI?umy&IxUSMu!=a^c$%p z$O^p{?KE|F&gb%{nBss$kZZAc3AhEw%=FORY_hdRa*f7)i;IlF*zkfIm+x4-8UzFZ zN_onc@;xKrJOpC#Erd8q6x+Zfp~h$Nn0&bZB0L~rzRWmgMZhvPaCPyEH@8bUbZ3@6 z_`I(pOgoVOkmga7f1K^Fdy}ovwv$+X_bu=D>X}e_W(hGo^T(N7h_EA%HD(UMNFowr z@v(1$*{Rolr_7&T;2;@v%rJ7tH|hC+@~2ABx3jhPGr~WqunKxtFC)^8(aN*hZ_pa8 zd-sy<3!aCd$b6a4({H6mkk!lb;ml5}R7~6JyHkwvas$Gxfc7#OYOi(tjAPM}K9)r) zbQe5H!1G>KmaYd}H?$|KMX7CCcs%I631q3P~O7 z-=9xyZ=>EHZ2~zLt1f|H5|V|CR(f;&o|l0)US>v;`NIzhUi~=I?`9lV`nTIx@XQkE z3c~NZsQYax}-$M^QgLx$g$tZ$##?foNh5FPuS z4mYUq(%ToAvue>L<||gN39Z6Slo(tq&sPLp7==g8Crr|xT0TeeR~Nh*C{<%2{o0EW znsic)SyWTc{ML7=u;!hE+-?1QwnJBDf*hmiZLh| zi*n)XhD**Ikm+!4XGi9-J05CFW?V|~v>Mobh-Np1>}e}@4A?)-+Se^&tE>1!hAGv!TqPv7(S+dvEC4QqWud3jy&gd!NnswZXBHcXMhcgD*V#$aj<{r z?~Uu*xuwrWwz&j`TZzvw88(`a=D*+G*Dc>zmUcuH+)q(@jcZclQO2;t206LOy@r4Fr=sHa zo%~@M^b->)csZSs4tEOY1SoMgu6D;9E&`I-DV|(VE_)(f_R|4mT}+qQ2>4)dcfs50 zM6xfdTv!Xt8Md2R_NIuLShgjc&;kS^@CHEIXaXoppoi@i0)3v5Z(dPuHB~9-&RHh= zZ)PPz%*RJM%igadw2!|c`DucmE5COW9E}Uqe|+QSuFXfskaJkaD-|Nuk?JV0tF3&383;yhrZIkbR`KOGN zUlb$RVH=0pGQ!?;6Eu@7wJ_j+hDAN7bWOD-m{hE44IB4!Kqq(@`5+j5rq#M3%M z7uJ8R@x18iQ;g+eNuX=OkZdi#N`~noNbku%(MN=-bIkqUlEx#W>sfwM0EPjAWe>kL zF1@Y@YQuZ{$(+Gcr%xN@kTX-8KB2aj0l#Nc?}nD|LJ&b_Pj>QGf#p+Uzbt;-Qb&VW zRGP1q%pc_%ZDu`Rxdv3(Ab@Ex__F;$2BYh9a&_Hq_+yBGpP^E4m%(E{YM^yQCQojp zo#XB2XWu;0AI~y2BaOjq2+gwxPJA|Ve@CgrOoI7O8!Awajpa~CtD2=2(2hduVr3Go zy%72q?mZi+bT8E8A%wns`|FN}*;)h_R4eIT%yxgP^985tS!?gg%G88ME<(TH?T_)I zkqDuA-K|*V1KzD|?KWwyQcowCmp>;mIhH~}=IH8p2!!)(tII2ebQE&NIEooummwq2 zxBnkb$UJhnfu~A{UQ#B>o%aMLB-Z`@XWo1_esyOBjHk}$sqFVOV; zT`87FZhmABZnj>L#2S64ko&MlVTk6fyZ%k2JHiVrY=J9XCnuL~C83k=(T3T<@!{{hB1jneMJ z`%mzy-5LF>)KHY;p#A4@7H|;5irAV-wGPcNBdiA+^u&g=#q#nz!^XjeBd#S3a2q~J%Fagx! z&tfg+@`o^?RJ1Rf^T5e)oG;%fWU)3C!~Nq0rh3<`#9J(|3&*|AL-RkdG{T`#;?HMe z$z?C1CEoSJ-_`d>rnkdHdBuh+FIuX=-GQrWdaHjY829qQd!|oDoop=QUVA;Yaa+l6 zKCWEuCTK{937f?}#>)%Ww|F!u$}F4vi_e9&czIIv|53)LNoX7g#3>-SU) zU`$2Cj;BHXe~i;_qvO19LH4zg{G%mDC2ZDw~4kLB0k9Qy7aKI=-UMLGkiS4H+QHv`};xQu{3W0)E`SZ z*`nhS86rEKkpCu=syIC47xKB2tOF?Pe3uLFHWlUE?XLs#{V0gXxct1>zp1+JpXNl= z)Ku{r0<%Ea*vkpHH7*o8(ujS)21swl9-K#eRaS4;M7N&V30^U~AybH8Eu6loehE_n zJkJcB9{niSB6%Tp`vY+UriW7Ho19w1WCjfmYn+Wy1&%}6$k_382EbH?ppRG+DMJfM zkF`_0;(|kVTiZLh^wH3lU`=*X1xk5M4qDk~N|boqtXy1CGoT^B#tAUa=1an3>0)FL z85MSWD3}y73AO~Bq6=_yR#3yc-Vv^$7649j0~Q=$x*GnKcmW{$CsU^<-E6Vmt$?)x zbel&Hga#oDS)z8k%JfNbFbV$u~3zX&-+F{k9;Ws}pZq z!AeOrSQ;Uz7G;f=Pot+YT?N$Tx z=5sD9v_Wef05nQ9_M z%SUdC+uyW3NE@2%=d2qZx|mj~c=owAn3>AXp+ckRKs2tEfccDJ^)}WoTHh2!({i?% zi8Zw;UhU7R`AlPjcQ{4mo=`EdTgt5i`Ude`IKLAEVA%dli@51XR8;G=7M!zyLV>s+ zy;-Z?SWQh`nj8+YNKAON!u7=G-_8mA!u+~oG*unu^(6fI?FI z>;hiq$}$K2=)cuDn;P4mw41sCw9Ij`*$6a|2OfP>IQD$=gCl-4Oneaf0T^3ffB`5<(B`+Adi*Qu zx12VyT5u5(w7^f;BmLErwx+aVLHgZ_;no{N*tp0SE#03*j^-yVdi~M$^P6s4(-#>0 zxXL!4R3Fgjb+bOA}ao zOzg_-I@FWzqJhoRn`aIKfXhvSql7r#lKWw)fA~#G!)bavrYiDg)lcVKV*FIHhmk<5qLxtXgHiY@zd~>WM zA+1PH?P?VUS`B0ynDwO2?2I3g*#xHuVo2vdsU$9id-K3q$|QIEfDk4|gINMa1$FS- zrWt?AzaAN{%>_;Zo#mLYq7@r=PDh;$o#UrJ=_1~ z8Pa6P@;_+eNM!!_@sqhlHnGg9m0)N9f|sp*g~i$MY;m!ht%(sCM2ncz z+g4>Rrxn>u3414az%uP37Tt}hIp%_w4aF~*=O$FC-2%NOryJOc4%Td6>LFlc+LsAf zm-@t4ZcYQHtTSfAN5l)Jp7XNh2z)jiuc_S!Q%J2Xo(EyO2x5XMms_q|ubS-}%vbBq ztgA4eV^z<@XAy?`-w8%#qP?L->&9Z5>WRSZX7+7QTqI+xB***^L9wu*L<|Ymrn#sX z2k$HW|AGm$x3&N6LXr2kmog+OQv!eD#{jP5b>aB#@a_4kUoS?E5Zm)SN=WFJi2lQ{$s_RFlHhh6*irGdSl|Z_ozk*dup;TX zoDS8RcP_bF(ALiRqIN$(b6)~H%B$9^)IFQOVzHIO=$1l3&|gUR{apOM>>sF%_bR1-PX$( zx#xRR+sDK6`-A@B19@UYxJ@_Lyk+gRMWhYiRvI@yKhuo^@Xv7{uRbuv$9^4Wi3PQ( z_(ep<0Tc3x)4#^v5GTIl;xWrTmErn9h-)o`;j~4=|GIftbO_A0{Aue|JZsK(OHP?e9!KiAmR_fO2J5081%1k7pUK-4#H47SQg-k!C1u8GK8dlMw1M-TgT*E zD1wc;<6H2au3X@cVk-Z=uL+k!wwto-z|y0hMI__@vf+Tj{IW_(kZ@NxvQ#M2pW@J7Wml)(LNkGtIoA zM{X1EO1|y9jk|6|bfS8X5%UJ)x5tDO&bN851oYz>qBw%!8bt5N`H4_HaC3x(T-?X9 z-K-X^&swcNU0#{IcHKDN{Bv(+0(gaQfLQtVOy$&v z!lteTJN&?K+IF7`2*gWb|I&uLprgykiom(`GQ*Z{uj45|_AwL9J89xRYRtioy?xTulG?X~ib}6i?dni}I@&t-T-$UN z9z!iFI^)RO)Mb8{lO4Q|@^O~(p+4bG^rdiLO<#1)n)30MC+eDr9gnz%g$Pe=X|fXw zlW?DYprwB>34qy$CPEmf`6{Z|UF70lI}J)ZK+J#_Ra0@PAZd8p)1}Fg{F;<&qOYJ)l%2W)9Ujwb{#s|_nrNErVMub=|9tm^f1Zoe; z)fy==gaRdsDA2sbRt#x@Z4QSD=CDq)*wZP}BQr7YeX~R|RmN2(=f%9!Ibzf8E~a*S z+kIpbmo4Vs5RU?Z9SX#DCZb6dw2UH@^S7@J{4_6LYZP)q=)A=1+DOy&Wc^*z-A2`} z5n_{VoD>niE0*bv?{7QI{4o2V;@qzR8mz1%I?|AP`LG;yCd+cgi%oUylw_i#=h z<^M7x8tQkw(7cQgM&K_c2ttaG&cQDKm%;3x*16Y`3UF%oPj0Yx#i?76 zv67JyOxhNh_^;z#YJMYQa0^H*=U2#)jb~DRWUU-fG;u)hlL|O!KM;>%4yi(E0lU`i z%rl|18^GvY@>LNlB_Dg5ZNU%4aN%{^bCDyI;WKden)~DH3wbi{K^wb+GX~c7WP2{v z*%vMJ@kypzf;_C8t1mF{4M=kvE9>ss3yen6?b;1Lrq3L{m8l)+)fxi`(-w>$BL%U| z1F&M=0t|ZLJX4|0(8D`u*sKOY%blk>hxXmMzIojf;GFu3-U~8ZbE`zh3q^e2&XF7smdlDp4ZQLo!8nUxhNsey7{6KUNEob$52h(>wRih}{`)7VHk8-(7c|FK6u1 zP0jj&gLCut3wGz;DU$cG4u#1p-mdklN20670P5I+Aq?~5T6#!{DXJGIY3MO`;;X_% z`~?Cn;UwirU~dY=aKE}0>#L}!h1C^rd!$A+MhgV=0CQdOXEHoDPlJK-3+$$Ou^5(z zhtrj_{0!)FEg1TEW3)PQMr5ek9-^hlJ+0C6GGGb_^0xSD!#liPg9bW~9>Ss_VPoig zDD;i$+zgZJ)CKpwY6~kZg%Ct^nX8vpt`=Mgj#kSav`^exEnWDgi@9-v4x{>)D$DHe z4GJi&!Zk0Xb->)Tz((_7L8@RnJ-TFIBOv}#-%bb#mVd{GViKd!zuTDSk9y`$gf2nA zTzqaaITT85a6aa4THz(`#S#BRxO8zFj6O@*QQd?{-usOLz6e|HjXDj&LZhXN+}YLJ zD;h}ap_T=etlkPwdMk*n{V^{wdA&-Kt1D@#EZzKk=&;|I8UaDf+bT$g6X4l9nFRgg zDgdbB=9NCc8jvrxFAlN?7_uSLqu6pk!J2w5{q#+b*^5mlr1#=&kM7Rdeu`Uk-?`jR za4BlxQ*OkHGEKp_bkN%mW6+uJa03}vmS%77iq|wrb{kw~U~;B3v(6Bbw1pV2udyK+ zgQ|bB!=J0Qd5*ZVzCPG1dJv-9dKdIOBH)G!Cx{Z*tI6dxP9|^un=X*e{1qV+0TDLo z^cO}XUh(iSuu81p-#<`JQHElFOqU^CLum0PZ+J^H-rV3uf34|yEnN`!=MDp5ym4_C zKiXse=4xNREs&pd_`Hd+pQsEtpLK7VYI zTY&5)v#<5(CIs(hJkTx`py&Z>L#}Cg?leeF&cYuDprdtMDk>WTfSt6c4%Qpx2Mr{q zW#%fNykgGdnggZe#w(LNLK#B_Ow9jy0m@veKdd}FVvODkq_@2FPeHTvH%C7~Z%Od2 zABIE$GJdzvZ!>vdAzawdyekVy2g&gD5c^Fzqlo!sF(9aXd4GK|R zedIoJV4`=ncXZ6srpFDL!inrjg3L+XVaH;x;HHUs_4kz1LR19ouY>UC0q5Qc64(w7 zxaRqZg!$5A>4+^{&4c*;cfmk$UNPl+e&RCzo&h5f?OVdllWV>-q!y-sX_tsrw~}Ge zPEqd6h=PP8nGz=Vs#=TTt9+wqL+fu+?o1TWL*Yu3wG(~61ROkmQW`>)Fd(iQzw7S< zJ1OCnG-OAI!k16$Fp>-(74rbH=avwcRqN>&3rzPqp)?&`(Za&A?5{1r?deZ^u;2La z?F6yQks~-KhEa*@z^cRXa(M1u{2_pFVXz&nA?CDQG==-pEaAy3l_Qr-o}R9Fp3()g zbjt#eKW2X^xx&QBUlG&AhPB-+r8NDXQ7g}sgLXsITBn$VDn^(p;0jISku!znKGzs@U%(v)^(q$kxXiQe?EyTk8xMX8I&EJ+k zZ=Kk1f>a=xkPLCu!{^DYCw&N`DqpfY%r*MOMxmWTW#(+w`a)SXDzj|X+1~el!TdbTv;N+T_t^5+ z*p`{TXmAOpgg^4wlBMq8rjRy3jf#Wb-{q^hTY!0eC+8*TNG7&T^KvMj)fB8h_Av`^ zT?`%UuFs5Zj?|lK>K#rq+gI^0wVmX;%~&Ra@ksuG9i@6qfOG-uP5^9ND-llLW68f$`Tv0a` z^U5F$q6~%|yXqMm%Gtgc7m+EMS}r@W@^!mQAOP&9k@aC6&yXOL+5Z*R_4z6IwNX-3 zf@z%3M_;!)?=pQucX>_mJZl_h(ah>q_` z3ugEwx%1m19DrTGBH88bV=114%Jl3mukdLaOOatU++ar~w4P%E04EcKQFu*H=bG8MfUHjdX)Z3er;2HAqNzBQ+ob(%lRVk}54R z14xKUNGe?-IUq=PcQ?b#d3@jR`*F^Xv(~T{iyzE$KX+Z%zV_ai`pHcv>qvndj(UX3 z+)jj71^f{{b$P|{zR92ay)MC5A^HUzw~t(zFd`=kn?#syTDt|Q#I{tomJWOXkq6`* zD2$AS9lMa(w%4=hWIOEW7d!R~pCJ`Pg9ltH2A&vjUA|vGu?^_8sm@fndvA9s!tO}X z;ajj!8p6K>Qh0c9s_mF0MNl<&xB^B2OeOBqeQKnEv&MAgQL*D(b&&)-C-83 z%Hm==OJNO!W2n02MGc9oc2^g`7{o4+yO@Xn-W5I ze?S}9`=FN&P9T#^7r_7~l!J-e@1}0c5m6!#WPg;1ao&})X!zHIo5$SYchMb!_+KWX zKyg;2gbg@~1G22x@$@a@5Sz27^K10YQ3KEUY zFR8E&xOUoP{7g_xazx*BoQA$}#hJU{2qz&LkA#rXeL}k^KybvZKEtfcd)HpD$%!$3 znr@GTTIFDM@MgLC1c`jdV!`Lqq*zTA#{k16Tnt1=W{xq099vH3xh_)~kb}(ui-Bki z8u96%C+K_hjW>gfdv}6NlvNq=;P(@EbTj(F!X#_k^}7G{emkPs?B}hm~^-Oj{3tq;Iltoq$R3|zh zI7&}F08tzmiozMmmrO=5%Z~U^p5gGuCXXEep%t6~rZM^MLtPbP?Vna{OL z#!V1Rh~3J#L9MWA_qrk0Yx^n3n#ZU6o;e_XwJ&JXW_*ug!5@{8knjBN8Q>GIUL)x7 zt%?qlOTs=qL79Ppv5T7;@{z-0%Kq}XBA{>x+c@&j(;qL!|qW7!dMc=w`=0ct(&gJ?GAG*gih3^6h8zLUE{sA9-aK zgAXIxQmyg7dq|?Aq{_@A0XH)IF+K(uBm*nj!m<=lYE)Ul8w$NI?+n`C`!XnmmlnAA z{RVK>z~Xb4i9=sPQA@hz`7u*_9p2L0KU{rMF2`n<6rHOg?iU1>nV331b-1I>5TmX# z|Hy3mi=K3b8?QZgM3`iQqNAd)0PS86zdzQ09T-9ZjZmHWw`rr2LLv88FUaHOV_tZ8 zBD#8Ic@LFOCvTnwf2Gz0rQ;`taq3kKcn;um$K*Ww6Y%jj&#QoAPr5%!_KSys5|&>! zJ|N1**3DER!{Za3dpl#2{Mi!31x;6~fEzDokh*~ZnN}8Yl98Mo@SB~8J5Qfn<(t^7 z8y-$GNZ>dlBalAG?U8FE@wnjFj^EguMmufITCk;i1xrYFWFRd;G<#5|->Z@C`HnQB zO*J6IIqWGQM_hpvstqfbU3~3XKtlM66BQaBt^5ql@kRD)IT^k#mH9c-Phn-kTh*GA z^CDUlc=jJ0-F?$Q4LI4j5l2{i8Xfu%r=3J<#vN1YQxf7K8G*nksqt`yY-rW132^8# zA9V9{)VXHIU=9#|sZLZ5P|=dP%7-S`Z?(PsXPt4!ykV^_M)?0$U)u&3JQ)lts2LcU zI?rTZ!7I_zwKNj$fU?Vg$`qi?o}Y9KSzF z5Mau_R-RD_2Q5wWc7Qr(O5ZLSTU@hx_joNJS>%`R{*kMIs1tR3(=vY;1su|LYvqFqb7y3+5C&vlS>0#B-{9oZ4Etl9=o(FeQOD&xUE{I2%ow+-M=D--5itC1 za^5qK4q~tr?Hyj6x{1h+?+PAIvXB(+Gc&+Vx!{I8m~PS)s&~(0#2|#BK~|*NpGE%S zyoN8gH~f^)C8xD1+Bq!zSr_ly6;v;s%#qd6A`G&qO3Wy+ult<3o7A{FTY0pGq(6?6 zI<9K#e{C9w5#v-n{DO!=ibfm zB_s|C1-_?3ccX8oj=7%K1cMrNV<2XNT8~Pdx&>XQdnsh5Vk~(&4;sei1Cyc-1ggDG z0_9EB{U`$3`XW~H6nBk(P8K4g)S?4z zgt4|}N;uk`-ywLL-dM;yMV{Rt(qjXvM^m;YV&!;8(Sz!Ev5?NQpyj9v2wBG_wcTBUBBLN*jYKWxcw^GSvxJZ&kO7ck&9 za~{(97?R`s&~;p_s%73V9O<}WiD7L!*ui%v5bp*KHZCE*D3L+Jdpbt54FvG`2z+fW zMaS$QxfrJxiA_od==e^0RV#VOLwQGXs^~tgk~_^}7 z9hX+mE@t(3@?|bbK{1iUh^v!S^m#Dd3*q6Z_REl#?V)xTS%#**fm1Ix{~)G)k?Gwx z3s$6+a~LI5_ViskREOC3+>!5qqi=@_yKs~~f&O7v_Rr_xEcEKfB&6gt1l6w6hcViO z07?)rW0AsCQjM*nDz3DDyV>c)b2KS400WM%->!D{P7j0cLg!v`nRraQVZd4PiXtNr z^mCN0Y~2IQoP2(n*Dbt>|)l1fZI+sr5_S~j?ttvZk&zE9u(ION4){a zGof?%R1}0(p6qdSLa%{eyaq`fs|685=Hei-AY_8&4c5stXbsc(_(6# z<145`<~SnV+JzhVu-XKa^Ivi#u|z{YUT^(k63fg2k%@gB-RmWe(XSDt&T1ywtyPu- zoB$#8?;rlf(GqZt?)=4e+93Th zGK0=%I@70ig`2XRxF{JLAMd1njcYS~J#Og#kyVDBlSQ)`>2UNw`l`t)ZYxfrwjt?R zJ@vViWrIYTQv@A{<~T@pHg3rcaEHcP7_6`W{4!|8m-=amL8d{Lg5%`(ms%) zLTGdhmob*mmGMS}fI$WC6eb)5QI<_+ZkeOli|2Ap&CFx%ai%{rchR=chLvG8j^|8AiPj-mJ9g}` zN)+PtBa~!)q()zI%6$W%oHcG35~ERQ>(FfXpyA0j%AmL?g15`O-yP$c8Llk z>q!LgSMHczdYL;?WtZZN+fp|}vK^1YQZB|h=JR!8tOJq1(Q6B#dEj6g2;J&90t|DuuN>t=>FWCY zWc2yT|8s#UsFf)MmMnhN!UO;Th%3M=&*C#41dvISM|a%L4k!UeBVE@mZGDqFoI|() zy~CELnf4uZL(_8rG~lpPZB>nnDCNzyRXI_Qauz6-jxsB}JnulLh3N@U1|J zH;gM2v#T|k;Jq&zKzwUk_>&Bs2~M*yz43nI`hc`Cry!9UYoS2K1t_0pK0P!!Y$K&2 z9NIvkYp>jDJ}_+MEYk*lHZU{VGWXS9nhyP)sr1qcVfxrLa8l6zvT}x4p%_|#-#-~@QU*B_A^n3s8mur-o z8W^Y;(AZae_3FiI``7ly_W4r7DWqJ6Mz3G>Fk8RqVLs_&CdXr39vza_%4ok2JlLQ; zIrB%mlVMt1pPHO3mrGN{mh;39TyGWotO`)de8<(Lc}X{owU63@L5@touxaiQK>FC- z(W{XKccRxE$1Z78j(@7hIz6)(3z*c1(93smY5R1npg-faoW==YPGfK? z<#4fieoEfN>dVhNv$s9cLdsU?$DawVHlzlsY?%#+3ZVCOK7Mxmi7aDHhQ4Pb54_pB zi)%l=ScB|QmTfn zXp7st;qUht;Zenud?N@InIoOrd&$F~?5diP#sggme%-M^h8lMe7T%~)M>ZRs1XT-j z$KGF&S#%#WnqsR8g^RCuTt40hpXx2Sc?18w!t1B3SNMjyuG!U)=PcXW(}sz$Xs_xg zdB#oC0k}j&g4M`qTG(J$Z!$t8iQXc$Xkjt+7000<8LaA|*|b{~9!H?iezW`j%7XZ% z7x{2EN6glMMN3~d?wNZ+WUx4iJbq{EBfRHLz^{=y0Y&6@#}cQPnacdDOT}bR$g0XZ z`0d@})|>GLns}tc(_FUqlqDbXo1zJ)L$0}lwIK+-rI6h~CVAPWlt+1Fr925o)EdV( zYY5%XlPAk*Iusi{R5wc(!~10=L+`u5fQE`rO5y#oBDQ-lN{ekEPk&bcZEHZEvIY6} zL&WoDt6(aw6(c1`mf`A7VrNcQ70?0c8E*P)Qxkt2$@}0VJO4yy+{lrdIzxS%w&v&e zc)YtiiMk+(!wU#8!#1i;WtO}?jYa<#){{^2Mux{5&eSnB%L4g|W2dl4wHs?ovz|`Y zIWLL%)aCOzSXw3Cl%2FtIYBP(R&&qEIaNl^>KCbJ!!RFi5}nxQ2WA^!CVl~c{ow*V zC70=o*!rm}%9tuG*6@^8Iy0)FczM*o=8b6fF2kEd-tD>2{g6{~Ce&)TQfjm395Ykf z!eZQ>9^H%N{Gz_grb{)kxzUxxJNPn;JRvy&96UHQB}h=t=oM==Ls9r%m2w&AXDGoJ#AT=sy#RX#+H)4h`cRfMWg%|s z=l-1cN+*^tgYHx}d@082bNEmGltyr0SaWik3QS(jtU;{e{&~V`6`-*dGZ}p&3ZXM< zV4%j(a}!i4{Uw|u_HzKs&8q2&8A_iGUJSJ(J--Zs$Dhg1)4}>MmIzoDbrN&uXFC>{ z!{Vo`!h}9B3t5HTkK8d6m1M=R;uT=Vd*@x+a>i^=i)eh>;N^zBUlb0vMy`UNS3Q-l zdMa4ur@qoef*U2b-wCSRtI6-+0Sp#)B7%nI)js28>2FdWN$DC=f0UzTD3c^c`Cp}0 zhZV*Q;dMUw2AI&k4($Bx3E&;mR1nvT)4)2+Hw)`? zjpO!JUkOl}zNH?jJJij-_t-Iae!D#5t=Z;)ct50VZrsY>g+?6SKLNqr&EQNiO97l0RxR*H3HMA2y zYp&GOJrAO}y_ehE+_ae^Nl)aNOX18`T+3j0k}LZC9k;!al|hSs2`MMg^f|wm`d1pY zKN&~wSK-88nFpGjR%#!+&xJE<*VkaTR$W7ZzhEYL6=OmgihAk1uWI?Qd-%WlVww zb^uU1BH-d;l9_$(JYy~Ai+fG$x`%O;PH-WEZ9>CTvVAdwTBJs^5UF#wJZOYJj<)?) zACG8cUPI2(%P3$399!UldM_8{jCVzKo8UG_olH%-q7*HZeJ3F*zt{brbKAfCEyeHB z_k3y>@=a7$n~_LlXjGVpb5-j#0O>b_e@52$$WbnYe+y~#c_`nw0wlVzY3pb2r9UQv6_n;87Y_9 zbv58c9G1dIwfv`c{A{AJI`miiR8oO*s5%7ImOUAU`(~-_9BHN^giDf!5P83 zJGJDb#o$lZ#{`^g-s?hN<1gMNK6XDc+3!s521DgSGW+~#WKPl;6|XPyFUfZa2Rkn$ zExW=Y1gK7op zkH)7pcX10m^E@X@F*|D?m+ShQVZW5#PijS;Oa7dRKJx$-&V5#a8Nf7`IR4$0F6fC* z-XpBlV3@mzhmQ}{%%&**7UH~F?2N>R^qYic^pO*C)wa|Z#|L8p> zSvdi`Nxn>@W}4;t)nK9wnB?@7+0-?(7ga)w2BB}<7rQguTZl}?s?S$9C-Qg9JLC6FNzDgr6PsCrs?&(P+hF+Ky_}4eopdL` z!Y!Z@&|&_S%50yap2`G%?fmxvhMwWJ_lqKZfv)x@_e&d_;!qb3{<|D%v_+10%4USk z-c)=@0p4!zjI283Vuq$fv=UB55HRFDb7>*6S8pSG(c~rTee!sy@__<=MFzob$jgaQ z@-CCj`IywmpCaeBS1X?mf^|U3MnX&3aeINlA=vZAyT%9g!_KTlNBCH$BC35p!6R-Y z9pHb*edSA^L3xynUDmW)U&5h|`4VeX{1Ya|wn^mXx(3{YREs1RRg6?+o61wbdW_d|H_-ARi zCqp(PWu|?MS0$gopOWY`gZvSaWoa^Uh3YWKgH%85$8Y5W3QzVt*>gnkAM#%-cW521 zeLP4byW*hA$4ukZ3?Mm@pjOw~>Gk$Nyb%*iw7nZows9JjpDMh#JLc-$-Qb;`d6i!m6+!v%Lb5e&xv}Z3zW&;~EcC)9 zd1h9o4Oz#S$aF*0eRq_6A>tz%*j@!M&0ep~!}BQeK&GdS-Wwa!o{UW>DW5-`;^zO$ z1(-Usx!^O5(i2AB-!v`n%cuJ_N@psV7*TNfAiSfD2;%VYszYb!ri8?{ zyn&%a0pZleXua##i8mgf@us}TcIL9hH{p9cE_1za|E8ul*G{1J>r!nDw?^}SkfUV5~yq&NDnLmefX$*6xu8q|U>=3I{q&R4ZuUs$OothoO@e3?AGr z@RwWrTl95WqhaVdRr`Xnd+6n=uvlZt=X=(vT>B57fU1cg&&0+A}LyQm5&I0Z6?MQ31h$upBASCY+jFM^Y{3;U*k)QHCzY8UUd|5GM zf8upxar>H~EGGAg*5iiZIN#kex1NiFt@?*(hzv(xxgO5RRR7^2^U9xr3UJq}t8({Q zADJwjYg}nHPlu0SJ1iiki)-ORM=7UM>Ye+kBpyxmn%1R)Yr(|FY@|#n1O+?s zqJG~pltcb3_IVH6)1*Wv&y0=fEqgBqQl%VR=k_%T7FAAzAh{;GP-pETuIa;UkZPq& zTxs|A#b2s%ZfVSOIs#S+!RF_Os-voR#GaAkG+AZa&0Is^VEntgd=IzmrNRj`C6j9f zFFmeouIRSp0Q67zn|VDadbHDtC(5^cJ1>ttHb<$Tt_hztEQhXDNVDLSndfM`HgFck zhq4abaYp5bFjf>HX*YmH`DS*`+0N55;-KhB0EN1$CV{7Rg=!SLal>C=5Do2wk&bq} zL%%jY#N@`LZFZ-vGx?_KCrl1tiZRJ0{SKPNmt=Aa!IaFRJyr{5PAJ0m-bvRNk@w6K z^e^Wkpn`@BHO#TQ$kw+<^=_&3!9m6*Gz6lKT))w~0}9p8Rb_OZFz1QUOnyw9zRq)3 zDzW)tMMx!a;o75Q%F;Boqg0@!c)IQ46_X-#?arJBFWptV8c9g`ZGcKV^#{(ew*IfO znf5pw{si*H>8r=bWYJvZ#$NyGB91l09uA0e<(!zYiDVkTC&eC(DnDQ5!6vD#iE?;< zM=v8MM-pI>JanIu>G~0=1JIwXUHK#R6Y?*G@G+!}!KP{;vb^&k3>7u+_P~Mm_?6G_ zF=6d4^c!#T;JdzZ-k@cfpv-HX1kNvj`#rJ`GAE`vt^_b!n_E-6yA{L#jIO)U;qE3& zNpUhxrx44E@;rFEQ>Yq~govU(@C6Su8jtI~Mo3DJr; zhD8BRuzm2}ku zzQOj=HxiONHc%+@eb|!8u4O}3aQz$jj7yR6m%_2~ZtpYZHUBfz-@-M6_PeM%5LrlS zlv=^l$WJ8wP9^tsHWp6`&ht#CZ}b^Y45-vf+hjHu$K~}NejkcjlRp)>fjwbI?;Kmu zCcj_=tYC*VbX)OzzZpvgxh* zSIt!hW2iIxA|XuN8TUMUgPhOS%m#isdDHp>p#JK%t6qO$`=6qYo=*vXn+8D zgm!jS@hj$47)3Wj>giFByn7WV&)>#M+27w>RAjR*(7;P1x6jYy2QS+*{J5$2JUy!m zH)0aFgt75Cc=j~}=wuk-(irQBlvmKqb8>THaST%YQsIVdIR?+qYeswVXF{KNwK`hT z#eBl$ur4lEdZqKo2xMh*n1OT{%dEaYu*zld&TcAEJC7((e1tm&uyOm4zkMQpi?>g zbWBm)Nzk6<{Eb^Bpv^NNTlKwFm8n`Ulm8+U>Z{fVC#Nl;n_gHNk~VJL`V>lYeI-NC zE$DIb=ZWJ-+;NfT+;hfs6&3PE_n+5cRQXJ`k3*d1tFC>|`G^T+yUVU;L?<;Vg;b^-Ol*tFo?ukPqmw zcSsEs0u!CCUpMEv@8Yg#gl}kca>dRb4w+aEqB^(?^DQC!N0H?ep6p+a#rB)Z#vj?F~OfirN5T+w20%BOx^m|g}`-97Krr)0g(vr)2(cE22R(QuO0UUdhVX4U@ zY1I78kal0!&~2$;zq)xfgvL6SfB`btP$wJO$4f=_G-}1_aLp*?@u1skX^kg??P_vw zxrD0Aw=ch$@7A3sR*7?fH+ocNy#25rJ+u5D_HoLadIAs~5pMPLchPWtzlucHIk?DA zCz%(o3fSCsX@GD$QNUB9Ss=DQC?$8LcP!BOry7Fhy|j+p6S~tO_m~yOSK<;i4tZiz zqG~G7QZ3X@wuTL4klW+6r~MyZHw>yv>#T9#AUG3&Z)VPNVp!i`VFEAV&NR3-RX$>3 zscmVIcdn=ZZ-xxtqn1GRI$X zx+&kQF1LJOCM&!%F$6WDHRvkl)V+KFafcw!)S31bwo_j#hI5 zT=-8lQ=xiSry?1%1ai0ACe;Ve;myqp{35!5gYm>0UyHU=BH%ofsKgR|_Ta%=y` zE7RcE;0h{GOwqfJT#?VL4<5b!{2CJdwYZxzne0@=vHOPTQC&cYr?;($R)70?JA*39 z0rCG$X{1`72SwMH43@F~?4iZ**`sAcIBS1#T)n8f5m3N46urp_NG&+}tow_8Hl&!6 zG#zuU8IL6)67IYcl_MZA`0`8qM zEo~dumGr^dZ?sj^>m zTB@{K^_`puNq;J_>hA1h6-4rn?sjt8@%n1MM=#vlSy)Pug319bIw^4fkl7(=B5tF&4a>u@pw3P-4V|h$ zjvDMS<3>BZL+X44$y4FPLaH$W+DS1x*9)FZn{k0CbogafTtEVT+8}>(wY_}WPVGC# zZF=|RViNuvUZaN_FkWv3kd{HAlO8Gct{!sJqzw~sc05+a46pw#;i>qGd`sHRYaMyC z&$u#Z{gc3QJS33|2fs6`N-i$7&v5tgz&eoi(^;EKrIwoGJ>g4D9AGGaLBQ)vB)Zvu zs+Eft9)QxkHbd2}#;!xxy_Mm*;PK#KSAe{|8>pC{{Z$pce+eTEy}s}yhc-A`qMpesbR9UJ*e9jE!OZ`z5OIHx1QFuiDHO*e!5%sv_v zEkCYuSw+Z}XcrdhaD@Nwb&43pR8;Wsv9g4n-EM3Z^lIM){APgRIi;wtoicNIe>-&V zRs-T0sv>xy;aHwE{^sAljC#%W73fE-((gAg4t8|$+5>rv-9Vtk20U>?G-Bz55$Kr( zEJ&bv7QmBkI8aqGdyC%r27bUmh4g!XzFZQD8EEf&Zoy5P%*)+kcwYP5{2@>A)jt@- z!cw`U6cbM8BTK|b+OSbr3l6t3f}3Q>?QVexP6X|Y2UH1tfEOP17PGToUZ?>b;e!HKbn^@S*Fmx)?Id@*EYNmW5 z5OWjA;XP~Hu^sgl+7|Yntwp!QBq9VbK>G!F&0Ei_D7K9v^DiMRDj`@U!ZiTuZC+21 zkvsJ)1dpl+`+NDve^Qv%B)!ch;)34*8L|0})3iOLzp?t+jSS)p?6O9Zsvm&zOkSir z;u>@)!bdkB*jYH`1EVu65+saV-BM=NrJ`2St4=U4)kh@qr3|iGr@NXbl<=X-o~Y2X zqi3oab0xb)cY$lZ>F{X74L(&{{iEh>S{jLn=)$=P`&xP_??y|4WMw$|d=jQqS<;)4 z>1Tob${qQsX}pWJe|$WtDaUy}q3+-GDdONCBhX)XuXmX(dfe|CL_CUcHq{Oi{T159LQKk zeTtW+>%HMriullrV|})Vwrodg7U~7fPs9sdFS#d)ezkg%lv`Hz z0O?{MHuAm5vD2)*QX}eFM6m-#Sr1O6Kx-3j#`fqJdMYExU{^GOA~)Q~kT3ts%|><= z)P8ZZSktzfK+s&sC?ixv>p>VV!L#DMj|4_Wx}Ob!4`!dl^`7b$k2hdyH0lc^Sj%Hu zFg%3U!|`?c;j?4a=z9m?H1zT!=8c{h`U}_oYMnwXORhU9A5|avYE1v{L!3M=$QG;CQKwT1a z?;tcl>ooPjjfz4ape7KZ_nmkn3b>RQe!+dj&|%_LQ2~hU!^tR4i1H>FIy2lu47GNS z9i>yitrwhcGu2OWpm#?o8KLC5I`Z7d@rbK4_sZqKTU&+}1D0QD;yOCKtE~p>9+?_Z z610`OU+KOmIqWr4ucl5;tBji#A4&0VzBHmEr1!fdshN{gx9rkaq7j<+ure#NI~!Bn z{)nqJuA85$I9y(6{>|3SjijPNVO+%Vr(8p~X6C%-BHPJb@%*-9R@a)VSsUGYN z=mZeG+qNVCmryhr&_@9+FY0Gm2nU?xU0hwe8(g6(u#@2RKo%)PhR~86aeVP1d(TD~ z#i}#rSp%48xz9BK7G=q9 zQ^!yTc8^xs=IGUKGWwS9pBCsz(1!j~eA{eMe8Y1(gds z29b)%_4bi6Et|B5h5rN6(dS>RZvwgdy2U?ZXuKJsg1%8;Ep&g*S6cXe{$+Fc;cg%Z z+oa$jG~LRFAoY$#X}B!BZ;Fbt(pQ#ht zP}Y!`f5}=Y=8ma z!m+pjezEsQhKp+!=6gK1)G{O^M~q28Td>#V%O#W5Rb$zNMOvx2GRtJB6}J^2F(bZ0 zlKo>qhGE&l*}h0UcjB%r-4@`F14tW@kx`)u&vkSA{Y#p~s|jPOUK1%R57WUT)Z$Tu zegcA7u4f62+;Zecq^`&5i+fWk*gCuVU#586gK;F~aNUG3Q75n^OtpG2xpfAi(U60m|~kKTEO4v7YMV;baSQilo2S-wy@~hlvq; zkUbmHFT8~0Wkumo1>r}&#@d4$;G`@z=nZTl=otyR_??HJA#ARQdUzJvU z`b`x6h-WiNGGHPGP%$w{US(e|uCjE%r{bP@JU5Jt`AeVTr#V663xux_Uwa|dFI;~% zcR>wll*s(4wv)T|)b_i%cEVSbC&5eCFREpTYTkF2SD5yEQVpS`I9Zqur2iUGOs+ga zErGmKGTaUe>7IK-qw?YL?;Cc8)K-5|&72tSmoT+mn57VmXZ*Tl7suJFw|-BLoD z3P-vHYAwOw6!w&o;1q$ou?u%bsF)`XXeM<={CKz9gDf~txODXZ1w~$tx6+6XQvC0sn zJ|@f1QB1XEl1rI85l}*^!nopl88!2^{X`%PoGol4%nkQo^Zu*+r2oy3QtirUXxBbg zvH1{7ifPod5sqhTk%`*@;fDe{OT`>CLU;qxH0i>%%}YL#{4r}1H31)9xTg(>aiAqx zGok5UH3zzWpB^cmS~Fc2iZ!_2wcqbiQMDGt_%$a3O-Qx-h%`MYDC}9x`_*18x}MkW zj)vp~&g0?0yX2P+;o@X@wb_}cQ}EgW$2nQD3>&vf0tD8++%v2K=cm+unQDW~Rp%{s zL{>ZU7+soELyQQ*g>`&lk$_i=mEq<(Z`jw`u@W)tcl4l@Im6Oris%qZ@3~YAGCJv( zogpH$u-n18RfbYC$YU@3*Hd~(-nYT?Dk@9SWD*m-7^2T*D{b(v+ptzGDkPRZMi;S_ z)S=aoS$e2miEZv+qH#tBCl6!IL2=ft7Ih zHYQ>N0x2OUx*PBbA`d=G-+SZsoddfEO#-xcTaVVTPxyEc85@KBD+>P{!HipQqZk;y z1*Xeqq9e$%BmVBV2&3Y^@KBg0|1bfQQ{Clzdzv9+A1*XpYL^9td{RY_p*xNpjb$XY zPMLc@A*Cy$Zf1@n$-|=@I09s+oArB{V@V&S(ET{W#?TWl8`~@Dpd%)LgA)Bq+1_7%4G8^k{p59`@K8v)K7;G6)Tpr#3!{V; zGYMmd-(7XS977rDm#K&f$MU~>rY<9PzO5J_)7C%uDMnA7(lH541N;e?qXIXcD|9b<_`XnwWe0~=6qdwvYwJZn1URq;0Fgv+rKhcoMF$D-rYpJ9s z*LHqyX8RqmD-Uw3oFq7jA=P_7!sW{F=EnBj{HmL0I?cU3e|G(GbkFByz&@L)Y5jPQ zMjxL+dv@_>iq0Z$UPwmp?Y~$d>G43>=BIPBavy#yu9-T}>ntaa_0ISBL1iN7S@x~2 z>CA=#HTbLU28R6s&D{xJoE9VkumWF{ zi%Br|Iy-#LR0fI_2Pc(0o}`JTN{ga2>?#>FzajA$&90X|pxM)h+cBXteT!C)GVO2J z^1U9HHBM1#tra=7?O(euw$nfS(N~7~qW>KY&4bsd#}L@-coIQ-8k@@L{0J<&!pKy# zA8kA8goz3HDOpF(rMvb)xrT0z!Abwpk?pr168CzN2sTqWON1SBAMqx+EP_z;WcAr< z1lP%rUv=-b@(`bXY2|gldXW@R%!TB(NzhCy)C3wOEb9e3JI*j1-Dl6TY`aXO=6|@( zd3+wc47id3I&1ZLVv@R?$g;D(y*s@YfPJYIQ=mQLzJXD2)vB9k#$e z1R%};0LXC>$ebUylNV)3$YE~o&KXzhS3#!}Z{CRPObP0D9EzDP$i;Gr4_)d+CLO2g zx0qq~D<6S@~4^l_%^K$yN08i>VR0=uw|0Md|p+`jB@~wJtrB2oPUQ|-pz)&VM z-K(~YgUlcwR=3*!P1UChpe#UHie;Mp`1=V-1f9zJY8`PZ^gJZiGhhEh0bBGka#vXH z{!UirdWM7v>4WzO?L_(rywtr3Za6uny)$*@Cm<$#yDuvH6qtk6IOv%;Dn7f6)^+~z zX8&>{?IWSLHphqA8^J<`8x$5hwmvvGMlEO%U3)D5%M8E;Zv(jyKHNvvUy(Kzpo zQGch^69U2nSpl2f+r73Y=#4XXX)zHkMPTd@x_`JL_n8%Rd3%;3EbYgVqtlo=M32qWAJ!71b{0lxc zx`#T1Uw9L*Nsro7O;CGa+$SOo-_9&&6;>i|FHNBL%(0u-Yo{E;Y+mu+XKILltFp5U z!We|oFez?1A(sqK1wTmDCqAQEz*fnZ_+KtSV`xw52gRM#!T*ff1^jDc631_ypwX+R z=%hTZAy9;UtBDH$+Fp^rHxU{xLS|v~Lxn|*y8G1ts^;DH)g90lV!1{_+w^p%jyCDp2#Tu*U3^i*Px7N*8`KqkH)QZ$}3vhY^-Dv#}7ki;+x6XX793Y^BsP~l{nb@FH zYR_d`ia1TYsBIv9lAikA*Aqnk`iqzx+|o{HfkJIf7er$6kgArBmt*&GfH zsenuX1a*MSShy!O0t4gOdr-WUm+3l~NgThTD&Y8cD6bm_sVEd##BXqf5!uiOJl~Q% z{$xjUW{AtPuid+K{#sNDBfK?=P3$KX!`f-kVr@YB5H9cVqb9Dla^%^XU|)_(1~L_| zAN>l+S)XfV&-C);9H7Mq5J(_)H8rNCF9F&zX6NSq%Uee!B_p=RxSKUpRX-`7d)cQ5 z(#=pZ#f%R5-k!zT_R=z*Cx|(QL_d~ew!WogCf9s?MEf49z_F%$YR&KgeR}hsGFA1@ zhFG9qU3Muz`4PdC*P|{c=p4Be16uVIkNr;KJL1w%nr1uu#qp7K_Al9#_*U;lV=hQc z|BU?Ld8T)`{Ix=FS3cTeQOz^+8WE+t*uP4huNx3UaU=^Aabd$=-*edAKcLS8KA<#E zDub*y0;y1SCl7^+3bOJF%w0Xdu%iDdRG|zv4VBK+XHGay z(zF#u#*bHNI>h~}cWot0KYiK{H6&3*v0Xhun@QXuDHTTDj=C?nh>CH^1O@!N%WXa1 zOMWP?Fc;oo!qgv0qRXC{cxwkyhVd}k8zAo zOrUr7A%X01uQOKcAC$?uXT(aNkJ@U)^?>@E?Yj+V!#gB`27u~1@-JNGV)uSVa9abY z%f;6Yrlp;1T_aKdo-4PyI636ccqdw}OgotaJUcj4O%0z;%r- zfU2BrN~6Ga7!uq_g*v;eR33^%d#>lw4Dg{ppjUxcljd$7F#CVU&IkbOz$5_NJm387 z$hhDIsB0ab`Saw~&}K=WklkSLEg2~C$SE?7x($CfH}FFi{d%v2x*HAkj)=radR0Ds>)z3nQ#9S=vIEUeqR3us7Qqq;6i~8nSPoQDOi@{ zgv$I<*{!F42o*^j#)mBCquEBrJgNp{1pMX;W<>Jv2Y-nGQsM)@ceub2KwyS)AnZrG z0I#3_L0C6n@#oeDm04uxsJxkL}1{@!6)kO{-l(tq)n&XlQ%=qaT@K8)T$x+B)lj zV%#CfA9dAg)I7X8PDd;|Xn+Q;|Lx&pw3@NtNPED?KN1ZU0OG@N1x1kJdQQ{Rf*2Gv=Cd|C=!-(BO`1;FI$YnZznR{!}IkBunf3TMYsCv*O3M z_3ociM%PYHNpPjtpL_&YmRCW&O~*X#wE$CdEuEfF(jJmdV08)NKRV)yzgp(K&>chb z)l~NeGG&+)=v1Dsd2T4zO?_3W%vz9^&jL$0mRLS3wVYhlDYS>>gd#H%|Rp zUB!IXnDx89=^4GXi2zd)g$a+E`s*P4E#v9axL54`yylcm-zo1@s#?w!-V|=Iy$Q-a zG8dQo{f*c}1KBY*8pGlqLHz zm{1LoWf=Q5Gh{GkewV(_^FGh}{_*RNa_Bhj`@Zh$zCPP|o}bS=UTnxlYD52@4-y@N zHTeew($(z;#if@dvIe36DN#>|`ga(}ldcywLmJ5t^?ZUlzL=E6QjqXKvXywGyoeG` zj!$DPTXxPcLIfThwcR5O{!@k!A z0QT;Y%m?)PK^!*ZMDkAaLH&XE$yOyp4um5=KWvb1CUGD{9&djEp+6s-wv*apa`ss? zR^m_QghFyv_lT?0OmMNb1{?fyt_?xo_s*+oc!!KxrA&Gs6rds~4Ggy${~ybO_MEoSmWM!I zxd`qf?!a4R;X!6RZ_ND1ooj43KA_NRLKgJNM^%$>ZEw>dkvPR z6khpla74lcpG7MeQ+r3%RZ=$Q!hBwPmn$|_pe$QiI{I7~ zpnfeaHexAh%`a}^i<;ua^7n$&uxbT4a!$;8$&%Ihl?=43uAnaw>#cR z{VWwP_4ZF_ozNM-Ir1xp$AOpTeDX@LGqde`-8}!ae&utcXNZ6mde3h|4+G&J02a_B zY6+r>x0xqmTKdwr1^Zuqu+pBhx;{MdqS`g+ur@$VtpVe-9 zTx0I(Y7N?k0)!Bfr`n7J7GP z(ZOKB^Aoztsk zgsc&RMqZni<-cMT&NcBXT}Z( ztB9extW%eaPixQhcfgd;yW>Njzxw^y*)5c#M&o5oSe3#VRvp?p-Lc2?+#E_CPHdyM zZ4YxQl22(@6+QgYk3>2tG+Uqc8F?@G)6u`d7059JaYdQPiqC(R6A3u4`0lIxe)Yr8 z*PaEbR^wTU0&h~omfI)$u?&YJ%bdF0&cnJCrFa9R@RBJ;(mo3x?<1s+frJt+mQZGu z{!6?>%L5R+d#siq>z6GM16Y}+F4!Eu zSuQgtlszkFjKd{rVoJDjPYtN+q3{h*_GoNDmUNq)jr8)Zdfw6rGC%{-?~DbJD%I(( z_^BIj4YjX4y5llb^D=nRu*O2)H|?{09j6O_t?A80LyUuj0I{)mqOW7!E5~(O4wOm@ zFEUdNwFWN^$PiEe z-$`-a$3Pomx3tDqmEH6~=61Q0fNGZRvMCD6UY5zXR@IwTwUB{#>lXy958**(XoMAB ztOg9Q(*pMacdK^;#4XaZASC_xaDE6#VBqre&BhX>i}OR6FOcT6ov;md9+99^Cp-1b zz>21TNy3D4Ky_=&&$vQ91%8I}K30)JU5b4JHu}hg(XIjHTk!Jl0{Tmma4fVx zKngN<8Xvv3q6OrHTaMNw|A}O7UaOb^g(5GzP=@4r?cPCVQR{|;01B-HL?e>bYUBrcn=OWxLAr+Tu4TD_8gM`z8|U}xyP(;s^>jQvcsEp$C$5jq>KMkxFDL?k4U&sipO&Kw}-#Q^8Ds?vmkr< z6{`YG@xX{gV!3}h4?;6-2NB>cK;uAvb`;a02^lc^{*qWZ9v5lTUjyZf1aKYefeZ%0a($=pfFZx)7V&tL6^tWB^4QukbT$7484%p zI*v5qDSoTx2eL+)$mNR+>}>DVhnj@se(makX2igrvPhpNl(aP`jMQdP5wl~j16Xwd zhhzOGa8OWCh&f|$>Sw{Qw^}RE)yHF+i%)8+Bc?}yA@uv9n7MC#xW!a59EuX1cK5Sw z)L;|(@&N~ED7@fQl$`w^Zw-pbfQCX+@vYgk8;pA42ms~qcb7Tz3%1b%ki*S){!SAN zJEgQRE#FHG{*)9M54ap=RQUf4BUMEoM<5Xxrl46(Z0n|mfwLQxc_-VV>@6-LjeB;K z8A4tVwE%fo=`^jlTmp0Q!5kEKWH8L_xT8yAf~Kxw3YVb1dIUjYrf;KBp7oDMZ8|}g*P3&>F|FiLxy8vJ}ZmUe67ji5t z-p(3@s;CWih!eGWz#4B7rmp4JEG(5rkE9p@5gJW53DrwR1_05sr%MWi$< zm%AvbnTaWv=1P#mSr3t~l^*|9-HNd6gciI%KMM^Vl8PVaSAnh;a=sPs?ts@o7%#Xmco$wuSFfeP+V-7(0n10Xu|hK53V&lAYz`#>ScDv zJ=XWO8ueGQ&|Leasd8B)7=O$yROz>)Gfm`N8iMA(wMnSD44*FY+KE^I6s2dC2eLWD zzpfnm>(H7>ZugTX2dEdUJ~=r>w+ts@D&0@ZDE2FDp^)~J&&Bc^vQY2YuF>s3^J1kmw)g5 zCmCVe_>Ep=)p}4GSOj_wst|z-#7F1h(m+{c;Fm3tKg=9E!4+gn#2q`*! z`-IaChAq$iAKCg-4H8fYusjv6>wNiLJn$it{5{J>VUgJ+gY~ThtdREZY?>MkGU6bm z=$xN4m(~Pr?pk)PJ;+(wo^E)_o9kjYUGjF^dZ8*?6B@0QMg9JAp24SvZ~}y9idz$1 z?z<+Uz=`yMnubN}4NGR|KTaNW-1|KXpaC(?Lr~BFRlW@`e%9D$%FVyS{kTbXkCbcY ze-(MfKU)V1t=LH_ke#vgZFFcwq&Tp2X4~VH+S*bP?ItI(Rz3&ukiIw2Jq@QLeaRCb z`Hov!Mj#Q32lE|ejT)k!-0mlpzm!YIes@gB`o=Ev(3`otq%9w%-|mJ@V2iw(q7CQ5{pjl>>QM#d z(}@ec8?V@uMe)D63FM%U;%HOgX|V;@G2Kk~gSyE6ytzRjOd`x(ijC&eMs{Bx>U&l|C7M$w*xJxawEMJMrPr z$^tXX5RjCe& z-O!ZjAQ+zqB^Z{T0zvtHkn>U1`SlnX#W1uU>2@TY<{5hS@A;WJB-*1tNWDU?%pZnr z9gmc%^hl=+6`*=D1M0;E&=BJaCECU!5aiPh5`)t5kV8CwB*)_^P>rkGpy2rZ;4kbN z(UI2Dq6idTNi09UN@d6|eA}GmRFV?pRD4V$+N!k>yQ^XomEzE3k9wy$C}{evNY(ErLmN0BA=YeBKYTa z<4~G{p!C)B$i(io6%C#D)nKHBXCN1SwGzuHVlp1sKi|U?M<0lu8@3A?KHJQi_}+TuNn^B z)@~B8H!u$EAl82^p0&HvOt-wk@nBef*Bi??eUwezUv($vY@;(6@VuH&=aqi-p4svQ z6C*e6wRfgUej?K(6|%Syd#1p9$NCJ#p)uHIhZB3TOym5f=G9w$B~{Pq2C+jo9c~Uk z8fPNSy-rZR8a5+}1j__kf9_)yyo9awWRGyC3>rLtwCt z5@YzXdGU~i==NKZ!-r7)E>MPdY$6rY7cYIKBX386yQ&wGbQ9P%5VZa-p{VTrZ@O}L z0~pnqYnE!lyfT516vy@7i@VpByxTOnxbR|N{j5ud{&|nB+!%ogqwz2uv%FPK-S*3g zVDN9OqRTCYt`K)n5>$VzUB#+J$fEuOW~er@u)Ig(aE6$YON68zB2copF9-rN=d-Jg zgyiE~9C!$e4f+I^LvA(=3`AQGu%?vl48KB16JC9lq5&1Ihz|N2QZ2%2S(&A7k`l`o ztob01XB|%Y5dlA)49{|;r8&EP+>7Fr&oKI8>@sMk6Pwx(@x17uVyD*iEdNA+I1vYo zaKzu7oHbsUmT;#HKxrTnd%1DcOxdreC%`wWJR1Ej*S1{+J*G8ls^_aRb*8dcwN~dU zSN&whl}ziuN3W_%5R@&3DAo)ejrsED9u8!<={$~p|%&b{zRP2HitJbH2{NSduu$m$R=mCuAp6Emlc(e}1HFytHsW{%y;fx9=8H`7(K|Mt$B= z^1Xhi7%SqOS?$9ftUjC}OwiAgrxzd|o`y6m+42&hra-R*2!3AQ9a(j}?Hof}I9B(? z0bvulR8D3zQh0}3!xkN$!L$$v^@i-v0&sn4bb}=^OaImf67#v~>(yf-=EXi7*RK@9 zId?{q?%wPZ^C=F(E(KVS+dpc+h~dOL zwQ)LSfQas=#inhGb`jMm_dioCP;mfO)ErE_GEkc`%tOSiXMRtY-i*WaSset2Nj8jAKAeehZ;-%8PJ;RQ?_k|87UeuP(-11 z_Ef4g@Uijj>t-3^HvRUZ^4#Z3cr{NI(Za-RBNaqmk-N&eO~9(*eQCP||XOCG;ioB9i6>$8~F>K zy}qrV{VTBPsIyrkP9Iot|2=O`B`{^RmF)97Miww#%KIQ>Awkjv(yYB8O>@LEC&xKXdcQSwQ?O^rMDVdUzZZ1*ObW#(m zBgG#XpFPmQs?dXecRqz*$?}vbU9P+8mjtov4TPF4>QuGnSbB_+Rs1N%>4u^Ms~4k0q%+Tgj+30y!l|IE^Y_yz5EXdD9w-CAyn;r6inr3dzL z=BX@ujwh@{yuw&OJ!XcQVD2t90(YeYTa^XU7 zsn%PyMXT~4m-uft%GKYtes_px&o0FY83cEZ3*9@m!oKO|@D>>4(4Z;#p!?}UIVnR& z71#0@)|NzIbxiZ`Ez`0~8hiei=4kba(bNCy?YV4Pn{LsJaar2O2<0qsncR^ylHE5Z zLw*^~qDq-uye<*s zE0L!sfHIS~O4L6@1Y8DaV4FRCekGVZe2#~l9W6Y0w-%6y$xNS{>duVPyj@`?mf$a# zYDiMOXSuNDg3iq_vT8kG&8;sCE(FH&-%#KbsJ>ED{6%2^6&}>2$)g6t^F@*-6-aF= zb7jV(=Zyvof2bQ!!LcJ0xU#eRBqSUteJynh=08U+lGnujt1Ll84V?dY^b+Lh5)_Cz zOwypSN7%xDYinY2*MEN*8^v>%I2geOKrru8*=+TJivGO7g-DBGrI#EuRJ09>d8RcFHe;M}J1s!WgVO=KGT>)Vd|ioLH2mE z|Djq2kZubLLvGbBP5g^J?JF5rKfByoRROJ$0K((LdqVu$>M=yLSPsjfH{np3;e6c^ zH^_XTKt|NNn1v_%@4ANhI{7Ussv1i&pv47{1kmNyDbG3uKE_0VbRP+lTN09)FFsEZ z?Q`S+3=Eu`Ok%)obDU<9Rz1VUUiG?lXeoR!BjBygwh;q6&(BX+v;&C94`%Bsd0owf zuP^);*2-O^>VQ4UOnx4+MH08UB1p^vfi?}O&ud^D=>8#F)LLmaPHBC7h^wk4<%zjG zWL9l)gGw|!9Qm)FON6Px4s_ z!kxbvQtehMV|15dSwVxp4TVN#;M2$kp=yVfXaD{kTa^NB&-Sb^L(^M0BS0bfF{10| zPdNaHuLR$Ej$CGV{3I=ZT9>~r9eB-wv2wt@Kj+gx#YimKmhfRd|rgL|%7 z03i9h49H!8K>uG~Yyd`D0O=Ay8pyk)=7Sf{^W{&I&L2&~fJ-FnEu;*yNR46q%o+z^ z&UO7nfy{ekPsjcO((WP8Qj^>-)is|>F=Jme19qz9Dky-*0;u3WMFmObVTwk{trMc>(@Tj53o)aSA64YMN{6grV6C0G^s-&QdcL0jhky`#iVloe` z1H@{;wJ_;_Mp$i3&dkKPKT#Krt=j;$_5nG0oM|rrR!IP8jmnf5^-Dk)AEPjPlTDdH zX_q^#jlD9H70w@g@V3Im-+C`dY`~XX`q$fd*BSU6 zZUEq^-mP?Q#~DaqK5fk-wV7p0S*{=F@}LDYW)M7{X=LvY#0gxMq5$mY%wr9lo%Q*t z$6Jd};-_Qh{QSJ|65M1`SFK2?$7T2nx#eF3W8qpRadV}A!Qua#4&S&rsj(xzhZ87P zi4nd;%db{{NgjrZT=nl3Q=fHfRC?lY3?~;L! zQOt^P8`Kbu-T1H0K!07@#dH7R{hn8SQCXr%K{sHGD~ z3~sI*WTA%>zI`7%M_DxXdA|JAs?%peDF&etidi__C?w+g<i!QU*>k(K8e@~-J*RG5Jsw8Qqk z#JGuhvNqGQz~!g<%Je20D~m~I-oJZRSz}}Iq}@e;*h3Gcf_3C#1}P2nz3-77t16#X zLrcXop@lvKHmBB$PerH!?eNWy`#Ev1l9shD-Y{LV%*#s76Ta*T8$0p<4UhU>bYFSa zs=BTs^jT#sX1RY#-UHMJfZYNIJ`rD~9=UU_xd{QfT!K`Psv$ATxB-1Ka%@c&dk7-m zPT^GCrF#7Z91*v#0(Z4bgGfq;myg)_U~OlIWNK$WJ}|hL_K?Zs$q{k-OU~uNjE#_} zG=qkLkN_+A9@#p(>^)D~$-C1x$e!FJM7@`ObbXJFklgt|HFKZrlggbBo>ytp4jQiC z*nco~z2@y`jj_w})8A-7Fa_TbJ`e9lH#m&wYuUNw{A!UB?ccKAQV3XThDqD-{DZr$Eoq z_CT@Me)u6fF4y4bqezNR9e9jm!kRy|cX7cC!2B1L04A=^$`tdwYFNIs7NWW}|7M$i z?df5zTXs;SBCz92od)mrc6(< zuP4Ya3ABsZ)!WPP%~}5-VopA-IuwVfKDzpbGVSW|~ zJ;1*~#ye60>)cG|F+Ez$aqrbN2l230MJUK(Oy!V1mUeScB=>%pHI`XMDXG4$P$GHN^g1I@@lTiBhY@&U`KKpP2pbbXFC z9x=DWQCBOD>>#w4as&}0;@-Rw6VJT~23Q`3N&N9`5(i~GzEtOA9sV9u@>=Ighqj2~ z7oSj!0lCIaZ&}L`hgdbAq-U{V9TIAe3Imrzaw$rG8Y8@JrL$WV70SW4qqrH)rvCW( z0po{+-ew?v>Fw`>GR1n^QNk)n5dn;GjQAEw!|mimO`AV|{(NQC6fmM%jruNMD-)k& z{E+~CE(EW>UxSqGwM1zLa6VF0b7XzJo>BAO;swe=L_S$AeeuJfpZN)Qh{gEzlPiif z4s1QpHgRML&KmWG6Kaju^`Yrw1y{}_`;jSYY)seUx#Rr)_WYhd&4es^b*xIN>)>o@ ziK=2JHhKnZ_%vLyfjab9&`WN!AT2rmOBn$ep<-H#e zZV&E5itMgb@d}N2xULyJ&VS_fWNFZs=dc)$W9{OjA+-ugJ1xN4C z;x%W_Lv;px*dS{NgVND*0-hA(*Bd$*58*fxH+MCGcodX1NRB<^ z{M`3)gFR@tG|^`JIKe4(aH-~FICNrF(=a#4v)eMGsLqu0j`AZi@HXnHP4M(&$PV@% z;_vwq>A1lqYSUrI>0^@UJk;aqOR1?b4l4VS1N>KmnXbrY%y>yLS&&=1+iE{ZSX8j# ze=S!{rRdYSlMz1wdOWTv#ebWWC0VOe6<3^8;|`aOpT)cDQJ(bSQ<#962Az)30fa! zMmH~hKKaD_T4GjWEEoD#_8DgnH1yVqFu{d^xV0EAf{!;%h$dr!j}~_}c;MRU%Nu7% z$EriG(L1ts$r|=FJ&bI)_2PNQ@kR#;HNRKyg0Sj`H?qe0#ha9j9Zw`b;`s_>-}^?m zj2NaCcoom>pk$8|p83?^yrN}@p7%kngwMszsSz!RiA1Iz;>q%ytRb-HUdxH2Dv_P6 z-JolPidSl{QzXz04O%q~preCscKW+@7kga=d>nEM#Ygcqsej zK(d4=dQ3zigwM{!8|V}xTp>5dp1)!q68#{vfBqtBt?J_^cP7t6F9Y4wi7NG>;DyUM zBNh0jbGrB@*iCLiHQ^$0U3Nnqzmo$MnT>SM2Y|7>ygV}fk{kEVtWoLvo-t^MQk>`X zy?m=%?7#Bm*Ojo}1&9SueAf63Z@m7V{=8ujx-cM20!xW8}I&pY$8q#|ff0@GHIv#BClzBGFeZ0V@5@EuUiglTV1! zCDi7Z?Uyy05`oKlI>d4-e=}l2v1ho`fx_TAK{AVTgV}z`3p>fvWbb1S-92XeHh~5 zer}Bc-(1#5LAGS+)eypj?EQ`pPTpLC7bVTUuT;_gav$c~QlP6y` z%JXih!Nl{AidMJT-|ufSebBQ!Yh;Q-80;KKw=fcWAm|P|24e4rrWDUzZgNr;c(jX6 zo?)F-kum8g)@B=2m3%G_&%zmENWU*STsQLzWn7s`I1%m$dkSXrK&ef7wVJRVk$4m6 z^}ddi{-)TVD=ofmcEv^|@o<;7R#6YLVCm^%?`%s8jImRf#tp30)b%$lEWZ1?_>Y86 z<9eWf?w8@sPgr;ef#;D>RdPojq3#yo*!#k^6a7t$=?)2mh))W?We;{2xruDeLPthh;e1Qu!@I9L6*a75o;{3gv`;yO2utD;u^YICV}W= zUrk_9$JbLLq1PcY+hjzyG40_JEukr>S0n6yt zH6=#`yt8Eo{h5I{q~y43X*5sad9M`ByZh0>Mi(sl+)PrBa?^LmN{1Sj`f-T_*#Dxj-!8ajK9B`8Ssj(<$FE9E+Kdp>B?;P5$ua`%4p zT9nNFQV0=Iv(QZD9J-kN*AjOS@%_^Ap3^~?J<(HoK=3@L6f21l;^iY6h^_6{gYf3M zy9wgXcM1Mo@#p&X%mhs~*ch~JOVvR)>>8!EnHXV{TZ~_fe59<3T?#aR?4ph&*SZrC$@B@$ue5p|G!<9m3nHTreN;DCy{&r+(iZoJR zKOHcVlL}>tDU1@HeG>}XM}El;1@WI(@&uFXKv2wYji1J2FW26%6J5XAHcda=t;x_a zpE1fYSx(Q*8GVYAjF~0}YwFOJDA4@wJ;2(1-}mgOqDLqABJU0v+>cHVwijm_XItKO7f`Jy6RK z;6?&-p?$2J{hdL09>w_HGh`PVXJT7;4TO4rcJ3nQ>{v)%2Q)t|>!rJKsX za;)Yk*9hauYg2@AO!34T*Y}#a&!O%~du1MJ9(_T7vJNtH4zh%RMdk(WfZ}tVvm92c z1TOu;@ZD{J#+gpixdTTVx#A+#?#{?0DQacmm+Qmk145W9el461?XD1r!&s=c+Xii+Ny`YNCeJr1(K`*+;`BEKQuWmAjx@p z^GfE<@vbXZmsE^6!^|f3EIo%yCKpd#9azcm+h!(wAuSMgIz><$zBFCWK7DnaU-aU)Q*mLNo52Us*eIw<=6!DoXm~ z>+0t?5$c<$75%B}QhnK>)`5db>-;v$>Z9Cf6Yv7LNr*J}P8}wAo*bAU$x?q+9of`D z(PHa8H5Pc%!T4xfO_}we=fyPdgRuiLKKXnlqLrWKh>0q@0d`qz1qc)~e~cx0WZF`` z;U0bxyhVnDGWS4>g@A1%=-?>7ausr-7qgs$W;u0r<3XXdr%X5PU2D4tc}|*1MYj!= z-R?;*Ey=uY2^aD>iS3oh5IfXi$p^d0cC60v))75UaY=ki|E8RNPI!bRceDy*|5{`~ zTX>62gC7f6zzT?-?-k(Rq)KpK4u96ryxRAo*xc>%xH17!ny^gns5 zp^$Z+aTUo*%i!-%o(gr*Q^#yz=I3vNy_!X-(!BDo!4??DU5(~tjhgEpWFJp(DZF9U zF*lxHO;fm8l~(z4|Ld{Pj5a}@w#6`zw)I_oz{vuW-M2rQpB}Pv7V{%J)HuOX=u>!T zfK}y0fYDHSa8(k$1K%bP0SEaJC@Ndx`_s6aCS9yj8XS19mOaEoRk~+KJXZ;mF0Rq| z&e(D%F>ZYzzB9c@ahsH=nG--2<^h}|ydHeDP{b;%C@*&;g+M9ePQ@M(A7=;6btP@pshUqzwFH&Hpzq>^4#B^bY}O?^o97PLc=zD>3|d!JJp}+h>{E3ras)Eq+-a zRy;mqB>BO^H$!Y%j)NKP&?-cTCk30_L<)Ykm-i~fZpK*HUHpPU=XEC_SxeOLJ0Z(xSYd6p1nzw5(GR5sSA{!@+x0o2Yc@{gO6i1&c9emle7NF@Yhgc8I>ttd_Skp z6)aDKmS*Y(#$q_HZfBR22Bp>nxhlfju-!H? zlFmkosu*({sElG<`Zbw%O@hVo64vGs$yYy3G`pRgYZ|x~4qZLQmWD(7goX1)T$|h5 zby&4o)B2m6aip8O-%y_=Xg1y4+(5FetE&r=wE03{;(Ib`90AL;$9{(dl$)?BtZ{j2 zJD-h_jR}s^aza_>Se?B&LZaXQ9|WC}GZr$(18 z@&mE+#>OKp(-@R*B2RN{=zN!~(ag-u{F9cRE#Jg~0uF#cAAHqjm~m>cbu|t-m?t?U zI4%qP=jv>?jDNkT3V%Y)zcs81@8%}#(7(vp-CfWGh&j0E&K#Z*r1VnBn-6|$Y;b_{ z4ax;D)P=f@D8Ez~>|EuO`6^2 z1^v_idKDWm{lK{MQ6#C00{lTHxNoITGsH6iKkRXE@}OGS{rD*vp71W-(Po!t$JK?H z;T!h6!X`~^oW;ZCo0%m}E%_66O}4)=6D4joqu*4lQIyn#FS$uc8?=ZLqg`8JH<99v zrCtBEQ4-WIE#==7@V%32NtSTvHt|3>`TGWf$h`$rJgmOWq9ovtzP7PeoyNVW{|6If Bc3%Jh diff --git a/tests/unit/snapshots/cox.OSB.png b/tests/unit/snapshots/cox.OSB.png index a80c273243c378782885e02f87e4bfd3446831a2..c3821555547a61979c5d36784aa93b817099f2aa 100644 GIT binary patch delta 37047 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 LhC+p$SaOZjr}o)h=H3XRRKn<6QZ z#0*Ehwr9p?fawH5*Nz2AhtL5Kp=b)`jZ1_VOz4shK5PSuxyN+FIfg>|x@66PXhcTv{^x`+MW>Z~wXuIcH~S zatP!xav=JxXb?K)J1an}k_-U3Z;Xcyd9Q!zr!Lr>edx9Pq39?JPtkv317vs(I<2$V z!&;R6NLfyRCpwRs^}Rd8&C_415L|uefBOCSwSmG`q~>J@HLvB_jN#y3D1n+CVB+Bc z-S!P-#SRC+E+m9ZLA@Uv`(@eg3qTAA6s0c%0A~5EtgHZlcE=LkR&n^oM)HlKxS@8% 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 WLDD|6CE!&V1e$8PPis}|qW&MZ-pWG& diff --git a/tests/unit/utils.ts b/tests/unit/utils.ts index 3382304225..b2a77220eb 100644 --- a/tests/unit/utils.ts +++ b/tests/unit/utils.ts @@ -89,7 +89,8 @@ const mockUser = (overrides?: MockUserArgs): User => { QP: overrides?.QP ?? 0, sacrificedValue: 0, id: overrides?.id ?? '', - monsterScores: {} + monsterScores: {}, + badges: [] } as unknown as User; return r; diff --git a/yarn.lock b/yarn.lock index d3a1a5c3d0..15f2921fda 100644 --- a/yarn.lock +++ b/yarn.lock @@ -679,9 +679,9 @@ __metadata: languageName: node linkType: hard -"@oldschoolgg/toolkit@git+https://github.com/oldschoolgg/toolkit.git#8570a6a8b2def1bfcafdcc5e30bbf5dd614c12c7": +"@oldschoolgg/toolkit@git+https://github.com/oldschoolgg/toolkit.git#87450a60c73136601714c77092793d2f432b70b5": version: 0.0.24 - resolution: "@oldschoolgg/toolkit@https://github.com/oldschoolgg/toolkit.git#commit=8570a6a8b2def1bfcafdcc5e30bbf5dd614c12c7" + resolution: "@oldschoolgg/toolkit@https://github.com/oldschoolgg/toolkit.git#commit=87450a60c73136601714c77092793d2f432b70b5" dependencies: decimal.js: "npm:^10.4.3" deepmerge: "npm:4.3.1" @@ -695,7 +695,7 @@ __metadata: peerDependencies: discord.js: ^14.15.3 oldschooljs: ^2.5.9 - checksum: 10c0/8e13388aeaf1b76dc1445e71e8b120344805f1c2e8990895e26303155d27fd521c6881bbe50265c4c84b9c7281cb8c7264811f295c267c095cf35423ebdc2601 + checksum: 10c0/3a8caa110fb1d8b90d3fb3eb24e3c98f7e388d2a3f7e35ea9a14d65d01cc3fc529cdb5e042e251f4b414d1b2fc86b62824d9d8c4814842cde9769101c6070661 languageName: node linkType: hard @@ -4090,7 +4090,7 @@ __metadata: dependencies: "@biomejs/biome": "npm:^1.8.3" "@napi-rs/canvas": "npm:^0.1.53" - "@oldschoolgg/toolkit": "git+https://github.com/oldschoolgg/toolkit.git#8570a6a8b2def1bfcafdcc5e30bbf5dd614c12c7" + "@oldschoolgg/toolkit": "git+https://github.com/oldschoolgg/toolkit.git#87450a60c73136601714c77092793d2f432b70b5" "@prisma/client": "npm:^5.16.1" "@sapphire/snowflake": "npm:^3.5.3" "@sapphire/time-utilities": "npm:^1.6.0" From dac3c910aad14efcf74aae96c3bd03fcef722afe Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Sat, 13 Jul 2024 15:51:41 +1000 Subject: [PATCH 038/145] Fixes --- src/lib/PaginatedMessage.ts | 5 ++-- src/lib/util.ts | 5 ++-- src/mahoji/commands/leaderboard.ts | 45 +++++++++++++++++++++--------- 3 files changed, 38 insertions(+), 17 deletions(-) diff --git a/src/lib/PaginatedMessage.ts b/src/lib/PaginatedMessage.ts index 447ea30e1e..8f184a31fd 100644 --- a/src/lib/PaginatedMessage.ts +++ b/src/lib/PaginatedMessage.ts @@ -1,7 +1,7 @@ import { UserError } from '@oldschoolgg/toolkit'; import type { BaseMessageOptions, ComponentType, MessageEditOptions, TextChannel } from 'discord.js'; import { ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js'; -import { Time } from 'e'; +import { Time, isFunction } from 'e'; import { InteractionID } from './InteractionID'; import type { PaginatedMessagePage } from './util'; @@ -77,8 +77,9 @@ export class PaginatedMessage { const rawPage = !Array.isArray(this.pages) ? await this.pages.generate({ currentPage: this.index }) : this.pages[this.index]; + return { - ...rawPage, + ...(isFunction(rawPage) ? await rawPage() : rawPage), components: numberOfPages === 1 ? [] diff --git a/src/lib/util.ts b/src/lib/util.ts index 265d8c8363..c5251f077f 100644 --- a/src/lib/util.ts +++ b/src/lib/util.ts @@ -335,7 +335,7 @@ const badgesKey = `${BOT_TYPE_LOWERCASE.toLowerCase()}_badges` as 'osb_badges' | const usernameWithBadgesCache = new LRUCache({ max: 2000 }); export function cacheUsername(id: string, username: string, badges: string) { const current = usernameWithBadgesCache.get(id); - const newValue = `${badges} ${username}`; + const newValue = `${badges ? `${badges} ` : ''}${username}`; if (!current || current !== newValue) { usernameWithBadgesCache.set(id, newValue); redis.setUser(id, { username: cleanUsername(username), [badgesKey]: badges }); @@ -347,7 +347,8 @@ export async function getUsername(_id: string | bigint): Promise { if (cached) return cached; const user = await redis.getUser(id); if (!user.username) return 'Unknown'; - const newValue = `${user[badgesKey]} ${user.username}`; + const badges = user[badgesKey]; + const newValue = `${badges ? `${badges} ` : ''}${user.username}`; usernameWithBadgesCache.set(id, newValue); return newValue; } diff --git a/src/mahoji/commands/leaderboard.ts b/src/mahoji/commands/leaderboard.ts index 20a783f512..c07926510b 100644 --- a/src/mahoji/commands/leaderboard.ts +++ b/src/mahoji/commands/leaderboard.ts @@ -1,6 +1,6 @@ import { toTitleCase } from '@oldschoolgg/toolkit'; import type { CommandRunOptions } from '@oldschoolgg/toolkit'; -import type { ChatInputCommandInteraction } from 'discord.js'; +import type { ChatInputCommandInteraction, MessageEditOptions } from 'discord.js'; import { EmbedBuilder } from 'discord.js'; import { ApplicationCommandOptionType } from 'discord.js'; import { calcWhatPercent, chunk, isFunction } from 'e'; @@ -47,11 +47,12 @@ export function getPos(page: number, record: number) { return `${page * LB_PAGE_SIZE + 1 + record}. `; } +export type AsyncPageString = () => Promise; export async function doMenu( interaction: ChatInputCommandInteraction, user: MUser, channelID: string, - pages: string[] | (() => Promise)[], + pages: string[] | AsyncPageString[], title: string ) { if (pages.length === 0) { @@ -75,7 +76,6 @@ export async function doMenu( function doMenuWrapper({ user, - interaction, channelID, users, title, @@ -91,17 +91,36 @@ function doMenuWrapper({ formatter?: (val: number) => string; }) { const chunked = chunk(users, LB_PAGE_SIZE); - const pages = []; - for (const chnk of chunked) { - const page = chnk - .map( - (user, i) => async () => - `${getPos(i, i)}**${await getUsername(user.id)}:** ${formatter ? formatter(user.score) : user.score.toLocaleString()}` - ) - .join('\n'); - pages.push(page); + const pages: (() => Promise)[] = []; + for (let c = 0; c < chunked.length; c++) { + const makePage = async () => { + const chnk = chunked[c]; + const unwaited = chnk.map( + async (user, i) => + `${getPos(c, i)}**${await getUsername(user.id)}:** ${formatter ? formatter(user.score) : user.score.toLocaleString()}` + ); + const pageText = (await Promise.all(unwaited)).join('\n'); + return { embeds: [new EmbedBuilder().setTitle(title).setDescription(pageText)] }; + }; + pages.push(makePage); + } + if (pages.length === 0) { + return 'There are no users on this leaderboard.'; } - doMenu(interaction, user, channelID, pages, title); + const channel = globalClient.channels.cache.get(channelID); + if (!channelIsSendable(channel)) return 'Invalid channel.'; + + makePaginatedMessage( + channel, + pages.map(p => { + if (isFunction(p)) { + return p; + } + + return { embeds: [new EmbedBuilder().setTitle(title).setDescription(p)] }; + }), + user.id + ); return lbMsg(title, ironmanOnly); } From 38d5f973bb05fcd67ec04e62dcbea80b0f3344dc Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Sat, 13 Jul 2024 16:51:37 +1000 Subject: [PATCH 039/145] Remove unneeded crons import --- src/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index f33252c2d2..0e8ab5b630 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,5 @@ import './lib/safeglobals'; import './lib/globals'; -import './lib/crons'; import './lib/MUser'; import './lib/util/transactItemsFromBank'; import './lib/geImage'; From 0c4f39aed31c27c76e6370e05c22e956115f0d87 Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Sat, 13 Jul 2024 16:54:09 +1000 Subject: [PATCH 040/145] Do startup scripts in a transaction --- src/lib/crons.ts | 1 - src/lib/startupScripts.ts | 25 ++++++++++++++++--------- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/src/lib/crons.ts b/src/lib/crons.ts index 4480f110f7..e177f656a0 100644 --- a/src/lib/crons.ts +++ b/src/lib/crons.ts @@ -1,7 +1,6 @@ import { schedule } from 'node-cron'; import { analyticsTick } from './analytics'; - import { cacheCleanup } from './util/cachedUserIDs'; export function initCrons() { diff --git a/src/lib/startupScripts.ts b/src/lib/startupScripts.ts index f364947982..eb27427558 100644 --- a/src/lib/startupScripts.ts +++ b/src/lib/startupScripts.ts @@ -1,7 +1,5 @@ import { Items } from 'oldschooljs'; -import { logError } from './util/logError'; - const startupScripts: { sql: string; ignoreErrors?: true }[] = []; const arrayColumns = [ @@ -64,7 +62,7 @@ const checkConstraints: CheckConstraint[] = [ table: 'ge_listing', column: 'asking_price_per_item', name: 'asking_price_per_item_min', - body: 'asking_price_per_item_min >= 1' + body: 'asking_price_per_item >= 1' }, { table: 'ge_listing', @@ -115,9 +113,22 @@ const checkConstraints: CheckConstraint[] = [ body: 'quantity >= 0' } ]; + for (const { table, name, body } of checkConstraints) { - startupScripts.push({ sql: `ALTER TABLE ${table} ADD CONSTRAINT ${name} CHECK (${body});`, ignoreErrors: true }); + startupScripts.push({ + sql: `DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 + FROM information_schema.check_constraints + WHERE constraint_name = '${name}' + AND constraint_schema = 'public') + THEN + ALTER TABLE "${table}" ADD CONSTRAINT "${name}" CHECK (${body}); + END IF; +END$$;` + }); } + startupScripts.push({ sql: 'CREATE UNIQUE INDEX IF NOT EXISTS activity_only_one_task ON activity (user_id, completed) WHERE NOT completed;' }); @@ -135,9 +146,5 @@ WHERE item_metadata.name IS DISTINCT FROM EXCLUDED.name; startupScripts.push({ sql: itemMetaDataQuery }); export async function runStartupScripts() { - for (const query of startupScripts) { - await prisma - .$queryRawUnsafe(query.sql) - .catch(err => (query.ignoreErrors ? null : logError(`Startup script failed: ${err.message} ${query.sql}`))); - } + await prisma.$transaction(startupScripts.map(query => prisma.$queryRawUnsafe(query.sql))); } From 7601b60e3526737af6e73fc4ec1f1cc673df8c97 Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Sat, 13 Jul 2024 16:54:22 +1000 Subject: [PATCH 041/145] Add timing log --- src/mahoji/lib/events.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/mahoji/lib/events.ts b/src/mahoji/lib/events.ts index 43f5cee05c..478d38c865 100644 --- a/src/mahoji/lib/events.ts +++ b/src/mahoji/lib/events.ts @@ -54,9 +54,9 @@ export async function onStartup() { }); } - syncDisabledCommands(); + runTimedLoggedFn('Sync Disabled Commands', syncDisabledCommands); - syncBlacklists(); + runTimedLoggedFn('Sync Blacklist', syncBlacklists); runTimedLoggedFn('Syncing prices', syncCustomPrices); From f64ff4aa740a4a2800140243fcc9bd91cf095ef9 Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Sat, 13 Jul 2024 17:02:06 +1000 Subject: [PATCH 042/145] Remove some debug logs --- src/lib/analytics.ts | 3 --- src/lib/bankImage.ts | 2 -- src/lib/blacklists.ts | 1 - src/lib/collectionLogTask.ts | 2 -- src/lib/crons.ts | 4 ---- src/lib/gear/functions/generateGearImage.ts | 2 -- src/lib/party.ts | 1 - src/lib/pohImage.ts | 1 - 8 files changed, 16 deletions(-) diff --git a/src/lib/analytics.ts b/src/lib/analytics.ts index fafe274582..1d3229abf7 100644 --- a/src/lib/analytics.ts +++ b/src/lib/analytics.ts @@ -33,9 +33,6 @@ async function calculateMinionTaskCounts() { } export async function analyticsTick() { - debugLog('Analytics tick', { - type: 'ANALYTICS_TICK' - }); const [numberOfMinions, totalSacrificed, numberOfIronmen, totalGP] = ( await Promise.all( [ diff --git a/src/lib/bankImage.ts b/src/lib/bankImage.ts index 9ade1297ba..9e1662a5b9 100644 --- a/src/lib/bankImage.ts +++ b/src/lib/bankImage.ts @@ -642,8 +642,6 @@ export class BankImageTask { let items = bank.items(); - debugLog(`Generating a bank image with ${items.length} items`, { title, userID: user?.id }); - // Sorting const favorites = user?.user.favoriteItems; const weightings = user?.user.bank_sort_weightings as ItemBank; diff --git a/src/lib/blacklists.ts b/src/lib/blacklists.ts index a5ee835942..7ae966f68d 100644 --- a/src/lib/blacklists.ts +++ b/src/lib/blacklists.ts @@ -6,7 +6,6 @@ export const BLACKLISTED_USERS = new Set(); export const BLACKLISTED_GUILDS = new Set(); export async function syncBlacklists() { - debugLog('Syncing blacklists'); const blacklistedEntities = await roboChimpClient.blacklistedEntity.findMany(); BLACKLISTED_USERS.clear(); BLACKLISTED_GUILDS.clear(); diff --git a/src/lib/collectionLogTask.ts b/src/lib/collectionLogTask.ts index de805a1591..ae544cc9d8 100644 --- a/src/lib/collectionLogTask.ts +++ b/src/lib/collectionLogTask.ts @@ -194,8 +194,6 @@ class CollectionLogTask { ) ); - debugLog('Generating a CL image', { collection, ...flags, type, user_id: user.id }); - // Create base canvas const canvas = new Canvas(canvasWidth, canvasHeight); // Get the canvas context diff --git a/src/lib/crons.ts b/src/lib/crons.ts index e177f656a0..ec39cdec60 100644 --- a/src/lib/crons.ts +++ b/src/lib/crons.ts @@ -8,9 +8,6 @@ export function initCrons() { * Capture economy item data */ schedule('0 */6 * * *', async () => { - debugLog('Economy Item Insert', { - type: 'INSERT_ECONOMY_ITEM' - }); await prisma.$queryRawUnsafe(`INSERT INTO economy_item SELECT item_id::integer, SUM(qty)::bigint FROM ( @@ -32,7 +29,6 @@ GROUP BY item_id;`); * prescence */ schedule('0 * * * *', () => { - debugLog('Set Activity cronjob starting'); globalClient.user?.setActivity('/help'); }); diff --git a/src/lib/gear/functions/generateGearImage.ts b/src/lib/gear/functions/generateGearImage.ts index c15b7ae456..3116eecbe3 100644 --- a/src/lib/gear/functions/generateGearImage.ts +++ b/src/lib/gear/functions/generateGearImage.ts @@ -79,7 +79,6 @@ export async function generateGearImage( gearType: GearSetupType | null, petID: number | null ) { - debugLog('Generating gear image', { user_id: user.id }); const bankBg = user.user.bankBackground ?? 1; const { sprite, uniqueSprite, background: userBgImage } = bankImageGenerator.getBgAndSprite(bankBg, user); @@ -246,7 +245,6 @@ export async function generateAllGearImage(user: MUser) { } = bankImageGenerator.getBgAndSprite(user.user.bankBackground ?? 1, user); const hexColor = user.user.bank_bg_hex; - debugLog('Generating all-gear image', { user_id: user.id }); const gearTemplateImage = await loadAndCacheLocalImage('./src/lib/resources/images/gear_template_compact.png'); const canvas = new Canvas((gearTemplateImage.width + 10) * 4 + 20, Number(gearTemplateImage.height) * 2 + 70); const ctx = canvas.getContext('2d'); diff --git a/src/lib/party.ts b/src/lib/party.ts index dd42268c21..7e82e6c19f 100644 --- a/src/lib/party.ts +++ b/src/lib/party.ts @@ -14,7 +14,6 @@ import { CACHED_ACTIVE_USER_IDS } from './util/cachedUserIDs'; const partyLockCache = new Set(); if (production) { setInterval(() => { - debugLog('Clearing partylockcache'); partyLockCache.clear(); }, Time.Minute * 20); } diff --git a/src/lib/pohImage.ts b/src/lib/pohImage.ts index c4733464de..542c98b9f5 100644 --- a/src/lib/pohImage.ts +++ b/src/lib/pohImage.ts @@ -62,7 +62,6 @@ class PoHImage { ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.drawImage(bgImage, 0, 0, bgImage.width, bgImage.height); - debugLog('Generating a POH image'); return [canvas, ctx]; } From aa15d19c9cdabc1608114698c0bcefe9db42363c Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Sat, 13 Jul 2024 19:30:45 +1000 Subject: [PATCH 043/145] Fixes --- package.json | 2 +- src/mahoji/commands/mass.ts | 2 +- src/mahoji/commands/sacrifice.ts | 2 +- yarn.lock | 16 ++++++++++++---- 4 files changed, 15 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index 4efdb48704..80bb21fd06 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ }, "dependencies": { "@napi-rs/canvas": "^0.1.53", - "@oldschoolgg/toolkit": "git+https://github.com/oldschoolgg/toolkit.git#87450a60c73136601714c77092793d2f432b70b5", + "@oldschoolgg/toolkit": "git+https://github.com/oldschoolgg/toolkit.git#3eb432f0349a32fde98e981c42653d927d91e7e2", "@prisma/client": "^5.16.1", "@sapphire/snowflake": "^3.5.3", "@sapphire/time-utilities": "^1.6.0", diff --git a/src/mahoji/commands/mass.ts b/src/mahoji/commands/mass.ts index 66320bf354..8439cf7d20 100644 --- a/src/mahoji/commands/mass.ts +++ b/src/mahoji/commands/mass.ts @@ -71,7 +71,7 @@ export const massCommand: OSBMahojiCommand = { } ], run: async ({ interaction, options, userID, channelID }: CommandRunOptions<{ monster: string }>) => { - deferInteraction(interaction); + await deferInteraction(interaction); const user = await mUserFetch(userID); if (user.user.minion_ironman) return 'Ironmen cannot do masses.'; const channel = globalClient.channels.cache.get(channelID.toString()); diff --git a/src/mahoji/commands/sacrifice.ts b/src/mahoji/commands/sacrifice.ts index fcddfcf922..0f34b3d7c1 100644 --- a/src/mahoji/commands/sacrifice.ts +++ b/src/mahoji/commands/sacrifice.ts @@ -99,7 +99,7 @@ export const sacrificeCommand: OSBMahojiCommand = { ); } - deferInteraction(interaction); + await deferInteraction(interaction); const bankToSac = parseBank({ inputStr: options.items, diff --git a/yarn.lock b/yarn.lock index 15f2921fda..f85bfc1ad6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -679,11 +679,12 @@ __metadata: languageName: node linkType: hard -"@oldschoolgg/toolkit@git+https://github.com/oldschoolgg/toolkit.git#87450a60c73136601714c77092793d2f432b70b5": +"@oldschoolgg/toolkit@git+https://github.com/oldschoolgg/toolkit.git#3eb432f0349a32fde98e981c42653d927d91e7e2": version: 0.0.24 - resolution: "@oldschoolgg/toolkit@https://github.com/oldschoolgg/toolkit.git#commit=87450a60c73136601714c77092793d2f432b70b5" + resolution: "@oldschoolgg/toolkit@https://github.com/oldschoolgg/toolkit.git#commit=3eb432f0349a32fde98e981c42653d927d91e7e2" dependencies: decimal.js: "npm:^10.4.3" + deep-object-diff: "npm:^1.1.9" deepmerge: "npm:4.3.1" e: "npm:0.2.33" emoji-regex: "npm:^10.2.1" @@ -695,7 +696,7 @@ __metadata: peerDependencies: discord.js: ^14.15.3 oldschooljs: ^2.5.9 - checksum: 10c0/3a8caa110fb1d8b90d3fb3eb24e3c98f7e388d2a3f7e35ea9a14d65d01cc3fc529cdb5e042e251f4b414d1b2fc86b62824d9d8c4814842cde9769101c6070661 + checksum: 10c0/e3dbf7ef73a2fdbc6e9beed5ec37d12ac246528d63185e387f855d2b0f64e2501db92ce0da940f00f6eba3c352e50d2f9b6e1be707471f30ffb9b9d07e599139 languageName: node linkType: hard @@ -2146,6 +2147,13 @@ __metadata: languageName: node linkType: hard +"deep-object-diff@npm:^1.1.9": + version: 1.1.9 + resolution: "deep-object-diff@npm:1.1.9" + checksum: 10c0/12cfd1b000d16c9192fc649923c972f8aac2ddca4f71a292f8f2c1e2d5cf3c9c16c85e73ab3e7d8a89a5ec6918d6460677d0b05bd160f7bd50bb4816d496dc24 + languageName: node + linkType: hard + "deepmerge@npm:4.3.1": version: 4.3.1 resolution: "deepmerge@npm:4.3.1" @@ -4090,7 +4098,7 @@ __metadata: dependencies: "@biomejs/biome": "npm:^1.8.3" "@napi-rs/canvas": "npm:^0.1.53" - "@oldschoolgg/toolkit": "git+https://github.com/oldschoolgg/toolkit.git#87450a60c73136601714c77092793d2f432b70b5" + "@oldschoolgg/toolkit": "git+https://github.com/oldschoolgg/toolkit.git#3eb432f0349a32fde98e981c42653d927d91e7e2" "@prisma/client": "npm:^5.16.1" "@sapphire/snowflake": "npm:^3.5.3" "@sapphire/time-utilities": "npm:^1.6.0" From 747c6c191012cf33d62331d90f5905457ad8a464 Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Sat, 13 Jul 2024 19:31:36 +1000 Subject: [PATCH 044/145] Fix shutdown command --- src/mahoji/commands/admin.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/mahoji/commands/admin.ts b/src/mahoji/commands/admin.ts index 024f7e5b03..26a53e3869 100644 --- a/src/mahoji/commands/admin.ts +++ b/src/mahoji/commands/admin.ts @@ -889,6 +889,7 @@ ${META_CONSTANTS.RENDERED_STR}` process.exit(); } if (options.shut_down) { + debugLog('SHUTTING DOWN'); globalClient.isShuttingDown = true; const timer = production ? Time.Second * 30 : Time.Second * 5; await interactionReply(interaction, { @@ -901,7 +902,7 @@ ${META_CONSTANTS.RENDERED_STR}` ${META_CONSTANTS.RENDERED_STR}` }).catch(noOp); - execSync(`pm2 stop ${BOT_TYPE === 'OSB' ? 'osb' : 'bso'}`); + execSync(`sudo systemctl stop ${BOT_TYPE === 'OSB' ? 'osb' : 'bso'}`); } if (options.sync_blacklist) { From e405454773002fb510319e886593071010181f50 Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Sat, 13 Jul 2024 20:02:39 +1000 Subject: [PATCH 045/145] Remove sql logger, make prod log in shared log folder --- src/lib/safeglobals.ts | 2 +- src/lib/util/logger.ts | 16 +++++----------- 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/src/lib/safeglobals.ts b/src/lib/safeglobals.ts index c1ea0174ad..3b0fbe4f80 100644 --- a/src/lib/safeglobals.ts +++ b/src/lib/safeglobals.ts @@ -1,2 +1,2 @@ -import './util/logger'; import './data/itemAliases'; +import './util/logger'; diff --git a/src/lib/util/logger.ts b/src/lib/util/logger.ts index 40715f7fa6..edc78d05f4 100644 --- a/src/lib/util/logger.ts +++ b/src/lib/util/logger.ts @@ -1,29 +1,23 @@ import SonicBoom from 'sonic-boom'; +import { BOT_TYPE_LOWERCASE, globalConfig } from '../constants'; + const today = new Date(); const year = today.getFullYear(); const month = (today.getMonth() + 1).toString().padStart(2, '0'); const day = today.getDate().toString().padStart(2, '0'); const formattedDate = `${year}-${month}-${day}`; -export const LOG_FILE_NAME = `./logs/${formattedDate}-${today.getHours()}-${today.getMinutes()}-debug-logs.log`; +const LOG_FILE_NAME = globalConfig.isProduction + ? `../logs/${BOT_TYPE_LOWERCASE}.debug.log` + : `./logs/${formattedDate}-${today.getHours()}-${today.getMinutes()}-debug-logs.log`; export const sonicBoom = new SonicBoom({ fd: LOG_FILE_NAME, mkdir: true, - minLength: 4096, sync: false }); -const sqlLogger = new SonicBoom({ - fd: './logs/queries.sql', - mkdir: true, - minLength: 0, - sync: true -}); - -export const sqlLog = (str: string) => sqlLogger.write(`${new Date().toLocaleTimeString()} ${str}\n`); - interface LogContext { type?: string; [key: string]: unknown; From 6072cecc4fb1724ae4160b191a69b072dba780c6 Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Sat, 13 Jul 2024 20:03:34 +1000 Subject: [PATCH 046/145] Cleanup some logs/error handling --- src/lib/Task.ts | 10 +++++----- src/lib/grandExchange.ts | 10 +++++----- src/lib/util/globalInteractions.ts | 5 ----- 3 files changed, 10 insertions(+), 15 deletions(-) diff --git a/src/lib/Task.ts b/src/lib/Task.ts index fce856a374..5ea4db1a2f 100644 --- a/src/lib/Task.ts +++ b/src/lib/Task.ts @@ -236,15 +236,16 @@ const ActivityTaskOptionsSchema = z.object({ async function completeActivity(_activity: Activity) { const activity = convertStoredActivityToFlatActivity(_activity); - debugLog(`Attemping to complete activity ID[${activity.id}]`); if (_activity.completed) { - throw new Error('Tried to complete an already completed task.'); + logError(new Error('Tried to complete an already completed task.')); + return; } const task = tasks.find(i => i.type === activity.type)!; if (!task) { - throw new Error('Missing task'); + logError(new Error('Missing task')); + return; } modifyBusyCounter(activity.userID, 1); @@ -253,7 +254,7 @@ async function completeActivity(_activity: Activity) { const schema = ActivityTaskOptionsSchema.and(task.dataSchema); const { success } = schema.safeParse(activity); if (!success) { - console.error(`Invalid activity data for ${activity.type} task: ${JSON.stringify(activity)}`); + logError(new Error(`Invalid activity data for ${activity.type} task: ${JSON.stringify(activity)}`)); } } await task.run(activity); @@ -262,7 +263,6 @@ async function completeActivity(_activity: Activity) { } finally { modifyBusyCounter(activity.userID, -1); minionActivityCacheDelete(activity.userID); - debugLog(`Finished completing activity ID[${activity.id}]`); } } diff --git a/src/lib/grandExchange.ts b/src/lib/grandExchange.ts index c2581bdbb6..26befe12d4 100644 --- a/src/lib/grandExchange.ts +++ b/src/lib/grandExchange.ts @@ -537,9 +537,9 @@ ${type} ${toKMB(quantity)} ${item.name} for ${toKMB(price)} each, for a total of buyerListing.asking_price_per_item }] SellerPrice[${ sellerListing.asking_price_per_item - }] TotalPriceBeforeTax[${totalPriceBeforeTax}] QuantityToBuy[${quantityToBuy}] TotalTaxPaid[${totalTaxPaid}] BuyerRefund[${buyerRefund}] BuyerLoot[${buyerLoot}] SellerLoot[${sellerLoot}] CurrentGEBank[${geBank}] BankToRemoveFromGeBank[${bankToRemoveFromGeBank}] ExpectedAfterBank[${geBank - .clone() - .remove(bankToRemoveFromGeBank)}]`; + }] TotalPriceBeforeTax[${totalPriceBeforeTax}] QuantityToBuy[${quantityToBuy}] TotalTaxPaid[${totalTaxPaid}] BuyerRefund[${buyerRefund}] BuyerLoot[${buyerLoot}] SellerLoot[${sellerLoot}] CurrentGEBank[${geBank}] BankToRemoveFromGeBank[${bankToRemoveFromGeBank.bank}] ExpectedAfterBank[${ + geBank.clone().remove(bankToRemoveFromGeBank).bank + }]`; assert( bankToRemoveFromGeBank.amount('Coins') === Number(buyerListing.asking_price_per_item) * quantityToBuy, @@ -557,7 +557,7 @@ ${type} ${toKMB(quantity)} ${item.name} for ${toKMB(price)} each, for a total of } debugLog( - `Completing a transaction, removing ${bankToRemoveFromGeBank} from the GE bank, ${totalTaxPaid} in taxed gp. The current GE bank is ${geBank.toString()}. ${debug}`, + `Completing a transaction, removing ${bankToRemoveFromGeBank.bank} from the GE bank, ${totalTaxPaid} in taxed gp. The current GE bank is ${geBank.bank}. ${debug}`, { totalPriceAfterTax, totalTaxPaid, @@ -617,7 +617,7 @@ ${type} ${toKMB(quantity)} ${item.name} for ${toKMB(price)} each, for a total of ...makeTransactFromTableBankQueries({ bankToRemove: bankToRemoveFromGeBank }) ]); - debugLog(`Transaction completed, the new G.E bank is ${await this.fetchOwnedBank()}.`); + debugLog(`Transaction completed, the new G.E bank is ${(await this.fetchOwnedBank()).bank}.`); const buyerUser = await mUserFetch(buyerListing.user_id); const sellerUser = await mUserFetch(sellerListing.user_id); diff --git a/src/lib/util/globalInteractions.ts b/src/lib/util/globalInteractions.ts index 9ff0c472b6..1bccb15c59 100644 --- a/src/lib/util/globalInteractions.ts +++ b/src/lib/util/globalInteractions.ts @@ -329,11 +329,6 @@ export async function interactionHook(interaction: Interaction) { }); } - debugLog(`Interaction hook for button [${interaction.customId}]`, { - user_id: interaction.user.id, - channel_id: interaction.channelId, - guild_id: interaction.guildId - }); const id = interaction.customId; const userID = interaction.user.id; From 100e60fbd886433780decc254e050249bdb451e3 Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Sat, 13 Jul 2024 20:15:17 +1000 Subject: [PATCH 047/145] Dont mention in server notifications --- src/index.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index 0e8ab5b630..0dad9104d1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -170,7 +170,9 @@ client.on('interactionCreate', async interaction => { client.on(Events.ServerNotification, (message: string) => { const channel = globalClient.channels.cache.get(Channel.Notifications); - if (channel) (channel as TextChannel).send(message); + if (channel) { + (channel as TextChannel).send({ content: message, allowedMentions: { parse: [], users: [], roles: [] } }); + } }); client.on(Events.EconomyLog, async (message: string) => { From 57a20ca3b4a478b4f3aba331d7fcaa1580075b90 Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Sat, 13 Jul 2024 20:19:17 +1000 Subject: [PATCH 048/145] User mention/username changes --- src/lib/MUser.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/lib/MUser.ts b/src/lib/MUser.ts index f250588d47..df94f648c9 100644 --- a/src/lib/MUser.ts +++ b/src/lib/MUser.ts @@ -1,4 +1,4 @@ -import { mentionCommand } from '@oldschoolgg/toolkit'; +import { cleanUsername, mentionCommand } from '@oldschoolgg/toolkit'; import { UserError } from '@oldschoolgg/toolkit'; import type { GearSetupType, Prisma, User, UserStats, xp_gains_skill_enum } from '@prisma/client'; import { userMention } from 'discord.js'; @@ -221,11 +221,11 @@ export class MUserClass { } get rawUsername() { - return globalClient.users.cache.get(this.id)?.username ?? this.user.username ?? 'Unknown'; + return cleanUsername(this.user.username ?? globalClient.users.cache.get(this.id)?.username ?? 'Unknown'); } get usernameOrMention() { - return this.user.username ?? this.mention; + return this.rawUsername; } get badgedUsername() { From cc17ab72730e6c0b27cbf7a314d01f2967018351 Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Sat, 13 Jul 2024 20:48:54 +1000 Subject: [PATCH 049/145] Fix g.e logs --- src/lib/grandExchange.ts | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/src/lib/grandExchange.ts b/src/lib/grandExchange.ts index 26befe12d4..dcb431a612 100644 --- a/src/lib/grandExchange.ts +++ b/src/lib/grandExchange.ts @@ -537,7 +537,7 @@ ${type} ${toKMB(quantity)} ${item.name} for ${toKMB(price)} each, for a total of buyerListing.asking_price_per_item }] SellerPrice[${ sellerListing.asking_price_per_item - }] TotalPriceBeforeTax[${totalPriceBeforeTax}] QuantityToBuy[${quantityToBuy}] TotalTaxPaid[${totalTaxPaid}] BuyerRefund[${buyerRefund}] BuyerLoot[${buyerLoot}] SellerLoot[${sellerLoot}] CurrentGEBank[${geBank}] BankToRemoveFromGeBank[${bankToRemoveFromGeBank.bank}] ExpectedAfterBank[${ + }] TotalPriceBeforeTax[${totalPriceBeforeTax}] QuantityToBuy[${quantityToBuy}] TotalTaxPaid[${totalTaxPaid}] BuyerRefund[${buyerRefund}] BuyerLoot[${buyerLoot}] SellerLoot[${sellerLoot}] CurrentGEBank[${geBank}] BankToRemoveFromGeBank[${JSON.stringify(bankToRemoveFromGeBank.bank)}] ExpectedAfterBank[${ geBank.clone().remove(bankToRemoveFromGeBank).bank }]`; @@ -557,13 +557,13 @@ ${type} ${toKMB(quantity)} ${item.name} for ${toKMB(price)} each, for a total of } debugLog( - `Completing a transaction, removing ${bankToRemoveFromGeBank.bank} from the GE bank, ${totalTaxPaid} in taxed gp. The current GE bank is ${geBank.bank}. ${debug}`, + `Completing a transaction, removing ${JSON.stringify(bankToRemoveFromGeBank.bank)} from the GE bank, ${totalTaxPaid} in taxed gp. The current GE bank is ${JSON.stringify(geBank.bank)}. ${debug}`, { totalPriceAfterTax, totalTaxPaid, totalPriceBeforeTax, bankToRemoveFromGeBank: bankToRemoveFromGeBank.toString(), - currentGEBank: geBank.toString() + currentGEBank: JSON.stringify(geBank.bank) } ); @@ -617,7 +617,7 @@ ${type} ${toKMB(quantity)} ${item.name} for ${toKMB(price)} each, for a total of ...makeTransactFromTableBankQueries({ bankToRemove: bankToRemoveFromGeBank }) ]); - debugLog(`Transaction completed, the new G.E bank is ${(await this.fetchOwnedBank()).bank}.`); + debugLog(`Transaction completed, the new G.E bank is ${JSON.stringify((await this.fetchOwnedBank()).bank)}.`); const buyerUser = await mUserFetch(buyerListing.user_id); const sellerUser = await mUserFetch(sellerListing.user_id); @@ -788,7 +788,7 @@ ${type} ${toKMB(quantity)} ${item.name} for ${toKMB(price)} each, for a total of shouldHave.add(listing.item_id, listing.quantity_remaining); } - debugLog(`Expected G.E Bank: ${shouldHave}`); + debugLog(`Expected G.E Bank: ${JSON.stringify(shouldHave.bank)}`); if (!currentBank.equals(shouldHave)) { if (!currentBank.has(shouldHave)) { throw new Error( @@ -803,11 +803,7 @@ G.E Bank Has: ${currentBank} G.E Bank Should Have: ${shouldHave} Difference: ${shouldHave.difference(currentBank)}`); } else { - debugLog( - `GE has ${currentBank}, which is enough to cover the ${ - [...buyListings, ...sellListings].length - }x active listings! Difference: ${shouldHave.difference(currentBank)}` - ); + debugLog('GE has enough to cover the listings.'); return true; } } From 9e2b9ab2bc344b75406b3d369c754440c40ffb6d Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Sun, 14 Jul 2024 11:13:03 +1000 Subject: [PATCH 050/145] Defer interactions in runCommand --- src/lib/settings/settings.ts | 3 ++- src/lib/util/globalInteractions.ts | 3 +-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/lib/settings/settings.ts b/src/lib/settings/settings.ts index f8b57b40ac..d39047fa7a 100644 --- a/src/lib/settings/settings.ts +++ b/src/lib/settings/settings.ts @@ -14,7 +14,7 @@ import { preCommand } from '../../mahoji/lib/preCommand'; import { convertMahojiCommandToAbstractCommand } from '../../mahoji/lib/util'; import { minionActivityCache } from '../constants'; import { channelIsSendable, isGroupActivity, roughMergeMahojiResponse } from '../util'; -import { handleInteractionError, interactionReply } from '../util/interactionReply'; +import { deferInteraction, handleInteractionError, interactionReply } from '../util/interactionReply'; import { logError } from '../util/logError'; import { convertStoredActivityToFlatActivity } from './prisma'; @@ -110,6 +110,7 @@ export async function runCommand({ continueDeltaMillis, ephemeral }: RunCommandArgs): Promise { + await deferInteraction(interaction); const channel = globalClient.channels.cache.get(channelID.toString()); if (!channel || !channelIsSendable(channel)) return null; const mahojiCommand = Array.from(globalClient.mahojiClient.commands.values()).find(c => c.name === commandName); diff --git a/src/lib/util/globalInteractions.ts b/src/lib/util/globalInteractions.ts index 1bccb15c59..e318b2ee88 100644 --- a/src/lib/util/globalInteractions.ts +++ b/src/lib/util/globalInteractions.ts @@ -17,7 +17,7 @@ import { toaHelpCommand } from '../simulation/toa'; import type { ItemBank } from '../types'; import { formatDuration, stringMatches } from '../util'; import { updateGiveawayMessage } from './giveaway'; -import { deferInteraction, interactionReply } from './interactionReply'; +import { interactionReply } from './interactionReply'; import { minionIsBusy } from './minionIsBusy'; import { fetchRepeatTrips, repeatTrip } from './repeatStoredTrip'; @@ -146,7 +146,6 @@ async function giveawayButtonHandler(user: MUser, customID: string, interaction: }); } - await deferInteraction(interaction); return runCommand({ commandName: 'giveaway', args: { From a3823c0dd4165f9e01a0d3e2fd57f543e7884309 Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Sun, 14 Jul 2024 12:12:02 +1000 Subject: [PATCH 051/145] fix tests --- tests/integration/tradeTransaction.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/integration/tradeTransaction.test.ts b/tests/integration/tradeTransaction.test.ts index 0c47819447..77a3a5f743 100644 --- a/tests/integration/tradeTransaction.test.ts +++ b/tests/integration/tradeTransaction.test.ts @@ -120,7 +120,7 @@ describe('Transactionalized Trade Test', async () => { .freeze(); const magna = await createUserWithBank(magnaStartingBank); - const uCyr = await mUserFetch(cyr); + const uCyr = await mUserFetch(cyr, { username: 'Cyr' }); const uMagna = await mUserFetch(magna); expect(uCyr.GP).toBe(1_000_000); @@ -131,7 +131,7 @@ describe('Transactionalized Trade Test', async () => { const result = await tradePlayerItems(uCyr, uMagna, tradeFromCyr, tradeFromMagna); - const expectedResult = { success: false, message: `<@${cyr}> doesn't own all items.` }; + const expectedResult = { success: false, message: `Cyr doesn't own all items.` }; expect(result).toMatchObject(expectedResult); expect(uCyr.bankWithGP.toString()).toEqual(cyrStartingBank.toString()); @@ -155,14 +155,14 @@ describe('Transactionalized Trade Test', async () => { const magna = await createUserWithBank(magnaStartingBank); const uCyr = await mUserFetch(cyr); - const uMagna = await mUserFetch(magna); + const uMagna = await mUserFetch(magna, { username: 'magna' }); const tradeFromCyr = new Bank().add('Coins', 1_000_000).add('Twisted bow', 1).freeze(); const tradeFromMagna = new Bank().add('Coins', 2_000_000).add('Feather', 5000).add('Cannonball', 2000).freeze(); const result = await tradePlayerItems(uCyr, uMagna, tradeFromCyr, tradeFromMagna); - const expectedResult = { success: false, message: `<@${magna}> doesn't own all items.` }; + const expectedResult = { success: false, message: `magna doesn't own all items.` }; expect(result).toMatchObject(expectedResult); expect(uCyr.bankWithGP.equals(cyrStartingBank)).toBe(true); From eb19c2c81ec56a8ab1adcb3b8f9722fb11915e11 Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Sun, 14 Jul 2024 12:16:02 +1000 Subject: [PATCH 052/145] Fix other test --- src/lib/MUser.ts | 2 +- src/lib/util/makeBadgeString.ts | 2 +- tests/integration/commands/dice.test.ts | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/lib/MUser.ts b/src/lib/MUser.ts index df94f648c9..e6ecbdb8c4 100644 --- a/src/lib/MUser.ts +++ b/src/lib/MUser.ts @@ -229,7 +229,7 @@ export class MUserClass { } get badgedUsername() { - return `${this.badgesString} ${this.usernameOrMention}`; + return `${this.badgesString} ${this.usernameOrMention}`.trim(); } toString() { diff --git a/src/lib/util/makeBadgeString.ts b/src/lib/util/makeBadgeString.ts index d8f5742b15..5a6598d358 100644 --- a/src/lib/util/makeBadgeString.ts +++ b/src/lib/util/makeBadgeString.ts @@ -5,5 +5,5 @@ export function makeBadgeString(badgeIDs: number[] | null | undefined, isIronman if (isIronman) { rawBadges.push(Emoji.Ironman); } - return rawBadges.join(' '); + return rawBadges.join(' ').trim(); } diff --git a/tests/integration/commands/dice.test.ts b/tests/integration/commands/dice.test.ts index ab20bbd5ee..a903a0186d 100644 --- a/tests/integration/commands/dice.test.ts +++ b/tests/integration/commands/dice.test.ts @@ -27,7 +27,7 @@ describe('Dice Command', async () => { await user.gpMatch(100_000_000); const unmock = mockMathRandom(0.1); const result = await user.runCommand(gambleCommand, { dice: { amount: '100m' } }); - expect(result).toMatchObject(` <@${user.id}> rolled **11** on the percentile dice, and you lost -100m GP.`); + expect(result).toMatchObject('Unknown rolled **11** on the percentile dice, and you lost -100m GP.'); await user.gpMatch(0); await user.statsMatch('dice_losses', 1); await user.statsMatch('gp_dice', BigInt(-100_000_000)); @@ -39,7 +39,7 @@ describe('Dice Command', async () => { const unmock = mockMathRandom(0.9); await user.gpMatch(100_000_000); const result = await user.runCommand(gambleCommand, { dice: { amount: '100m' } }); - expect(result).toMatchObject(` <@${user.id}> rolled **91** on the percentile dice, and you won 100m GP.`); + expect(result).toMatchObject('Unknown rolled **91** on the percentile dice, and you won 100m GP.'); await user.gpMatch(200_000_000); await user.statsMatch('dice_wins', 1); await user.statsMatch('gp_dice', BigInt(100_000_000)); From eb8813585ea9fcd242596737d00f3dfab1e32abf Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Sun, 14 Jul 2024 12:23:49 +1000 Subject: [PATCH 053/145] Exit with code 0 on shutdown --- src/mahoji/commands/admin.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/mahoji/commands/admin.ts b/src/mahoji/commands/admin.ts index 26a53e3869..e60ff52cb1 100644 --- a/src/mahoji/commands/admin.ts +++ b/src/mahoji/commands/admin.ts @@ -1,5 +1,3 @@ -import { execSync } from 'node:child_process'; - import { type CommandRunOptions, bulkUpdateCommands } from '@oldschoolgg/toolkit'; import type { MahojiUserOption } from '@oldschoolgg/toolkit'; import type { ClientStorage } from '@prisma/client'; @@ -15,7 +13,6 @@ import { ADMIN_IDS, OWNER_IDS, SupportServer, production } from '../../config'; import { mahojiUserSettingsUpdate } from '../../lib/MUser'; import { BLACKLISTED_GUILDS, BLACKLISTED_USERS, syncBlacklists } from '../../lib/blacklists'; import { - BOT_TYPE, BadgesEnum, BitField, BitFieldData, @@ -902,7 +899,7 @@ ${META_CONSTANTS.RENDERED_STR}` ${META_CONSTANTS.RENDERED_STR}` }).catch(noOp); - execSync(`sudo systemctl stop ${BOT_TYPE === 'OSB' ? 'osb' : 'bso'}`); + process.exit(0); } if (options.sync_blacklist) { From 8b7d94e4ccc38daf6a5d28c5af9e3e0f151fa1cc Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Sun, 14 Jul 2024 15:05:57 +1000 Subject: [PATCH 054/145] Improve minion status command and cl array updating --- src/lib/handleNewCLItems.ts | 28 +----------- src/lib/roboChimp.ts | 26 ++++++++++- src/lib/util/handleTripFinish.ts | 2 +- src/mahoji/commands/activities.ts | 2 +- .../abstracted_commands/birdhousesCommand.ts | 19 ++++---- .../abstracted_commands/minionBuyCommand.ts | 11 ++++- .../minionStatusCommand.ts | 8 ++-- tests/integration/clArrayUpdate.test.ts | 44 +++++++++++++++++++ 8 files changed, 91 insertions(+), 49 deletions(-) create mode 100644 tests/integration/clArrayUpdate.test.ts diff --git a/src/lib/handleNewCLItems.ts b/src/lib/handleNewCLItems.ts index 8001ecc628..f64c06afe7 100644 --- a/src/lib/handleNewCLItems.ts +++ b/src/lib/handleNewCLItems.ts @@ -30,32 +30,6 @@ async function createHistoricalData(user: MUser): Promise Number(i)); - const updateObj = { - cl_array: newCLArray, - cl_array_length: newCLArray.length - } as const; - - await prisma.userStats.upsert({ - where: { - user_id: id - }, - create: { - user_id: id, - ...updateObj - }, - update: { - ...updateObj - } - }); - - return { - newCLArray - }; -} - export async function handleNewCLItems({ itemsAdded, user, @@ -81,7 +55,7 @@ export async function handleNewCLItems({ const previousCLDetails = calcCLDetails(previousCL); const previousCLRank = previousCLDetails.percent >= 80 ? await calculateOwnCLRanking(user.id) : null; - await Promise.all([roboChimpSyncData(user), clArrayUpdate(user, newCL)]); + await roboChimpSyncData(user, newCL); const newCLRank = previousCLDetails.percent >= 80 ? await calculateOwnCLRanking(user.id) : null; const newCLDetails = calcCLDetails(newCL); diff --git a/src/lib/roboChimp.ts b/src/lib/roboChimp.ts index 1bd33e5d9b..8976663d91 100644 --- a/src/lib/roboChimp.ts +++ b/src/lib/roboChimp.ts @@ -3,6 +3,7 @@ import type { TriviaQuestion, User } from '@prisma/robochimp'; import { calcWhatPercent, round, sumArr } from 'e'; import deepEqual from 'fast-deep-equal'; +import type { Bank } from 'oldschooljs'; import { BOT_TYPE, globalConfig, masteryKey } from './constants'; import { getTotalCl } from './data/Collections'; import { calculateMastery } from './mastery'; @@ -36,8 +37,29 @@ const clKey: keyof User = 'osb_cl_percent'; const levelKey: keyof User = 'osb_total_level'; const totalXPKey: keyof User = BOT_TYPE === 'OSB' ? 'osb_total_xp' : 'bso_total_xp'; -export async function roboChimpSyncData(user: MUser) { - const stats = await MUserStats.fromID(user.id); +export async function roboChimpSyncData(user: MUser, newCL?: Bank) { + const id = BigInt(user.id); + const newCLArray: number[] = Object.keys((newCL ?? user.cl).bank).map(i => Number(i)); + const clArrayUpdateObject = { + cl_array: newCLArray, + cl_array_length: newCLArray.length + } as const; + + const stats = new MUserStats( + await prisma.userStats.upsert({ + where: { + user_id: id + }, + create: { + user_id: id, + ...clArrayUpdateObject + }, + update: { + ...clArrayUpdateObject + } + }) + ); + const [totalClItems, clItems] = getTotalCl(user, 'collection', stats); const clCompletionPercentage = round(calcWhatPercent(clItems, totalClItems), 2); const totalXP = sumArr(Object.values(user.skillsAsXP)); diff --git a/src/lib/util/handleTripFinish.ts b/src/lib/util/handleTripFinish.ts index 4e639856aa..b5e1614913 100644 --- a/src/lib/util/handleTripFinish.ts +++ b/src/lib/util/handleTripFinish.ts @@ -142,7 +142,7 @@ export async function handleTripFinish( if (casketReceived) components.push(makeOpenCasketButton(casketReceived)); if (perkTier > PerkTier.One) { components.push(...buildClueButtons(loot, perkTier, user)); - const birdHousedetails = await calculateBirdhouseDetails(user.id); + const birdHousedetails = await calculateBirdhouseDetails(user); if (birdHousedetails.isReady && !user.bitfield.includes(BitField.DisableBirdhouseRunButton)) components.push(makeBirdHouseTripButton()); diff --git a/src/mahoji/commands/activities.ts b/src/mahoji/commands/activities.ts index 66cb1b39a6..1d0fa18e7b 100644 --- a/src/mahoji/commands/activities.ts +++ b/src/mahoji/commands/activities.ts @@ -545,7 +545,7 @@ export const activitiesCommand: OSBMahojiCommand = { return decantCommand(user, options.decant.potion_name, options.decant.dose); } if (options.inferno?.action === 'stats') return infernoStatsCommand(user); - if (options.birdhouses?.action === 'check') return birdhouseCheckCommand(user.user); + if (options.birdhouses?.action === 'check') return birdhouseCheckCommand(user); // Minion must be free const isBusy = user.minionIsBusy; diff --git a/src/mahoji/lib/abstracted_commands/birdhousesCommand.ts b/src/mahoji/lib/abstracted_commands/birdhousesCommand.ts index fb1dd671ed..c8d5e85a6c 100644 --- a/src/mahoji/lib/abstracted_commands/birdhousesCommand.ts +++ b/src/mahoji/lib/abstracted_commands/birdhousesCommand.ts @@ -1,4 +1,3 @@ -import type { User } from '@prisma/client'; import { time } from 'discord.js'; import { Bank } from 'oldschooljs'; @@ -20,11 +19,9 @@ interface BirdhouseDetails { readyAt: Date | null; } -export async function calculateBirdhouseDetails(userID: string | bigint): Promise { - const bh = await mahojiUsersSettingsFetch(userID, { - minion_birdhouseTraps: true - }); - if (!bh.minion_birdhouseTraps) { +export function calculateBirdhouseDetails(user: MUser): BirdhouseDetails { + const birdHouseTraps = user.user.minion_birdhouseTraps; + if (!birdHouseTraps) { return { raw: defaultBirdhouseTrap, isReady: false, @@ -34,7 +31,7 @@ export async function calculateBirdhouseDetails(userID: string | bigint): Promis }; } - const details = bh.minion_birdhouseTraps as unknown as BirdhouseData; + const details = birdHouseTraps as unknown as BirdhouseData; const birdHouse = details.lastPlaced ? birdhouses.find(_birdhouse => _birdhouse.name === details.lastPlaced) : null; if (!birdHouse) throw new Error(`Missing ${details.lastPlaced} birdhouse`); @@ -52,8 +49,8 @@ export async function calculateBirdhouseDetails(userID: string | bigint): Promis }; } -export async function birdhouseCheckCommand(user: User) { - const details = await calculateBirdhouseDetails(user.id); +export async function birdhouseCheckCommand(user: MUser) { + const details = calculateBirdhouseDetails(user); if (!details.birdHouse) { return 'You have no birdhouses planted.'; } @@ -67,8 +64,8 @@ export async function birdhouseHarvestCommand(user: MUser, channelID: string, in const infoStr: string[] = []; const boostStr: string[] = []; - const existingBirdhouse = await calculateBirdhouseDetails(user.id); - if (!existingBirdhouse.isReady && existingBirdhouse.raw.lastPlaced) return birdhouseCheckCommand(user.user); + const existingBirdhouse = await calculateBirdhouseDetails(user); + if (!existingBirdhouse.isReady && existingBirdhouse.raw.lastPlaced) return birdhouseCheckCommand(user); let birdhouseToPlant = inputBirdhouseName ? birdhouses.find(_birdhouse => diff --git a/src/mahoji/lib/abstracted_commands/minionBuyCommand.ts b/src/mahoji/lib/abstracted_commands/minionBuyCommand.ts index 666fdb1f57..d1a0c78daf 100644 --- a/src/mahoji/lib/abstracted_commands/minionBuyCommand.ts +++ b/src/mahoji/lib/abstracted_commands/minionBuyCommand.ts @@ -2,7 +2,6 @@ import type { CommandResponse } from '@oldschoolgg/toolkit'; import { ComponentType } from 'discord.js'; import { mahojiInformationalButtons } from '../../../lib/constants'; -import { clArrayUpdate } from '../../../lib/handleNewCLItems'; export async function minionBuyCommand(user: MUser, ironman: boolean): CommandResponse { if (user.user.minion_hasBought) return 'You already have a minion!'; @@ -14,7 +13,15 @@ export async function minionBuyCommand(user: MUser, ironman: boolean): CommandRe }); // Ensure user has a userStats row - await clArrayUpdate(user, user.cl); + await prisma.userStats.upsert({ + where: { + user_id: BigInt(user.id) + }, + create: { + user_id: BigInt(user.id) + }, + update: {} + }); return { content: `You have successfully got yourself a minion, and you're ready to use the bot now! Please check out the links below for information you should read. diff --git a/src/mahoji/lib/abstracted_commands/minionStatusCommand.ts b/src/mahoji/lib/abstracted_commands/minionStatusCommand.ts index 5038c0de6a..689adb9652 100644 --- a/src/mahoji/lib/abstracted_commands/minionStatusCommand.ts +++ b/src/mahoji/lib/abstracted_commands/minionStatusCommand.ts @@ -5,7 +5,6 @@ import { roll, stripNonAlphanumeric } from 'e'; import { ClueTiers } from '../../../lib/clues/clueTiers'; import { BitField, Emoji, minionBuyButton } from '../../../lib/constants'; -import { clArrayUpdate } from '../../../lib/handleNewCLItems'; import { roboChimpSyncData, roboChimpUserFetch } from '../../../lib/roboChimp'; import { makeComponents } from '../../../lib/util'; @@ -57,16 +56,15 @@ async function fetchPinnedTrips(userID: string) { export async function minionStatusCommand(user: MUser): Promise { const { minionIsBusy } = user; - const [roboChimpUser, birdhouseDetails, gearPresetButtons, pinnedTripButtons, dailyIsReady] = await Promise.all([ + const birdhouseDetails = minionIsBusy ? { isReady: false } : calculateBirdhouseDetails(user); + const [roboChimpUser, gearPresetButtons, pinnedTripButtons, dailyIsReady] = await Promise.all([ roboChimpUserFetch(user.id), - minionIsBusy ? { isReady: false } : calculateBirdhouseDetails(user.id), minionIsBusy ? [] : fetchFavoriteGearPresets(user.id), minionIsBusy ? [] : fetchPinnedTrips(user.id), isUsersDailyReady(user) ]); - roboChimpSyncData(user); - await clArrayUpdate(user, user.cl); + await roboChimpSyncData(user); if (user.user.cached_networth_value === null || roll(100)) { await user.update({ cached_networth_value: (await user.calculateNetWorth()).value diff --git a/tests/integration/clArrayUpdate.test.ts b/tests/integration/clArrayUpdate.test.ts new file mode 100644 index 0000000000..9c190aca5a --- /dev/null +++ b/tests/integration/clArrayUpdate.test.ts @@ -0,0 +1,44 @@ +import { Time } from 'e'; +import { expect, test } from 'vitest'; + +import { Bank } from 'oldschooljs'; +import { itemID } from 'oldschooljs/dist/util'; +import { roboChimpSyncData } from '../../src/lib/roboChimp'; +import { createTestUser } from './util'; + +test( + 'All Commands Base Test', + async () => { + const user = await createTestUser(); + await user.addItemsToBank({ items: new Bank().add('Coal', 100) }); + await roboChimpSyncData(user); + expect(user.fetchStats({ cl_array: true, cl_array_length: true })).resolves.toMatchObject({ + cl_array: [], + cl_array_length: 0 + }); + + await user.addItemsToBank({ items: new Bank().add('Egg', 100), collectionLog: true }); + await roboChimpSyncData(user); + expect(user.fetchStats({ cl_array: true, cl_array_length: true })).resolves.toMatchObject({ + cl_array: [itemID('Egg')], + cl_array_length: 1 + }); + + await user.addItemsToBank({ items: new Bank().add('Egg', 100), collectionLog: true }); + await roboChimpSyncData(user); + expect(user.fetchStats({ cl_array: true, cl_array_length: true })).resolves.toMatchObject({ + cl_array: [itemID('Egg')], + cl_array_length: 1 + }); + + await user.addItemsToBank({ items: new Bank().add('Trout', 100), collectionLog: true }); + await roboChimpSyncData(user); + expect(user.fetchStats({ cl_array: true, cl_array_length: true })).resolves.toMatchObject({ + cl_array: [itemID('Trout'), itemID('Egg')], + cl_array_length: 2 + }); + }, + { + timeout: Time.Minute * 10 + } +); From fe9de84c84faddb22cbdc275a1128f36cce96a9a Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Sun, 14 Jul 2024 15:48:18 +1000 Subject: [PATCH 055/145] Userstats/CL cleanup --- src/lib/collectionLogTask.ts | 4 ++-- src/lib/data/Collections.ts | 30 +++++++----------------------- src/lib/data/CollectionsExport.ts | 6 +++--- src/lib/handleNewCLItems.ts | 3 +-- src/lib/structures/MUserStats.ts | 16 ++++++++++++---- src/lib/util/fetchStatsForCL.ts | 19 ------------------- src/mahoji/commands/cl.ts | 4 ++-- 7 files changed, 27 insertions(+), 55 deletions(-) delete mode 100644 src/lib/util/fetchStatsForCL.ts diff --git a/src/lib/collectionLogTask.ts b/src/lib/collectionLogTask.ts index ae544cc9d8..21520befab 100644 --- a/src/lib/collectionLogTask.ts +++ b/src/lib/collectionLogTask.ts @@ -6,12 +6,12 @@ import { calcWhatPercent, objectEntries } from 'e'; import type { Bank } from 'oldschooljs'; import { Util } from 'oldschooljs'; -import type { UserStatsDataNeededForCL } from '../lib/data/Collections'; import { allCollectionLogs, getCollection, getTotalCl } from '../lib/data/Collections'; import type { IToReturnCollection } from '../lib/data/CollectionsExport'; import { fillTextXTimesInCtx, getClippedRegion, measureTextWidth } from '../lib/util/canvasUtil'; import getOSItem from '../lib/util/getOSItem'; import type { IBgSprite } from './bankImage'; +import type { MUserStats } from './structures/MUserStats'; export const collectionLogTypes = [ { name: 'collection', description: 'Normal Collection Log' }, @@ -100,7 +100,7 @@ class CollectionLogTask { collection: string; type: CollectionLogType; flags: { [key: string]: string | number | undefined }; - stats: UserStatsDataNeededForCL | null; + stats: MUserStats | null; collectionLog?: IToReturnCollection; }): Promise { const { sprite } = bankImageGenerator.getBgAndSprite(options.user.user.bankBackground, options.user); diff --git a/src/lib/data/Collections.ts b/src/lib/data/Collections.ts index abe96d255e..84726fe90d 100644 --- a/src/lib/data/Collections.ts +++ b/src/lib/data/Collections.ts @@ -21,9 +21,8 @@ import type { MinigameName } from '../settings/minigames'; import { NexNonUniqueTable, NexUniqueTable } from '../simulation/misc'; import { allFarmingItems } from '../skilling/skills/farming'; import { SkillsEnum } from '../skilling/types'; -import type { MUserStats } from '../structures/MUserStats'; +import { MUserStats } from '../structures/MUserStats'; import type { ItemBank } from '../types'; -import { fetchStatsForCL } from '../util/fetchStatsForCL'; import { shuffleRandom } from '../util/smallUtils'; import type { FormatProgressFunction, ICollection, ILeftListStatus, IToReturnCollection } from './CollectionsExport'; import { @@ -558,9 +557,9 @@ export const allCollectionLogs: ICollection = { kcActivity: { Default: async (_, minigameScores) => minigameScores.find(i => i.minigame.column === 'tombs_of_amascut')!.score, - Entry: async (_, __, { stats }) => stats.getToaKCs().entryKC, - Normal: async (_, __, { stats }) => stats.getToaKCs().normalKC, - Expert: async (_, __, { stats }) => stats.getToaKCs().expertKC + Entry: async (_, __, stats) => stats.getToaKCs().entryKC, + Normal: async (_, __, stats) => stats.getToaKCs().normalKC, + Expert: async (_, __, stats) => stats.getToaKCs().expertKC }, items: toaCL, isActivity: true, @@ -1242,22 +1241,7 @@ function getLeftList(userBank: Bank, checkCategory: string, allItems = false, re return leftList; } -export interface UserStatsDataNeededForCL { - sacrificedBank: Bank; - titheFarmsCompleted: number; - lapsScores: ItemBank; - openableScores: Bank; - kcBank: ItemBank; - highGambles: number; - gotrRiftSearches: number; - stats: MUserStats; -} - -function getBank( - user: MUser, - type: 'sacrifice' | 'bank' | 'collection' | 'temp', - userStats: UserStatsDataNeededForCL | MUserStats | null -) { +function getBank(user: MUser, type: 'sacrifice' | 'bank' | 'collection' | 'temp', userStats: MUserStats | null) { switch (type) { case 'collection': return new Bank(user.cl); @@ -1275,7 +1259,7 @@ function getBank( export function getTotalCl( user: MUser, logType: 'sacrifice' | 'bank' | 'collection' | 'temp', - userStats: UserStatsDataNeededForCL | MUserStats | null + userStats: MUserStats | null ) { return getUserClData(getBank(user, logType, userStats).bank, allCLItemsFiltered); } @@ -1365,7 +1349,7 @@ export async function getCollection(options: { if (logType === undefined) logType = 'collection'; const minigameScores = await user.fetchMinigameScores(); - const userStats = await fetchStatsForCL(user); + const userStats = await MUserStats.fromID(user.id); const userCheckBank = getBank(user, logType, userStats); let clItems = getCollectionItems(search, allItems, logType === 'sacrifice'); diff --git a/src/lib/data/CollectionsExport.ts b/src/lib/data/CollectionsExport.ts index 1022377dff..60363b980f 100644 --- a/src/lib/data/CollectionsExport.ts +++ b/src/lib/data/CollectionsExport.ts @@ -7,8 +7,8 @@ import { resolveItems } from 'oldschooljs/dist/util/util'; import { growablePets } from '../growablePets'; import { implings } from '../implings'; import type { MinigameScore } from '../settings/minigames'; +import type { MUserStats } from '../structures/MUserStats'; import getOSItem from '../util/getOSItem'; -import type { UserStatsDataNeededForCL } from './Collections'; import { gracefulCapes, gracefulFeet, @@ -39,7 +39,7 @@ export interface IKCActivity { [key: string]: | string | string[] - | ((user: MUser, minigameScores: MinigameScore[], stats: UserStatsDataNeededForCL) => Promise); + | ((user: MUser, minigameScores: MinigameScore[], stats: MUserStats) => Promise); } export type FormatProgressFunction = ({ @@ -51,7 +51,7 @@ export type FormatProgressFunction = ({ user: MUser; getKC: (id: number) => Promise; minigames: Minigame; - stats: UserStatsDataNeededForCL; + stats: MUserStats; }) => string | string[] | Promise; interface ICollectionActivity { diff --git a/src/lib/handleNewCLItems.ts b/src/lib/handleNewCLItems.ts index f64c06afe7..c57afd9041 100644 --- a/src/lib/handleNewCLItems.ts +++ b/src/lib/handleNewCLItems.ts @@ -11,7 +11,6 @@ import { calculateOwnCLRanking, roboChimpSyncData } from './roboChimp'; import { MUserStats } from './structures/MUserStats'; import { fetchCLLeaderboard } from './util/clLeaderboard'; -import { fetchStatsForCL } from './util/fetchStatsForCL'; import { insertUserEvent } from './util/userEvents'; async function createHistoricalData(user: MUser): Promise { @@ -98,7 +97,7 @@ export async function handleNewCLItems({ getKC: (id: number) => user.getKC(id), user, minigames: await user.fetchMinigames(), - stats: await fetchStatsForCL(user) + stats: await MUserStats.fromID(user.id) })}!` : ''; diff --git a/src/lib/structures/MUserStats.ts b/src/lib/structures/MUserStats.ts index 8dcab5db95..12d774918a 100644 --- a/src/lib/structures/MUserStats.ts +++ b/src/lib/structures/MUserStats.ts @@ -9,10 +9,22 @@ import { getToaKCs } from '../util/smallUtils'; export class MUserStats { userStats: UserStats; sacrificedBank: Bank; + titheFarmsCompleted: number; + lapsScores: ItemBank; + openableScores: Bank; + kcBank: ItemBank; + highGambles: number; + gotrRiftSearches: number; constructor(userStats: UserStats) { this.userStats = userStats; this.sacrificedBank = new Bank().add(this.userStats.sacrificed_bank as ItemBank); + this.titheFarmsCompleted = this.userStats.tithe_farms_completed; + this.lapsScores = userStats.laps_scores as ItemBank; + this.openableScores = new Bank().add(userStats.openable_scores as ItemBank); + this.kcBank = userStats.monster_scores as ItemBank; + this.highGambles = userStats.high_gambles; + this.gotrRiftSearches = userStats.gotr_rift_searches; } static async fromID(id: string) { @@ -28,10 +40,6 @@ export class MUserStats { return new MUserStats(userStats); } - get lapsScores() { - return this.userStats.laps_scores as ItemBank; - } - getToaKCs() { return getToaKCs(this.userStats.toa_raid_levels_bank); } diff --git a/src/lib/util/fetchStatsForCL.ts b/src/lib/util/fetchStatsForCL.ts deleted file mode 100644 index 84573b2479..0000000000 --- a/src/lib/util/fetchStatsForCL.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Bank } from 'oldschooljs'; -import type { UserStatsDataNeededForCL } from '../data/Collections'; -import { MUserStats } from '../structures/MUserStats'; -import type { ItemBank } from '../types'; - -export async function fetchStatsForCL(user: MUser): Promise { - const stats = await MUserStats.fromID(user.id); - const { userStats } = stats; - return { - sacrificedBank: new Bank(userStats.sacrificed_bank as ItemBank), - titheFarmsCompleted: userStats.tithe_farms_completed, - lapsScores: userStats.laps_scores as ItemBank, - openableScores: new Bank(userStats.openable_scores as ItemBank), - kcBank: userStats.monster_scores as ItemBank, - highGambles: userStats.high_gambles, - gotrRiftSearches: userStats.gotr_rift_searches, - stats - }; -} diff --git a/src/mahoji/commands/cl.ts b/src/mahoji/commands/cl.ts index faa51d3f65..9664e61bb3 100644 --- a/src/mahoji/commands/cl.ts +++ b/src/mahoji/commands/cl.ts @@ -5,7 +5,7 @@ import { ApplicationCommandOptionType } from 'discord.js'; import type { CollectionLogType } from '../../lib/collectionLogTask'; import { CollectionLogFlags, clImageGenerator, collectionLogTypes } from '../../lib/collectionLogTask'; import { allCollectionLogs } from '../../lib/data/Collections'; -import { fetchStatsForCL } from '../../lib/util/fetchStatsForCL'; +import { MUserStats } from '../../lib/structures/MUserStats'; import type { OSBMahojiCommand } from '../lib/util'; export const collectionLogCommand: OSBMahojiCommand = { @@ -96,7 +96,7 @@ export const collectionLogCommand: OSBMahojiCommand = { type: options.type ?? 'collection', flags, collection: options.name, - stats: await fetchStatsForCL(user) + stats: await MUserStats.fromID(user.id) }); return result; } From 9c3b3321bd8b44beaaebe5f7a488f5570f3058a7 Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Sun, 14 Jul 2024 16:04:21 +1000 Subject: [PATCH 056/145] Enable logging on prisma clients --- src/lib/globals.ts | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/lib/globals.ts b/src/lib/globals.ts index 2a4236e185..df04e5394f 100644 --- a/src/lib/globals.ts +++ b/src/lib/globals.ts @@ -19,12 +19,7 @@ function makePrismaClient(): PrismaClient { } return new PrismaClient({ - log: [ - { - emit: 'event', - level: 'query' - } - ] + log: ['info', 'warn', 'error'] }); } global.prisma = global.prisma || makePrismaClient(); @@ -35,7 +30,9 @@ function makeRobochimpPrismaClient(): RobochimpPrismaClient { throw new Error('Robochimp client should only be created on the main thread.'); } - return new RobochimpPrismaClient(); + return new RobochimpPrismaClient({ + log: ['info', 'warn', 'error'] + }); } global.roboChimpClient = global.roboChimpClient || makeRobochimpPrismaClient(); From e456be45191919c569597c83714eadc1b8cdf7ed Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Mon, 15 Jul 2024 11:46:47 +1000 Subject: [PATCH 057/145] Diary/requirement code improvements --- src/lib/diaries.ts | 49 ++++++++++--------- src/lib/mastery.ts | 3 +- src/lib/structures/Requirements.ts | 11 +++-- .../achievementDiaryCommand.ts | 12 ++--- .../lib/abstracted_commands/minionKill.ts | 7 ++- 5 files changed, 41 insertions(+), 41 deletions(-) diff --git a/src/lib/diaries.ts b/src/lib/diaries.ts index 267bfbc58d..d74961b72a 100644 --- a/src/lib/diaries.ts +++ b/src/lib/diaries.ts @@ -1,11 +1,12 @@ import { objectEntries } from 'e'; import { Monsters } from 'oldschooljs'; +import type { Minigame } from '@prisma/client'; import { resolveItems } from 'oldschooljs/dist/util/util'; import { MAX_QP } from './minions/data/quests'; import type { DiaryTier, DiaryTierName } from './minions/types'; import { DiaryID } from './minions/types'; -import type { MinigameScore } from './settings/minigames'; +import { Minigames } from './settings/minigames'; import Skillcapes from './skilling/skillcapes'; import { courses } from './skilling/skills/agility'; import { MUserStats } from './structures/MUserStats'; @@ -25,9 +26,10 @@ interface Diary { export function userhasDiaryTierSync( user: MUser, - tier: DiaryTier, - data: { stats: MUserStats; minigameScores: MinigameScore[] } -): [true] | [false, string] { + _tier: DiaryTier | [DiaryID, DiaryTierName], + data: { stats: MUserStats; minigameScores: Minigame } +): { hasDiary: boolean; reasons: string; diaryGroup: Diary; tier: DiaryTier } { + const tier = Array.isArray(_tier) ? diaries.find(d => d.id === _tier[0])![_tier[1]] : _tier; const [hasReqs] = hasSkillReqs(user, tier.skillReqs); const skills = user.skillsAsLevels; let canDo = true; @@ -69,13 +71,13 @@ export function userhasDiaryTierSync( } if (tier.minigameReqs) { - const entries = Object.entries(tier.minigameReqs); + const entries = objectEntries(tier.minigameReqs); for (const [key, neededScore] of entries) { - const thisScore = data.minigameScores.find(m => m.minigame.column === key)!; - if (thisScore.score < neededScore!) { + const thisScore = data.minigameScores[key]!; + if (thisScore < neededScore!) { canDo = false; reasons.push( - `You don't have **${neededScore}** KC in **${thisScore.minigame.name}**, you have **${thisScore.score}**` + `You don't have **${neededScore}** KC in **${Minigames.find(m => m.column === key)!.name}**, you have **${thisScore}**` ); } } @@ -114,15 +116,23 @@ export function userhasDiaryTierSync( } } - if (canDo) return [true]; - return [canDo, reasons.join('\n- ')]; + return { + hasDiary: canDo, + reasons: reasons.join('\n- '), + tier, + diaryGroup: diaries.find(d => [d.easy, d.medium, d.hard, d.elite].includes(tier))! + }; } -export async function userhasDiaryTier(user: MUser, tier: DiaryTier): Promise<[true] | [false, string]> { - return userhasDiaryTierSync(user, tier, { +export async function userhasDiaryTier( + user: MUser, + tier: [DiaryID, DiaryTierName] | DiaryTier +): Promise<[boolean, string, Diary]> { + const result = userhasDiaryTierSync(user, tier, { stats: await MUserStats.fromID(user.id), - minigameScores: await user.fetchMinigameScores() + minigameScores: await user.fetchMinigames() }); + return [result.hasDiary, result.reasons, result.diaryGroup]; } export const WesternProv: Diary = { @@ -1130,16 +1140,9 @@ export const diariesObject = { } as const; export const diaries = Object.values(diariesObject); -export async function userhasDiaryIDTier(user: MUser, type: DiaryID, tier: DiaryTierName) { - const diaryGroup = diaries.find(d => d.id === type)!; - const diaryTier = diaryGroup[tier]!; - const [hasDiary] = userhasDiaryTierSync(user, diaryTier, { +export async function userhasDiaryIDTier(user: MUser, diaryID: DiaryID, tier: DiaryTierName) { + return userhasDiaryTierSync(user, [diaryID, tier], { stats: await MUserStats.fromID(user.id), - minigameScores: await user.fetchMinigameScores() + minigameScores: await user.fetchMinigames() }); - return { - hasDiary, - diaryGroup, - diaryTier - }; } diff --git a/src/lib/mastery.ts b/src/lib/mastery.ts index 1d1764d296..ca17a8c17d 100644 --- a/src/lib/mastery.ts +++ b/src/lib/mastery.ts @@ -39,8 +39,7 @@ export async function calculateMastery(user: MUser, stats: MUserStats) { }, { name: 'Achievement Diaries', - percentage: (await calculateAchievementDiaryProgress(user, stats, await user.fetchMinigameScores())) - .percentComplete + percentage: calculateAchievementDiaryProgress(user, stats, await user.fetchMinigames()).percentComplete } ] as const; diff --git a/src/lib/structures/Requirements.ts b/src/lib/structures/Requirements.ts index 180cb62241..a2c9583a0d 100644 --- a/src/lib/structures/Requirements.ts +++ b/src/lib/structures/Requirements.ts @@ -7,7 +7,7 @@ import { getParsedStashUnits } from '../../mahoji/lib/abstracted_commands/stashU import type { ClueTier } from '../clues/clueTiers'; import type { BitField } from '../constants'; import { BOT_TYPE, BitFieldData } from '../constants'; -import { diaries, userhasDiaryIDTier } from '../diaries'; +import { diaries, userhasDiaryTierSync } from '../diaries'; import { effectiveMonsters } from '../minions/data/killableMonsters'; import type { ClueBank, DiaryID, DiaryTierName } from '../minions/types'; import type { RobochimpUser } from '../roboChimp'; @@ -281,10 +281,13 @@ export class Requirements { const unmetDiaries = ( await Promise.all( requirement.diaryRequirement.map(async ([diary, tier]) => { - const res = await userhasDiaryIDTier(user, diary, tier); + const { hasDiary, diaryGroup } = userhasDiaryTierSync(user, [diary, tier], { + stats, + minigameScores: minigames + }); return { - has: res.hasDiary, - tierName: `${tier} ${res.diaryGroup.name}` + has: hasDiary, + tierName: `${tier} ${diaryGroup.name}` }; }) ) diff --git a/src/mahoji/lib/abstracted_commands/achievementDiaryCommand.ts b/src/mahoji/lib/abstracted_commands/achievementDiaryCommand.ts index 96063494a5..8ed056367e 100644 --- a/src/mahoji/lib/abstracted_commands/achievementDiaryCommand.ts +++ b/src/mahoji/lib/abstracted_commands/achievementDiaryCommand.ts @@ -3,9 +3,9 @@ import { strikethrough } from 'discord.js'; import { calcWhatPercent } from 'e'; import { Bank, Monsters } from 'oldschooljs'; +import type { Minigame } from '@prisma/client'; import { diaries, userhasDiaryTier, userhasDiaryTierSync } from '../../../lib/diaries'; import type { DiaryTier } from '../../../lib/minions/types'; -import type { MinigameScore } from '../../../lib/settings/minigames'; import { Minigames } from '../../../lib/settings/minigames'; import { MUserStats } from '../../../lib/structures/MUserStats'; import { formatSkillRequirements, itemNameFromID, stringMatches } from '../../../lib/util'; @@ -153,19 +153,15 @@ export async function claimAchievementDiaryCommand(user: MUser, diaryName: strin return `You have already completed the entire ${diary.name} diary!`; } -export async function calculateAchievementDiaryProgress( - user: MUser, - stats: MUserStats, - minigameScores: MinigameScore[] -) { +export function calculateAchievementDiaryProgress(user: MUser, stats: MUserStats, minigameScores: Minigame) { let totalDiaries = 0; let totalCompleted = 0; for (const diaryLocation of diaries) { for (const diaryTier of [diaryLocation.easy, diaryLocation.medium, diaryLocation.hard, diaryLocation.elite]) { - const has = userhasDiaryTierSync(user, diaryTier, { stats, minigameScores })[0]; + const { hasDiary } = userhasDiaryTierSync(user, diaryTier, { stats, minigameScores }); totalDiaries++; - if (has) { + if (hasDiary) { totalCompleted++; } } diff --git a/src/mahoji/lib/abstracted_commands/minionKill.ts b/src/mahoji/lib/abstracted_commands/minionKill.ts index 10b4653186..10df8f822b 100644 --- a/src/mahoji/lib/abstracted_commands/minionKill.ts +++ b/src/mahoji/lib/abstracted_commands/minionKill.ts @@ -22,7 +22,7 @@ import { BitField, PeakTier } from '../../../lib/constants'; import { Eatables } from '../../../lib/data/eatables'; import { getSimilarItems } from '../../../lib/data/similarItems'; import { checkUserCanUseDegradeableItem, degradeItem, degradeablePvmBoostItems } from '../../../lib/degradeableItems'; -import { userhasDiaryIDTier } from '../../../lib/diaries'; +import { userhasDiaryTier } from '../../../lib/diaries'; import type { GearSetupType } from '../../../lib/gear/types'; import { trackLoot } from '../../../lib/lootTrack'; import type { CombatOptionsEnum } from '../../../lib/minions/data/combatConstants'; @@ -228,10 +228,9 @@ export async function minionKillCommand( if (!hasReqs) return reason ?? "You don't have the requirements to fight this monster"; if (monster.diaryRequirement) { - const [diaryID, tier] = monster.diaryRequirement; - const { hasDiary, diaryGroup } = await userhasDiaryIDTier(user, diaryID, tier); + const [hasDiary, _, diaryGroup] = await userhasDiaryTier(user, monster.diaryRequirement); if (!hasDiary) { - return `${user.minionName} is missing the ${diaryGroup.name} ${tier} diary to kill ${monster.name}.`; + return `${user.minionName} is missing the ${diaryGroup.name} ${monster.diaryRequirement[1]} diary to kill ${monster.name}.`; } } From ace16febf379ddd0a24a2853159677cb521730b2 Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Mon, 15 Jul 2024 20:10:03 +1000 Subject: [PATCH 058/145] Rework requirements to fetch required data only once --- src/lib/data/buyables/capes.ts | 10 +-- src/lib/musicCape.ts | 47 ++++---------- src/lib/settings/prisma.ts | 18 +----- src/lib/structures/Requirements.ts | 99 +++++++++++++++++------------- src/mahoji/commands/ca.ts | 4 +- 5 files changed, 77 insertions(+), 101 deletions(-) diff --git a/src/lib/data/buyables/capes.ts b/src/lib/data/buyables/capes.ts index 480434fd71..7e672fa86c 100644 --- a/src/lib/data/buyables/capes.ts +++ b/src/lib/data/buyables/capes.ts @@ -3,6 +3,7 @@ import { Bank } from 'oldschooljs'; import { diaries, userhasDiaryTier } from '../../diaries'; import { MAX_QP } from '../../minions/data/quests'; import { musicCapeRequirements } from '../../musicCape'; +import { Requirements } from '../../structures/Requirements'; import type { Buyable } from './buyables'; export const capeBuyables: Buyable[] = [ @@ -78,7 +79,7 @@ export const capeBuyables: Buyable[] = [ }), gpCost: 99_000, customReq: async user => { - const meetsReqs = await musicCapeRequirements.check(user); + const meetsReqs = await musicCapeRequirements.check(await Requirements.fetchRequiredData(user)); if (!meetsReqs.hasAll) { return [false, `You don't meet the requirements to buy this: \n${meetsReqs.rendered}`]; } @@ -92,13 +93,12 @@ export const capeBuyables: Buyable[] = [ }), gpCost: 99_000, customReq: async user => { - const meetsReqs = await musicCapeRequirements.check(user); - if (!meetsReqs.hasAll) { - return [false, `You don't meet the requirements to buy this: \n${meetsReqs.rendered}`]; - } if (user.QP < MAX_QP) { return [false, "You can't buy this because you haven't completed all the quests!"]; } + if (!user.cl.has('Music cape')) { + return [false, 'You need to own the regular Music cape first.']; + } for (const diary of diaries.map(d => d.elite)) { const [has] = await userhasDiaryTier(user, diary); if (!has) { diff --git a/src/lib/musicCape.ts b/src/lib/musicCape.ts index 4e18293e53..ee32e947d1 100644 --- a/src/lib/musicCape.ts +++ b/src/lib/musicCape.ts @@ -3,32 +3,24 @@ import { objectEntries, partition } from 'e'; import { Bank, Monsters } from 'oldschooljs'; import { resolveItems } from 'oldschooljs/dist/util/util'; -import { getPOH } from '../mahoji/lib/abstracted_commands/pohCommand'; import { MIMIC_MONSTER_ID, NEX_ID, ZALCANO_ID } from './constants'; import { championScrolls } from './data/CollectionsExport'; import { RandomEvents } from './randomEvents'; import type { MinigameName } from './settings/minigames'; import { Minigames } from './settings/minigames'; -import { getUsersActivityCounts } from './settings/prisma'; import type { RequirementFailure } from './structures/Requirements'; import { Requirements } from './structures/Requirements'; -import { itemNameFromID } from './util'; export const musicCapeRequirements = new Requirements() .add({ - name: 'Do 20 slayer tasks', - has: async ({ user }) => { - const count = await prisma.slayerTask.count({ - where: { - user_id: user.id - } - }); - if (count >= 20) { + name: 'Reach level 50 Slayer', + has: ({ user }) => { + if (user.skillsAsLevels.slayer >= 50) { return []; } return [ { - reason: 'You need to complete 20 slayer tasks.' + reason: 'You need level 50 slayer.' } ]; } @@ -109,13 +101,7 @@ export const musicCapeRequirements = new Requirements() }) .add({ name: 'Runecraft all runes atleast once', - has: async ({ user }) => { - const counts = await prisma.$queryRaw<{ rune_id: string }[]>`SELECT DISTINCT(data->>'runeID') AS rune_id -FROM activity -WHERE user_id = ${BigInt(user.id)} -AND type = 'Runecraft' -AND data->>'runeID' IS NOT NULL;`; - + has: ({ uniqueRunesCrafted }) => { const runesToCheck = resolveItems([ 'Mind rune', 'Air rune', @@ -130,10 +116,7 @@ AND data->>'runeID' IS NOT NULL;`; 'Astral rune', 'Wrath rune' ]); - const notDoneRunes = runesToCheck - .filter(i => !counts.some(c => c.rune_id === i.toString())) - .map(i => itemNameFromID(i)!) - .map(s => s.split(' ')[0]); + const notDoneRunes = runesToCheck.filter(r => !uniqueRunesCrafted.includes(r)); if (notDoneRunes.length > 0) { return [ { @@ -147,7 +130,7 @@ AND data->>'runeID' IS NOT NULL;`; }) .add({ name: 'One of Every Activity', - has: async ({ user }) => { + has: ({ uniqueActivitiesDone }) => { const typesNotRequiredForMusicCape: activity_type_enum[] = [ activity_type_enum.Easter, activity_type_enum.HalloweenEvent, @@ -159,10 +142,8 @@ AND data->>'runeID' IS NOT NULL;`; activity_type_enum.Nex, activity_type_enum.Revenants // This is now under monsterActivity ]; - const activityCounts = await getUsersActivityCounts(user); - const notDoneActivities = Object.values(activity_type_enum).filter( - type => !typesNotRequiredForMusicCape.includes(type) && activityCounts[type] < 1 + type => !typesNotRequiredForMusicCape.includes(type) && !uniqueActivitiesDone.includes(type) ); const [firstLot, secondLot] = partition(notDoneActivities, i => notDoneActivities.indexOf(i) < 5); @@ -182,7 +163,7 @@ AND data->>'runeID' IS NOT NULL;`; }) .add({ name: 'One of Every Minigame', - has: async ({ user }) => { + has: ({ minigames }) => { const results: RequirementFailure[] = []; const typesNotRequiredForMusicCape: MinigameName[] = [ 'corrupted_gauntlet', @@ -192,9 +173,8 @@ AND data->>'runeID' IS NOT NULL;`; 'champions_challenge' ]; - const minigameScores = await user.fetchMinigames(); const minigamesNotDone = Minigames.filter( - i => !typesNotRequiredForMusicCape.includes(i.column) && minigameScores[i.column] < 1 + i => !typesNotRequiredForMusicCape.includes(i.column) && minigames[i.column] < 1 ).map(i => i.name); if (minigamesNotDone.length > 0) { @@ -208,7 +188,7 @@ AND data->>'runeID' IS NOT NULL;`; }) .add({ name: 'One Random Event with a unique music track', - has: async ({ stats }) => { + has: ({ stats }) => { const results: RequirementFailure[] = []; const eventBank = stats.randomEventCompletionsBank(); const uniqueTracks = RandomEvents.filter(i => i.uniqueMusic); @@ -224,8 +204,7 @@ AND data->>'runeID' IS NOT NULL;`; }) .add({ name: 'Must Build Something in PoH', - has: async ({ user }) => { - const poh = await getPOH(user.id); + has: ({ poh }) => { for (const [key, value] of objectEntries(poh)) { if (['user_id', 'background_id'].includes(key)) continue; if (value !== null) { @@ -237,7 +216,7 @@ AND data->>'runeID' IS NOT NULL;`; }) .add({ name: 'Champions Challenge', - has: async ({ user }) => { + has: ({ user }) => { for (const scroll of championScrolls) { if (user.cl.has(scroll)) return []; } diff --git a/src/lib/settings/prisma.ts b/src/lib/settings/prisma.ts index a974743e44..4e719a2bf1 100644 --- a/src/lib/settings/prisma.ts +++ b/src/lib/settings/prisma.ts @@ -1,5 +1,5 @@ import type { Activity, Prisma } from '@prisma/client'; -import { activity_type_enum } from '@prisma/client'; +import type { activity_type_enum } from '@prisma/client'; import type { ActivityTaskData } from '../types/minions'; @@ -32,19 +32,3 @@ export async function countUsersWithItemInCl(itemID: number, ironmenOnly: boolea } return result; } - -export async function getUsersActivityCounts(user: MUser) { - const counts = await prisma.$queryRaw<{ type: activity_type_enum; count: bigint }[]>`SELECT type, COUNT(type) -FROM activity -WHERE user_id = ${BigInt(user.id)} -GROUP BY type;`; - - const result: Record = {} as Record; - for (const type of Object.values(activity_type_enum)) { - result[type] = 0; - } - for (const { count, type } of counts) { - result[type] = Number(count); - } - return result; -} diff --git a/src/lib/structures/Requirements.ts b/src/lib/structures/Requirements.ts index a2c9583a0d..a4354fcf6b 100644 --- a/src/lib/structures/Requirements.ts +++ b/src/lib/structures/Requirements.ts @@ -1,7 +1,8 @@ -import type { Minigame } from '@prisma/client'; +import type { Minigame, PlayerOwnedHouse, activity_type_enum } from '@prisma/client'; import { calcWhatPercent, objectEntries } from 'e'; import type { Bank } from 'oldschooljs'; +import { getPOH } from '../../mahoji/lib/abstracted_commands/pohCommand'; import type { ParsedUnit } from '../../mahoji/lib/abstracted_commands/stashUnitsCommand'; import { getParsedStashUnits } from '../../mahoji/lib/abstracted_commands/stashUnitsCommand'; import type { ClueTier } from '../clues/clueTiers'; @@ -28,19 +29,12 @@ interface RequirementUserArgs { stats: MUserStats; roboChimpUser: RobochimpUser; clueCounts: ClueBank; + poh: PlayerOwnedHouse; + uniqueRunesCrafted: number[]; + uniqueActivitiesDone: activity_type_enum[]; } -type ManualHasFunction = ( - args: RequirementUserArgs -) => - | Promise - | RequirementFailure[] - | undefined - | Promise - | string - | Promise - | boolean - | Promise; +type ManualHasFunction = (args: RequirementUserArgs) => RequirementFailure[] | undefined | string | boolean; type Requirement = { name?: string; @@ -157,15 +151,12 @@ export class Requirements { return this; } - async checkSingleRequirement( - requirement: Requirement, - userArgs: RequirementUserArgs - ): Promise { + checkSingleRequirement(requirement: Requirement, userArgs: RequirementUserArgs): RequirementFailure[] { const { user, stats, minigames, clueCounts } = userArgs; const results: RequirementFailure[] = []; if ('has' in requirement) { - const result = await requirement.has(userArgs); + const result = requirement.has(userArgs); if (typeof result === 'boolean') { if (!result) { results.push({ reason: requirement.name }); @@ -278,20 +269,18 @@ export class Requirements { } if ('diaryRequirement' in requirement) { - const unmetDiaries = ( - await Promise.all( - requirement.diaryRequirement.map(async ([diary, tier]) => { - const { hasDiary, diaryGroup } = userhasDiaryTierSync(user, [diary, tier], { - stats, - minigameScores: minigames - }); - return { - has: hasDiary, - tierName: `${tier} ${diaryGroup.name}` - }; - }) - ) - ).filter(i => !i.has); + const unmetDiaries = requirement.diaryRequirement + .map(([diary, tier]) => { + const { hasDiary, diaryGroup } = userhasDiaryTierSync(user, [diary, tier], { + stats, + minigameScores: minigames + }); + return { + has: hasDiary, + tierName: `${tier} ${diaryGroup.name}` + }; + }) + .filter(i => !i.has); if (unmetDiaries.length > 0) { results.push({ reason: `You need to finish these achievement diaries: ${unmetDiaries @@ -312,7 +301,7 @@ export class Requirements { } if ('OR' in requirement) { - const orResults = await Promise.all(requirement.OR.map(req => this.checkSingleRequirement(req, userArgs))); + const orResults = requirement.OR.map(req => this.checkSingleRequirement(req, userArgs)); if (!orResults.some(i => i.length === 0)) { results.push({ reason: `You need to meet one of these requirements:\n${orResults.map((res, index) => { @@ -325,29 +314,51 @@ export class Requirements { return results; } - async check(user: MUser) { + static async fetchRequiredData(user: MUser) { const minigames = await user.fetchMinigames(); const stashUnits = await getParsedStashUnits(user.id); const stats = await MUserStats.fromID(user.id); const roboChimpUser = await user.fetchRobochimpUser(); const clueCounts = BOT_TYPE === 'OSB' ? stats.clueScoresFromOpenables() : (await user.calcActualClues()).clueCounts; + const poh = await getPOH(user.id); + const [_uniqueRunesCrafted, uniqueActivitiesDone] = await prisma.$transaction([ + prisma.$queryRaw<{ rune_id: string }[]>`SELECT DISTINCT(data->>'runeID') AS rune_id +FROM activity +WHERE user_id = ${BigInt(user.id)} +AND type = 'Runecraft' +AND data->>'runeID' IS NOT NULL;`, + prisma.$queryRaw<{ type: activity_type_enum }[]>`SELECT DISTINCT(type) +FROM activity +WHERE user_id = ${BigInt(user.id)} +GROUP BY type;` + ]); + const uniqueRunesCrafted = _uniqueRunesCrafted.map(i => Number(i.rune_id)); + return { + user, + minigames, + stashUnits, + stats, + roboChimpUser, + clueCounts, + poh, + uniqueRunesCrafted, + uniqueActivitiesDone: uniqueActivitiesDone.map(i => i.type) + }; + } - const requirementResults = this.requirements.map(async i => ({ - result: await this.checkSingleRequirement(i, { - user, - minigames, - stashUnits, - stats, - roboChimpUser, - clueCounts - }), + static async checkMany(user: MUser, requirements: Requirements[]) { + const data = await Requirements.fetchRequiredData(user); + return requirements.map(i => i.check(data)); + } + + check(data: Awaited>) { + const results = this.requirements.map(i => ({ + result: this.checkSingleRequirement(i, data), requirement: i })); - const results = await Promise.all(requirementResults); const flatReasons = results.flatMap(r => r.result); - const totalRequirements = this.requirements.length; const metRequirements = results.filter(i => i.result.length === 0).length; const completionPercentage = calcWhatPercent(metRequirements, totalRequirements); diff --git a/src/mahoji/commands/ca.ts b/src/mahoji/commands/ca.ts index 108d35a35e..508bd74c1e 100644 --- a/src/mahoji/commands/ca.ts +++ b/src/mahoji/commands/ca.ts @@ -13,6 +13,7 @@ import { caToPlayerString, nextCATier } from '../../lib/combat_achievements/combatAchievements'; +import { Requirements } from '../../lib/structures/Requirements'; import { deferInteraction } from '../../lib/util/interactionReply'; import type { OSBMahojiCommand } from '../lib/util'; @@ -94,9 +95,10 @@ export const caCommand: OSBMahojiCommand = { .filter(i => !('rng' in i)); const completedTasks: CombatAchievement[] = []; + const reqData = await Requirements.fetchRequiredData(user); for (const task of tasksToCheck) { if ('requirements' in task) { - const { hasAll } = await task.requirements.check(user); + const { hasAll } = task.requirements.check(reqData); if (hasAll) { completedTasks.push(task); } From 4462e78cddeb25daa6dc37823fd570a7d7b6c73a Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Mon, 15 Jul 2024 20:25:23 +1000 Subject: [PATCH 059/145] Remove unnecessary fetching for farming details --- src/mahoji/commands/farming.ts | 4 ++-- .../farmingContractCommand.ts | 17 +++++++---------- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/src/mahoji/commands/farming.ts b/src/mahoji/commands/farming.ts index 68f802cd57..d2eecfdf93 100644 --- a/src/mahoji/commands/farming.ts +++ b/src/mahoji/commands/farming.ts @@ -9,7 +9,7 @@ import { superCompostables } from '../../lib/data/filterables'; import type { ContractOption } from '../../lib/minions/farming/types'; import { ContractOptions } from '../../lib/minions/farming/types'; import { autoFarm } from '../../lib/minions/functions/autoFarm'; -import { getFarmingInfo } from '../../lib/skilling/functions/getFarmingInfo'; +import { getFarmingInfoFromUser } from '../../lib/skilling/functions/getFarmingInfo'; import Farming, { CompostTiers } from '../../lib/skilling/skills/farming'; import { stringMatches } from '../../lib/util'; import { farmingPatchNames, userGrowingProgressStr } from '../../lib/util/farmingHelpers'; @@ -204,7 +204,7 @@ export const farmingCommand: OSBMahojiCommand = { }>) => { await deferInteraction(interaction); const klasaUser = await mUserFetch(userID); - const { patchesDetailed } = await getFarmingInfo(userID); + const { patchesDetailed } = getFarmingInfoFromUser(klasaUser.user); if (options.auto_farm) { return autoFarm(klasaUser, patchesDetailed, channelID); diff --git a/src/mahoji/lib/abstracted_commands/farmingContractCommand.ts b/src/mahoji/lib/abstracted_commands/farmingContractCommand.ts index 56b644e515..03620fce8a 100644 --- a/src/mahoji/lib/abstracted_commands/farmingContractCommand.ts +++ b/src/mahoji/lib/abstracted_commands/farmingContractCommand.ts @@ -7,7 +7,7 @@ import type { FarmingContractDifficultyLevel } from '../../../lib/minions/farming/types'; import { getPlantToGrow } from '../../../lib/skilling/functions/calcFarmingContracts'; -import { getFarmingInfo } from '../../../lib/skilling/functions/getFarmingInfo'; +import { getFarmingInfoFromUser } from '../../../lib/skilling/functions/getFarmingInfo'; import { plants } from '../../../lib/skilling/skills/farming'; import { makeComponents, makeEasierFarmingContractButton, roughMergeMahojiResponse } from '../../../lib/util'; import { newChatHeadImage } from '../../../lib/util/chatHeadImage'; @@ -149,7 +149,7 @@ export async function farmingContractCommand(userID: string, input?: ContractOpt }; } -export async function canRunAutoContract(user: MUser) { +export function canRunAutoContract(user: MUser) { // Must be above 45 farming if (user.skillLevel('farming') < 45) return false; @@ -157,7 +157,7 @@ export async function canRunAutoContract(user: MUser) { const contract = user.user.minion_farmingContract as FarmingContract | null; if (!contract || !contract.hasContract) return true; - const farmingDetails = await getFarmingInfo(user.id); + const farmingDetails = getFarmingInfoFromUser(user.user); // If the patch we're contracted to is ready, we can auto contract const contractedPatch = farmingDetails.patchesDetailed.find( @@ -173,12 +173,9 @@ function bestFarmingContractUserCanDo(user: MUser) { } export async function autoContract(user: MUser, channelID: string, userID: string): CommandResponse { - const [farmingDetails, mahojiUser] = await Promise.all([ - getFarmingInfo(userID), - mahojiUsersSettingsFetch(userID, { minion_farmingContract: true }) - ]); - const contract = mahojiUser.minion_farmingContract as FarmingContract | null; - const plant = contract?.hasContract ? findPlant(contract?.plantToGrow) : null; + const farmingDetails = getFarmingInfoFromUser(user.user); + const contract = user.farmingContract(); + const plant = contract?.contract ? findPlant(contract?.contract.plantToGrow) : null; const patch = farmingDetails.patchesDetailed.find(p => p.plant === plant); const bestContractTierCanDo = bestFarmingContractUserCanDo(user); @@ -189,7 +186,7 @@ export async function autoContract(user: MUser, channelID: string, userID: strin } // If they have no contract, get them a contract, recurse. - if (!contract || !contract.hasContract) { + if (!contract || !contract.contract) { const contractResult = await farmingContractCommand(userID, bestContractTierCanDo); const newUser = await mahojiUsersSettingsFetch(userID, { minion_farmingContract: true }); const newContract = (newUser.minion_farmingContract ?? defaultFarmingContract) as FarmingContract; From d837cf2b4a0d5845cc7128d4d840abed7686b23e Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Mon, 15 Jul 2024 20:34:53 +1000 Subject: [PATCH 060/145] Put poh fetching in transaction --- src/lib/structures/Requirements.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/lib/structures/Requirements.ts b/src/lib/structures/Requirements.ts index a4354fcf6b..59280d2fd8 100644 --- a/src/lib/structures/Requirements.ts +++ b/src/lib/structures/Requirements.ts @@ -2,7 +2,6 @@ import type { Minigame, PlayerOwnedHouse, activity_type_enum } from '@prisma/cli import { calcWhatPercent, objectEntries } from 'e'; import type { Bank } from 'oldschooljs'; -import { getPOH } from '../../mahoji/lib/abstracted_commands/pohCommand'; import type { ParsedUnit } from '../../mahoji/lib/abstracted_commands/stashUnitsCommand'; import { getParsedStashUnits } from '../../mahoji/lib/abstracted_commands/stashUnitsCommand'; import type { ClueTier } from '../clues/clueTiers'; @@ -321,8 +320,8 @@ export class Requirements { const roboChimpUser = await user.fetchRobochimpUser(); const clueCounts = BOT_TYPE === 'OSB' ? stats.clueScoresFromOpenables() : (await user.calcActualClues()).clueCounts; - const poh = await getPOH(user.id); - const [_uniqueRunesCrafted, uniqueActivitiesDone] = await prisma.$transaction([ + + const [_uniqueRunesCrafted, uniqueActivitiesDone, poh] = await prisma.$transaction([ prisma.$queryRaw<{ rune_id: string }[]>`SELECT DISTINCT(data->>'runeID') AS rune_id FROM activity WHERE user_id = ${BigInt(user.id)} @@ -331,7 +330,8 @@ AND data->>'runeID' IS NOT NULL;`, prisma.$queryRaw<{ type: activity_type_enum }[]>`SELECT DISTINCT(type) FROM activity WHERE user_id = ${BigInt(user.id)} -GROUP BY type;` +GROUP BY type;`, + prisma.playerOwnedHouse.upsert({ where: { user_id: user.id }, update: {}, create: { user_id: user.id } }) ]); const uniqueRunesCrafted = _uniqueRunesCrafted.map(i => Number(i.rune_id)); return { From 99d582605e27473ed23848d9c211d78cf7f9d40c Mon Sep 17 00:00:00 2001 From: GC <30398469+gc@users.noreply.github.com> Date: Tue, 16 Jul 2024 11:35:47 +1000 Subject: [PATCH 061/145] Various fixes/improvements and patron system (#5957) --- .env.test | 3 +- .github/workflows/unit_tests.yml | 8 +- docker-compose.yml | 4 +- package.json | 10 +- prisma/robochimp.prisma | 113 +++---- src/index.ts | 14 +- src/lib/MUser.ts | 21 +- src/lib/combat_achievements/elite.ts | 2 +- src/lib/combat_achievements/grandmaster.ts | 2 +- src/lib/combat_achievements/master.ts | 2 +- src/lib/globals.ts | 12 + src/lib/grandExchange.ts | 34 ++- src/lib/patreonUtils.ts | 54 ++++ src/lib/perkTiers.ts | 104 ++----- src/lib/roboChimp.ts | 91 +++++- src/lib/settings/settings.ts | 36 ++- src/lib/simulation/toa.ts | 18 -- src/lib/util.ts | 41 ++- src/lib/util/globalInteractions.ts | 19 +- src/lib/util/linkedAccountsUtil.ts | 47 --- src/lib/util/repeatStoredTrip.ts | 3 +- src/lib/util/syncDisabledCommands.ts | 20 ++ src/mahoji/commands/leaderboard.ts | 2 +- src/mahoji/commands/minion.ts | 21 +- src/mahoji/commands/rp.ts | 38 +-- .../lib/abstracted_commands/ironmanCommand.ts | 8 - .../lib/abstracted_commands/minionKill.ts | 7 +- src/mahoji/lib/events.ts | 37 +-- src/mahoji/lib/inhibitors.ts | 11 +- src/mahoji/lib/preCommand.ts | 4 +- src/tasks/minions/minigames/toaActivity.ts | 9 +- tests/integration/leaderboard.test.ts | 15 + tests/integration/redis.test.ts | 103 +++++++ tests/unit/getUsersPerkTier.test.ts | 22 -- tests/unit/utils.ts | 4 - yarn.lock | 276 +++++++++++++++++- 36 files changed, 786 insertions(+), 429 deletions(-) create mode 100644 src/lib/patreonUtils.ts delete mode 100644 src/lib/util/linkedAccountsUtil.ts create mode 100644 src/lib/util/syncDisabledCommands.ts create mode 100644 tests/integration/leaderboard.test.ts create mode 100644 tests/integration/redis.test.ts delete mode 100644 tests/unit/getUsersPerkTier.test.ts diff --git a/.env.test b/.env.test index d3c4fd9c3c..63f5c51628 100644 --- a/.env.test +++ b/.env.test @@ -2,4 +2,5 @@ TZ="UTC" CLIENT_ID=111398433321891634 BOT_TOKEN=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA TEST=true -CI=true \ No newline at end of file +CI=true +YARN_ENABLE_HARDENED_MODE=0 \ No newline at end of file diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index 89ec39950c..118047b3da 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -11,7 +11,7 @@ jobs: test: name: Node v${{ matrix.node_version }} - ${{ matrix.os }} runs-on: ${{ matrix.os }} - timeout-minutes: 10 + timeout-minutes: 5 strategy: matrix: node_version: [20.15.0] @@ -43,8 +43,4 @@ jobs: - name: Build run: yarn build:tsc - name: Test - run: | - yarn test:unit - yarn test:lint - tsc -p tests/integration && tsc -p tests/unit - npm i -g dpdm && dpdm --exit-code circular:1 --progress=false --warning=false --tree=false ./dist/index.js + run: yarn test:ci:unit diff --git a/docker-compose.yml b/docker-compose.yml index 36de7bd1b2..26ea7e6f3c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,8 +19,8 @@ services: depends_on: - db environment: - ROBOCHIMP_DATABASE_URL: postgresql://postgres:postgres@db:5435/robochimp_integration_test?connection_limit=500&pool_timeout=0&schema=public - DATABASE_URL: postgresql://postgres:postgres@db:5435/osb_integration_test?connection_limit=500&pool_timeout=0&schema=public + ROBOCHIMP_DATABASE_URL: postgresql://postgres:postgres@db:5435/robochimp_integration_test?connection_limit=10&pool_timeout=0&schema=public + DATABASE_URL: postgresql://postgres:postgres@db:5435/osb_integration_test?connection_limit=10&pool_timeout=0&schema=public WAIT_HOSTS: db:5435 volumes: diff --git a/package.json b/package.json index 80bb21fd06..62d25f4bb0 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "start": "yarn build && node --enable-source-maps dist/", "gen": "concurrently --raw \"prisma generate --no-hints\" \"prisma generate --no-hints --schema prisma/robochimp.prisma\" && echo \"Generated Prisma Client\"", "prettify": "prettier --use-tabs \"./**/*.{md,yml}\" --write", - "lint": "concurrently --raw --kill-others-on-fail \"biome check --write --unsafe --diagnostic-level=error\" \"yarn prettify\"", + "lint": "concurrently --raw --kill-others-on-fail \"biome check --write --unsafe --diagnostic-level=error\" \"yarn prettify\" \"prisma format --schema ./prisma/robochimp.prisma\" \"prisma format --schema ./prisma/schema.prisma\"", "build:tsc": "tsc -p src", "watch:tsc": "tsc -w -p src", "wipedist": "node -e \"try { require('fs').rmSync('dist', { recursive: true }) } catch(_){}\"", @@ -19,12 +19,15 @@ "buildandrun": "yarn build:esbuild && node --enable-source-maps dist", "build:esbuild": "concurrently --raw \"yarn build:main\" \"yarn build:workers\"", "build:main": "esbuild src/index.ts src/lib/workers/index.ts --sourcemap=inline --minify --legal-comments=none --outdir=./dist --log-level=error --bundle --platform=node --loader:.node=file --external:@napi-rs/canvas --external:@prisma/robochimp --external:@prisma/client --external:zlib-sync --external:bufferutil --external:oldschooljs --external:discord.js --external:node-fetch --external:piscina", - "build:workers": "esbuild src/lib/workers/kill.worker.ts src/lib/workers/finish.worker.ts src/lib/workers/casket.worker.ts --sourcemap=inline --log-level=error --bundle --minify --legal-comments=none --outdir=./dist/lib/workers --platform=node --loader:.node=file --external:@napi-rs/canvas --external:@prisma/robochimp --external:@prisma/client --external:zlib-sync --external:bufferutil --external:oldschooljs --external:discord.js --external:node-fetch --external:piscina" + "build:workers": "esbuild src/lib/workers/kill.worker.ts src/lib/workers/finish.worker.ts src/lib/workers/casket.worker.ts --sourcemap=inline --log-level=error --bundle --minify --legal-comments=none --outdir=./dist/lib/workers --platform=node --loader:.node=file --external:@napi-rs/canvas --external:@prisma/robochimp --external:@prisma/client --external:zlib-sync --external:bufferutil --external:oldschooljs --external:discord.js --external:node-fetch --external:piscina", + "test:circular": "dpdm --exit-code circular:1 --progress=false --warning=false --tree=false ./dist/index.js", + "test:ci:unit": "concurrently --raw --kill-others-on-fail \"yarn test:unit\" \"yarn test:lint\" \"tsc -p tests/integration\" \"tsc -p tests/unit\" \"yarn test:circular\"" }, "dependencies": { "@napi-rs/canvas": "^0.1.53", - "@oldschoolgg/toolkit": "git+https://github.com/oldschoolgg/toolkit.git#3eb432f0349a32fde98e981c42653d927d91e7e2", + "@oldschoolgg/toolkit": "git+https://github.com/oldschoolgg/toolkit.git#3550582efbdf04929f0a2c5114ed069a61551807", "@prisma/client": "^5.16.1", + "@sapphire/ratelimits": "^2.4.9", "@sapphire/snowflake": "^3.5.3", "@sapphire/time-utilities": "^1.6.0", "@sentry/node": "^8.15.0", @@ -56,6 +59,7 @@ "@types/node-fetch": "^2.6.1", "@vitest/coverage-v8": "^1.6.0", "concurrently": "^8.2.2", + "dpdm": "^3.14.0", "esbuild": "0.21.5", "fast-glob": "^3.3.2", "nodemon": "^3.1.4", diff --git a/prisma/robochimp.prisma b/prisma/robochimp.prisma index f3e6cabce0..73fc468661 100644 --- a/prisma/robochimp.prisma +++ b/prisma/robochimp.prisma @@ -1,93 +1,100 @@ generator client { - provider = "prisma-client-js" - previewFeatures = ["fullTextSearch"] - output = "../node_modules/@prisma/robochimp" + provider = "prisma-client-js" + previewFeatures = ["fullTextSearch"] + output = "../node_modules/@prisma/robochimp" } datasource db { - provider = "postgresql" - url = env("ROBOCHIMP_DATABASE_URL") + provider = "postgresql" + url = env("ROBOCHIMP_DATABASE_URL") } model TriviaQuestion { - id Int @id @unique @default(autoincrement()) - question String @db.VarChar() - answers String[] @db.VarChar() + id Int @id @unique @default(autoincrement()) + question String @db.VarChar() + answers String[] @db.VarChar() - @@map("trivia_question") + @@map("trivia_question") } enum BlacklistedEntityType { - guild - user + guild + user } model BlacklistedEntity { - id BigInt @id @unique - type BlacklistedEntityType - reason String? - date DateTime @default(now()) @db.Timestamp(6) + id BigInt @id @unique + type BlacklistedEntityType + reason String? + date DateTime @default(now()) @db.Timestamp(6) - @@map("blacklisted_entity") + @@map("blacklisted_entity") } model User { - id BigInt @id @unique - bits Int[] - github_id Int? - patreon_id String? - migrated_user_id BigInt? + id BigInt @id @unique + bits Int[] + github_id Int? + patreon_id String? - leagues_completed_tasks_ids Int[] - leagues_points_balance_osb Int @default(0) - leagues_points_balance_bso Int @default(0) - leagues_points_total Int @default(0) + migrated_user_id BigInt? - react_emoji_id String? + leagues_completed_tasks_ids Int[] + leagues_points_balance_osb Int @default(0) + leagues_points_balance_bso Int @default(0) + leagues_points_total Int @default(0) - osb_total_level Int? - bso_total_level Int? - osb_total_xp BigInt? - bso_total_xp BigInt? - osb_cl_percent Float? - bso_cl_percent Float? - osb_mastery Float? - bso_mastery Float? + react_emoji_id String? - tag Tag[] + osb_total_level Int? + bso_total_level Int? + osb_total_xp BigInt? + bso_total_xp BigInt? + osb_cl_percent Float? + bso_cl_percent Float? + osb_mastery Float? + bso_mastery Float? - store_bitfield Int[] + tag Tag[] - testing_points Float @default(0) - testing_points_balance Float @default(0) + store_bitfield Int[] - @@map("user") + testing_points Float @default(0) + testing_points_balance Float @default(0) + + perk_tier Int @default(0) + premium_balance_tier Int? + premium_balance_expiry_date BigInt? + ironman_alts String[] + main_account String? + + @@map("user") } model PingableRole { - role_id String @id - name String @unique @db.VarChar(32) + role_id String @id + name String @unique @db.VarChar(32) - @@map("pingable_role") + @@map("pingable_role") } model Tag { - id Int @id @unique @default(autoincrement()) - name String @unique @db.VarChar(32) - content String @db.VarChar(2000) + id Int @id @unique @default(autoincrement()) + name String @unique @db.VarChar(32) + content String @db.VarChar(2000) - user_id BigInt - creator User @relation(fields: [user_id], references: [id]) + user_id BigInt + creator User @relation(fields: [user_id], references: [id]) - @@map("tag") + @@map("tag") } model StoreCode { - product_id Int - code String @id @unique + product_id Int + code String @id @unique - redeemed_at DateTime? - redeemed_by_user_id String? @db.VarChar(19) + redeemed_at DateTime? + redeemed_by_user_id String? @db.VarChar(19) - @@map("store_code") + @@map("store_code") } diff --git a/src/index.ts b/src/index.ts index 0dad9104d1..e5a34d6ae5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,10 +12,12 @@ import { isObject } from 'e'; import { SENTRY_DSN, SupportServer } from './config'; import { syncActivityCache } from './lib/Task'; -import { BLACKLISTED_GUILDS, BLACKLISTED_USERS } from './lib/blacklists'; +import { cacheBadges } from './lib/badges'; +import { BLACKLISTED_GUILDS, BLACKLISTED_USERS, syncBlacklists } from './lib/blacklists'; import { Channel, Events, META_CONSTANTS, gitHash, globalConfig } from './lib/constants'; import { economyLog } from './lib/economyLogs'; import { onMessage } from './lib/events'; +import { GrandExchange } from './lib/grandExchange'; import { modalInteractionHook } from './lib/modals'; import { runStartupScripts } from './lib/startupScripts'; import { OldSchoolBotClient } from './lib/structures/OldSchoolBotClient'; @@ -24,8 +26,9 @@ import { CACHED_ACTIVE_USER_IDS, syncActiveUserIDs } from './lib/util/cachedUser import { interactionHook } from './lib/util/globalInteractions'; import { handleInteractionError } from './lib/util/interactionReply'; import { logError } from './lib/util/logError'; +import { syncDisabledCommands } from './lib/util/syncDisabledCommands'; import { allCommands } from './mahoji/commands/allCommands'; -import { onStartup } from './mahoji/lib/events'; +import { onStartup, syncCustomPrices } from './mahoji/lib/events'; import { postCommand } from './mahoji/lib/postCommand'; import { preCommand } from './mahoji/lib/preCommand'; import { convertMahojiCommandToAbstractCommand } from './mahoji/lib/util'; @@ -193,7 +196,12 @@ async function main() { await Promise.all([ runTimedLoggedFn('Sync Active User IDs', syncActiveUserIDs), runTimedLoggedFn('Sync Activity Cache', syncActivityCache), - runTimedLoggedFn('Startup Scripts', runStartupScripts) + runTimedLoggedFn('Startup Scripts', runStartupScripts), + runTimedLoggedFn('Sync Disabled Commands', syncDisabledCommands), + runTimedLoggedFn('Sync Blacklist', syncBlacklists), + runTimedLoggedFn('Syncing prices', syncCustomPrices), + runTimedLoggedFn('Caching badges', cacheBadges), + runTimedLoggedFn('Init Grand Exchange', () => GrandExchange.init()) ]); await runTimedLoggedFn('Log In', () => client.login(globalConfig.botToken)); console.log(`Logged in as ${globalClient.user.username}`); diff --git a/src/lib/MUser.ts b/src/lib/MUser.ts index e6ecbdb8c4..7a725883da 100644 --- a/src/lib/MUser.ts +++ b/src/lib/MUser.ts @@ -30,7 +30,7 @@ import type { FarmingContract } from './minions/farming/types'; import type { AttackStyles } from './minions/functions'; import { blowpipeDarts, validateBlowpipeData } from './minions/functions/blowpipeCommand'; import type { AddXpParams, BlowpipeData, ClueBank } from './minions/types'; -import { getUsersPerkTier, syncPerkTierOfUser } from './perkTiers'; +import { getUsersPerkTier } from './perkTiers'; import { roboChimpUserFetch } from './roboChimp'; import type { MinigameScore } from './settings/minigames'; import { Minigames, getMinigameEntity } from './settings/minigames'; @@ -41,7 +41,7 @@ import type { BankSortMethod } from './sorts'; import type { ChargeBank } from './structures/Bank'; import { Gear, defaultGear } from './structures/Gear'; import type { ItemBank, Skills } from './types'; -import { addItemToBank, cacheUsername, convertXPtoLVL, itemNameFromID } from './util'; +import { addItemToBank, convertXPtoLVL, itemNameFromID } from './util'; import { determineRunes } from './util/determineRunes'; import { getKCByName } from './util/getKCByName'; import getOSItem, { getItem } from './util/getOSItem'; @@ -96,17 +96,12 @@ export class MUserClass { skillsAsXP!: Required; skillsAsLevels!: Required; badgesString!: string; + bitfield!: readonly BitField[]; constructor(user: User) { this.user = user; this.id = user.id; this.updateProperties(); - - syncPerkTierOfUser(this); - - if (this.user.username) { - cacheUsername(this.id, this.user.username, this.badgesString); - } } private updateProperties() { @@ -138,6 +133,8 @@ export class MUserClass { this.skillsAsLevels = this.getSkills(true); this.badgesString = makeBadgeString(this.user.badges, this.isIronman); + + this.bitfield = this.user.bitfield as readonly BitField[]; } countSkillsAtleast99() { @@ -200,12 +197,8 @@ export class MUserClass { return Number(this.user.GP); } - get bitfield() { - return this.user.bitfield as readonly BitField[]; - } - - perkTier(noCheckOtherAccounts?: boolean | undefined) { - return getUsersPerkTier(this, noCheckOtherAccounts); + perkTier() { + return getUsersPerkTier(this); } skillLevel(skill: xp_gains_skill_enum) { diff --git a/src/lib/combat_achievements/elite.ts b/src/lib/combat_achievements/elite.ts index 084a385769..480ef478b5 100644 --- a/src/lib/combat_achievements/elite.ts +++ b/src/lib/combat_achievements/elite.ts @@ -8,10 +8,10 @@ import { ZALCANO_ID, demonBaneWeapons } from '../constants'; -import { anyoneDiedInTOARaid } from '../simulation/toa'; import { SkillsEnum } from '../skilling/types'; import { Requirements } from '../structures/Requirements'; import type { ActivityTaskData, GauntletOptions, NightmareActivityTaskOptions, TOAOptions } from '../types/minions'; +import { anyoneDiedInTOARaid } from '../util'; import { isCertainMonsterTrip } from './caUtils'; import type { CombatAchievement } from './combatAchievements'; diff --git a/src/lib/combat_achievements/grandmaster.ts b/src/lib/combat_achievements/grandmaster.ts index 8736e99597..e0a689ed4e 100644 --- a/src/lib/combat_achievements/grandmaster.ts +++ b/src/lib/combat_achievements/grandmaster.ts @@ -2,7 +2,6 @@ import { Time } from 'e'; import { Monsters } from 'oldschooljs'; import { PHOSANI_NIGHTMARE_ID } from '../constants'; -import { anyoneDiedInTOARaid } from '../simulation/toa'; import { Requirements } from '../structures/Requirements'; import type { ActivityTaskData, @@ -13,6 +12,7 @@ import type { TOAOptions, TheatreOfBloodTaskOptions } from '../types/minions'; +import { anyoneDiedInTOARaid } from '../util'; import { isCertainMonsterTrip } from './caUtils'; import type { CombatAchievement } from './combatAchievements'; diff --git a/src/lib/combat_achievements/master.ts b/src/lib/combat_achievements/master.ts index 10947c56ff..9078a837fa 100644 --- a/src/lib/combat_achievements/master.ts +++ b/src/lib/combat_achievements/master.ts @@ -3,7 +3,6 @@ import { Monsters } from 'oldschooljs'; import { resolveItems } from 'oldschooljs/dist/util/util'; import { NEX_ID, NIGHTMARE_ID, PHOSANI_NIGHTMARE_ID } from '../constants'; -import { anyoneDiedInTOARaid } from '../simulation/toa'; import { Requirements } from '../structures/Requirements'; import type { ActivityTaskData, @@ -14,6 +13,7 @@ import type { TOAOptions, TheatreOfBloodTaskOptions } from '../types/minions'; +import { anyoneDiedInTOARaid } from '../util'; import { isCertainMonsterTrip } from './caUtils'; import type { CombatAchievement } from './combatAchievements'; diff --git a/src/lib/globals.ts b/src/lib/globals.ts index df04e5394f..f75e85a675 100644 --- a/src/lib/globals.ts +++ b/src/lib/globals.ts @@ -5,6 +5,7 @@ import { PrismaClient as RobochimpPrismaClient } from '@prisma/robochimp'; import { production } from '../config'; import { globalConfig } from './constants'; +import { handleDeletedPatron, handleEditPatron } from './patreonUtils'; declare global { var prisma: PrismaClient; @@ -44,3 +45,14 @@ function makeRedisClient(): TSRedis { return new TSRedis({ mocked: !globalConfig.redisPort, port: globalConfig.redisPort }); } global.redis = global.redis || makeRedisClient(); + +global.redis.subscribe(message => { + debugLog(`Received message from Redis: ${JSON.stringify(message)}`); + if (message.type === 'patron_tier_change') { + if (message.new_tier === 0) { + return handleDeletedPatron(message.discord_ids); + } else { + return handleEditPatron(message.discord_ids); + } + } +}); diff --git a/src/lib/grandExchange.ts b/src/lib/grandExchange.ts index dcb431a612..31f4a9c74e 100644 --- a/src/lib/grandExchange.ts +++ b/src/lib/grandExchange.ts @@ -110,6 +110,13 @@ class GrandExchangeSingleton { public locked = false; public isTicking = false; public ready = false; + public loggingEnabled = false; + + log(message: string, context?: any) { + if (this.loggingEnabled) { + debugLog(message, context); + } + } public config = { maxPricePerItem: ONE_TRILLION, @@ -260,8 +267,6 @@ class GrandExchangeSingleton { } }); - for (const tx of allActiveListingsInTimePeriod) sanityCheckTransaction(tx); - const item = getOSItem(geListing.item_id); const buyLimit = this.getItemBuyLimit(item); const totalSold = sumArr(allActiveListingsInTimePeriod.map(listing => listing.quantity_bought)); @@ -414,7 +419,8 @@ ${type} ${toKMB(quantity)} ${item.name} for ${toKMB(price)} each, for a total of ...makeTransactFromTableBankQueries({ bankToAdd: result.cost }) ]); - debugLog(`${user.id} created ${type} listing, removing ${result.cost}, adding it to the g.e bank.`); + sanityCheckListing(listing); + this.log(`${user.id} created ${type} listing, removing ${result.cost}, adding it to the g.e bank.`); return { createdListing: listing, @@ -524,7 +530,7 @@ ${type} ${toKMB(quantity)} ${item.name} for ${toKMB(price)} each, for a total of buyerLoot.add('Coins', buyerRefund); bankToRemoveFromGeBank.add('Coins', buyerRefund); - debugLog( + this.log( `Buyer got refunded ${buyerRefund} GP due to price difference. Buyer was asking ${buyerListing.asking_price_per_item}GP for each of the ${quantityToBuy}x items, seller was asking ${sellerListing.asking_price_per_item}GP, and the post-tax price per item was ${pricePerItemAfterTax}`, logContext ); @@ -552,11 +558,11 @@ ${type} ${toKMB(quantity)} ${item.name} for ${toKMB(price)} each, for a total of const missingItems = bankGEShouldHave.clone().remove(geBank); const str = `The GE did not have enough items to cover this transaction! We tried to remove ${bankGEShouldHave} missing: ${missingItems}. ${debug}`; logError(str, logContext); - debugLog(str, logContext); + this.log(str, logContext); throw new Error(str); } - debugLog( + this.log( `Completing a transaction, removing ${JSON.stringify(bankToRemoveFromGeBank.bank)} from the GE bank, ${totalTaxPaid} in taxed gp. The current GE bank is ${JSON.stringify(geBank.bank)}. ${debug}`, { totalPriceAfterTax, @@ -567,7 +573,7 @@ ${type} ${toKMB(quantity)} ${item.name} for ${toKMB(price)} each, for a total of } ); - await prisma.$transaction([ + const [newTx] = await prisma.$transaction([ prisma.gETransaction.create({ data: { buy_listing_id: buyerListing.id, @@ -617,7 +623,9 @@ ${type} ${toKMB(quantity)} ${item.name} for ${toKMB(price)} each, for a total of ...makeTransactFromTableBankQueries({ bankToRemove: bankToRemoveFromGeBank }) ]); - debugLog(`Transaction completed, the new G.E bank is ${JSON.stringify((await this.fetchOwnedBank()).bank)}.`); + sanityCheckTransaction(newTx); + + this.log(`Transaction completed, the new G.E bank is ${JSON.stringify((await this.fetchOwnedBank()).bank)}.`); const buyerUser = await mUserFetch(buyerListing.user_id); const sellerUser = await mUserFetch(sellerListing.user_id); @@ -767,11 +775,7 @@ ${type} ${toKMB(quantity)} ${item.name} for ${toKMB(price)} each, for a total of } async extensiveVerification() { - await Promise.all([ - prisma.gETransaction.findMany().then(txs => txs.map(tx => sanityCheckTransaction(tx))), - prisma.gEListing.findMany().then(listings => listings.map(listing => sanityCheckListing(listing))), - this.checkGECanFullFilAllListings() - ]); + await this.checkGECanFullFilAllListings(); return true; } @@ -788,7 +792,7 @@ ${type} ${toKMB(quantity)} ${item.name} for ${toKMB(price)} each, for a total of shouldHave.add(listing.item_id, listing.quantity_remaining); } - debugLog(`Expected G.E Bank: ${JSON.stringify(shouldHave.bank)}`); + this.log(`Expected G.E Bank: ${JSON.stringify(shouldHave.bank)}`); if (!currentBank.equals(shouldHave)) { if (!currentBank.has(shouldHave)) { throw new Error( @@ -803,7 +807,7 @@ G.E Bank Has: ${currentBank} G.E Bank Should Have: ${shouldHave} Difference: ${shouldHave.difference(currentBank)}`); } else { - debugLog('GE has enough to cover the listings.'); + this.log('GE has enough to cover the listings.'); return true; } } diff --git a/src/lib/patreonUtils.ts b/src/lib/patreonUtils.ts new file mode 100644 index 0000000000..934d1fa225 --- /dev/null +++ b/src/lib/patreonUtils.ts @@ -0,0 +1,54 @@ +import { BadgesEnum } from './constants'; +import { populateRoboChimpCache } from './roboChimp'; + +export async function handleDeletedPatron(userID: string[]) { + const users = await prisma.user.findMany({ + where: { + id: { + in: userID + } + } + }); + + for (const user of users) { + if (user.badges.includes(BadgesEnum.Patron) || user.badges.includes(BadgesEnum.LimitedPatron)) { + await prisma.user.update({ + where: { + id: user.id + }, + data: { + badges: user.badges.filter(b => b !== BadgesEnum.Patron && b !== BadgesEnum.LimitedPatron) + } + }); + } + } + + await populateRoboChimpCache(); +} + +export async function handleEditPatron(userID: string[]) { + const users = await prisma.user.findMany({ + where: { + id: { + in: userID + } + } + }); + + for (const user of users) { + if (!user.badges.includes(BadgesEnum.Patron) && !user.badges.includes(BadgesEnum.LimitedPatron)) { + await prisma.user.update({ + where: { + id: user.id + }, + data: { + badges: { + push: BadgesEnum.Patron + } + } + }); + } + } + + await populateRoboChimpCache(); +} diff --git a/src/lib/perkTiers.ts b/src/lib/perkTiers.ts index 6582178ce9..8488b2a049 100644 --- a/src/lib/perkTiers.ts +++ b/src/lib/perkTiers.ts @@ -1,18 +1,6 @@ -import type { User } from '@prisma/client'; -import { notEmpty } from 'e'; - import { SupportServer } from '../config'; import { BitField, PerkTier, Roles } from './constants'; -import { logError } from './util/logError'; - -export const perkTierCache = new Map(); - -const tier3ElligibleBits = [ - BitField.IsPatronTier3, - BitField.isContributor, - BitField.isModerator, - BitField.IsWikiContributor -]; +import { getPerkTierSync } from './roboChimp'; export const allPerkBitfields: BitField[] = [ BitField.IsPatronTier6, @@ -25,84 +13,56 @@ export const allPerkBitfields: BitField[] = [ BitField.BothBotsMaxedFreeTierOnePerks ]; -export function getUsersPerkTier( - userOrBitfield: MUser | User | BitField[], - noCheckOtherAccounts?: boolean -): PerkTier | 0 { - // Check if the user has a premium balance tier - if (userOrBitfield instanceof GlobalMUserClass && userOrBitfield.user.premium_balance_tier !== null) { - const date = userOrBitfield.user.premium_balance_expiry_date; - if (date && Date.now() < date) { - return userOrBitfield.user.premium_balance_tier + 1; - } else if (date && Date.now() > date) { - userOrBitfield - .update({ - premium_balance_tier: null, - premium_balance_expiry_date: null - }) - .catch(e => { - logError(e, { user_id: userOrBitfield.id, message: 'Could not remove premium time' }); - }); - } +export function getUsersPerkTier(user: MUser): PerkTier | 0 { + if ( + [BitField.isContributor, BitField.isModerator, BitField.IsWikiContributor].some(bit => + user.bitfield.includes(bit) + ) + ) { + return PerkTier.Four; } - if (noCheckOtherAccounts !== true && userOrBitfield instanceof GlobalMUserClass) { - const main = userOrBitfield.user.main_account; - const allAccounts: string[] = [...userOrBitfield.user.ironman_alts, userOrBitfield.id]; - if (main) { - allAccounts.push(main); + const elligibleTiers = []; + if ( + user.bitfield.includes(BitField.IsPatronTier1) || + user.bitfield.includes(BitField.HasPermanentTierOne) || + user.bitfield.includes(BitField.BothBotsMaxedFreeTierOnePerks) + ) { + elligibleTiers.push(PerkTier.Two); + } else { + const guild = globalClient.guilds.cache.get(SupportServer); + const member = guild?.members.cache.get(user.id); + if (member && [Roles.Booster].some(roleID => member.roles.cache.has(roleID))) { + elligibleTiers.push(PerkTier.One); } + } - const allAccountTiers = allAccounts.map(id => perkTierCache.get(id)).filter(notEmpty); - - const highestAccountTier = Math.max(0, ...allAccountTiers); - return highestAccountTier; + const cached = getPerkTierSync(user.id); + if (cached > 0) { + elligibleTiers.push(cached); } - const bitfield = Array.isArray(userOrBitfield) ? userOrBitfield : userOrBitfield.bitfield; + const bitfield = user.bitfield; if (bitfield.includes(BitField.IsPatronTier6)) { - return PerkTier.Seven; + return elligibleTiers.push(PerkTier.Seven); } if (bitfield.includes(BitField.IsPatronTier5)) { - return PerkTier.Six; + return elligibleTiers.push(PerkTier.Six); } if (bitfield.includes(BitField.IsPatronTier4)) { - return PerkTier.Five; + return elligibleTiers.push(PerkTier.Five); } - if (tier3ElligibleBits.some(bit => bitfield.includes(bit))) { - return PerkTier.Four; + if (bitfield.includes(BitField.IsPatronTier3)) { + return elligibleTiers.push(PerkTier.Four); } if (bitfield.includes(BitField.IsPatronTier2)) { - return PerkTier.Three; + return elligibleTiers.push(PerkTier.Three); } - if ( - bitfield.includes(BitField.IsPatronTier1) || - bitfield.includes(BitField.HasPermanentTierOne) || - bitfield.includes(BitField.BothBotsMaxedFreeTierOnePerks) - ) { - return PerkTier.Two; - } - - if (userOrBitfield instanceof GlobalMUserClass) { - const guild = globalClient.guilds.cache.get(SupportServer); - const member = guild?.members.cache.get(userOrBitfield.id); - if (member && [Roles.Booster].some(roleID => member.roles.cache.has(roleID))) { - return PerkTier.One; - } - } - - return 0; -} - -export function syncPerkTierOfUser(user: MUser) { - const perkTier = getUsersPerkTier(user, true); - perkTierCache.set(user.id, perkTier); - redis.setUser(user.id, { perk_tier: perkTier }); - return perkTier; + return Math.max(...elligibleTiers, 0); } diff --git a/src/lib/roboChimp.ts b/src/lib/roboChimp.ts index 8976663d91..4b6b1ea2fb 100644 --- a/src/lib/roboChimp.ts +++ b/src/lib/roboChimp.ts @@ -1,10 +1,12 @@ -import { formatOrdinal } from '@oldschoolgg/toolkit'; +import { PerkTier, formatOrdinal } from '@oldschoolgg/toolkit'; import type { TriviaQuestion, User } from '@prisma/robochimp'; import { calcWhatPercent, round, sumArr } from 'e'; import deepEqual from 'fast-deep-equal'; - +import { pick } from 'lodash'; import type { Bank } from 'oldschooljs'; -import { BOT_TYPE, globalConfig, masteryKey } from './constants'; + +import { SupportServer } from '../config'; +import { BOT_TYPE, BitField, Roles, globalConfig, masteryKey } from './constants'; import { getTotalCl } from './data/Collections'; import { calculateMastery } from './mastery'; import { MUserStats } from './structures/MUserStats'; @@ -73,7 +75,7 @@ export async function roboChimpSyncData(user: MUser, newCL?: Bank) { [masteryKey]: totalMastery } as const; - const newUser = await roboChimpClient.user.upsert({ + const newUser: RobochimpUser = await roboChimpClient.user.upsert({ where: { id: BigInt(user.id) }, @@ -83,6 +85,7 @@ export async function roboChimpSyncData(user: MUser, newCL?: Bank) { ...updateObj } }); + cacheRoboChimpUser(newUser); if (!deepEqual(newUser.store_bitfield, user.user.store_bitfield)) { await user.update({ store_bitfield: newUser.store_bitfield }); @@ -90,8 +93,8 @@ export async function roboChimpSyncData(user: MUser, newCL?: Bank) { return newUser; } -export async function roboChimpUserFetch(userID: string) { - const result = await roboChimpClient.user.upsert({ +export async function roboChimpUserFetch(userID: string): Promise { + const result: RobochimpUser = await roboChimpClient.user.upsert({ where: { id: BigInt(userID) }, @@ -101,6 +104,8 @@ export async function roboChimpUserFetch(userID: string) { update: {} }); + cacheRoboChimpUser(result); + return result; } @@ -113,3 +118,77 @@ WHERE osb_cl_percent >= (SELECT osb_cl_percent FROM public.user WHERE id = ${Big return formatOrdinal(clPercentRank); } + +const robochimpCachedKeys = [ + 'bits', + 'github_id', + 'patreon_id', + 'perk_tier', + 'main_account', + 'ironman_alts', + 'premium_balance_expiry_date', + 'premium_balance_tier' +] as const; +type CachedRoboChimpUser = Pick; + +export const roboChimpCache = new Map(); + +export async function populateRoboChimpCache() { + const users = await roboChimpClient.user.findMany({ + select: { + id: true, + bits: true, + github_id: true, + patreon_id: true, + perk_tier: true, + main_account: true, + ironman_alts: true, + premium_balance_expiry_date: true, + premium_balance_tier: true + }, + where: { + perk_tier: { + not: 0 + } + } + }); + for (const user of users) { + roboChimpCache.set(user.id.toString(), user); + } + debugLog(`Populated RoboChimp cache with ${users.length} users.`); +} + +function cacheRoboChimpUser(user: RobochimpUser) { + if (user.perk_tier === 0) return; + roboChimpCache.set(user.id.toString(), pick(user, robochimpCachedKeys)); +} + +export function getPerkTierSync(user: string | MUser) { + const elligibleTiers = []; + if (typeof user !== 'string') { + if ( + [BitField.isContributor, BitField.isModerator, BitField.IsWikiContributor].some(bit => + user.bitfield.includes(bit) + ) + ) { + return PerkTier.Four; + } + + if ( + user.bitfield.includes(BitField.IsPatronTier1) || + user.bitfield.includes(BitField.HasPermanentTierOne) || + user.bitfield.includes(BitField.BothBotsMaxedFreeTierOnePerks) + ) { + elligibleTiers.push(PerkTier.Two); + } else { + const guild = globalClient.guilds.cache.get(SupportServer); + const member = guild?.members.cache.get(user.id); + if (member && [Roles.Booster].some(roleID => member.roles.cache.has(roleID))) { + elligibleTiers.push(PerkTier.One); + } + } + } + + elligibleTiers.push(roboChimpCache.get(typeof user === 'string' ? user : user.id)?.perk_tier ?? 0); + return Math.max(...elligibleTiers); +} diff --git a/src/lib/settings/settings.ts b/src/lib/settings/settings.ts index d39047fa7a..3201d53122 100644 --- a/src/lib/settings/settings.ts +++ b/src/lib/settings/settings.ts @@ -9,11 +9,12 @@ import type { User } from 'discord.js'; +import { isEmpty } from 'lodash'; import { postCommand } from '../../mahoji/lib/postCommand'; import { preCommand } from '../../mahoji/lib/preCommand'; import { convertMahojiCommandToAbstractCommand } from '../../mahoji/lib/util'; import { minionActivityCache } from '../constants'; -import { channelIsSendable, isGroupActivity, roughMergeMahojiResponse } from '../util'; +import { channelIsSendable, isGroupActivity } from '../util'; import { deferInteraction, handleInteractionError, interactionReply } from '../util/interactionReply'; import { logError } from '../util/logError'; import { convertStoredActivityToFlatActivity } from './prisma'; @@ -111,10 +112,15 @@ export async function runCommand({ ephemeral }: RunCommandArgs): Promise { await deferInteraction(interaction); - const channel = globalClient.channels.cache.get(channelID.toString()); - if (!channel || !channelIsSendable(channel)) return null; + const channel = globalClient.channels.cache.get(channelID); const mahojiCommand = Array.from(globalClient.mahojiClient.commands.values()).find(c => c.name === commandName); - if (!mahojiCommand) throw new Error('No command found'); + if (!mahojiCommand || !channelIsSendable(channel)) { + await interactionReply(interaction, { + content: 'There was an error repeating your trip, I cannot find the channel you used the command in.', + ephemeral: true + }); + return null; + } const abstractCommand = convertMahojiCommandToAbstractCommand(mahojiCommand); const error: Error | null = null; @@ -132,19 +138,19 @@ export async function runCommand({ if (inhibitedReason) { inhibited = true; - if (inhibitedReason.silent) return null; + let response = + typeof inhibitedReason.reason! === 'string' ? inhibitedReason.reason : inhibitedReason.reason?.content!; + if (isEmpty(response)) { + response = 'You cannot use this command right now.'; + } await interactionReply(interaction, { - content: - typeof inhibitedReason.reason! === 'string' - ? inhibitedReason.reason - : inhibitedReason.reason?.content!, + content: response, ephemeral: true }); return null; } - if (Array.isArray(args)) throw new Error(`Had array of args for mahoji command called ${commandName}`); const result = await runMahojiCommand({ options: args, commandName, @@ -155,8 +161,14 @@ export async function runCommand({ user, interaction }); - if (result && !interaction.replied) - await interactionReply(interaction, roughMergeMahojiResponse(result, { ephemeral })); + if (result && !interaction.replied) { + await interactionReply( + interaction, + typeof result === 'string' + ? { content: result, ephemeral: ephemeral } + : { ...result, ephemeral: ephemeral } + ); + } return result; } catch (err: any) { await handleInteractionError(err, interaction); diff --git a/src/lib/simulation/toa.ts b/src/lib/simulation/toa.ts index 5ac57423fe..b5f0249a5f 100644 --- a/src/lib/simulation/toa.ts +++ b/src/lib/simulation/toa.ts @@ -1637,21 +1637,3 @@ ${calculateBoostString(user)} return channelID === '1069176960523190292' ? { content: str, ephemeral: true } : str; } - -export function normalizeTOAUsers(data: TOAOptions) { - const _detailedUsers = data.detailedUsers; - const detailedUsers = ( - (Array.isArray(_detailedUsers[0]) ? _detailedUsers : [_detailedUsers]) as [string, number, number[]][][] - ).map(userArr => - userArr.map(user => ({ - id: user[0], - points: user[1], - deaths: user[2] - })) - ); - return detailedUsers; -} - -export function anyoneDiedInTOARaid(data: TOAOptions) { - return normalizeTOAUsers(data).some(userArr => userArr.some(user => user.deaths.length > 0)); -} diff --git a/src/lib/util.ts b/src/lib/util.ts index c5251f077f..0f0ab2af7e 100644 --- a/src/lib/util.ts +++ b/src/lib/util.ts @@ -1,4 +1,4 @@ -import { Stopwatch, cleanUsername, stripEmojis } from '@oldschoolgg/toolkit'; +import { Stopwatch, stripEmojis } from '@oldschoolgg/toolkit'; import type { CommandResponse } from '@oldschoolgg/toolkit'; import type { BaseMessageOptions, @@ -35,6 +35,7 @@ import type { GroupMonsterActivityTaskOptions, NexTaskOptions, RaidsOptions, + TOAOptions, TheatreOfBloodTaskOptions } from './types/minions'; import { getItem } from './util/getOSItem'; @@ -333,20 +334,21 @@ export function skillingPetDropRate( const badgesKey = `${BOT_TYPE_LOWERCASE.toLowerCase()}_badges` as 'osb_badges' | 'bso_badges'; const usernameWithBadgesCache = new LRUCache({ max: 2000 }); -export function cacheUsername(id: string, username: string, badges: string) { - const current = usernameWithBadgesCache.get(id); - const newValue = `${badges ? `${badges} ` : ''}${username}`; - if (!current || current !== newValue) { - usernameWithBadgesCache.set(id, newValue); - redis.setUser(id, { username: cleanUsername(username), [badgesKey]: badges }); - } -} + export async function getUsername(_id: string | bigint): Promise { const id = _id.toString(); const cached = usernameWithBadgesCache.get(id); if (cached) return cached; - const user = await redis.getUser(id); - if (!user.username) return 'Unknown'; + const user = await prisma.user.findFirst({ + where: { + id + }, + select: { + username: true, + [badgesKey]: true + } + }); + if (!user?.username) return 'Unknown'; const badges = user[badgesKey]; const newValue = `${badges ? `${badges} ` : ''}${user.username}`; usernameWithBadgesCache.set(id, newValue); @@ -420,3 +422,20 @@ export function checkRangeGearWeapon(gear: Gear) { ammo }; } +export function normalizeTOAUsers(data: TOAOptions) { + const _detailedUsers = data.detailedUsers; + const detailedUsers = ( + (Array.isArray(_detailedUsers[0]) ? _detailedUsers : [_detailedUsers]) as [string, number, number[]][][] + ).map(userArr => + userArr.map(user => ({ + id: user[0], + points: user[1], + deaths: user[2] + })) + ); + return detailedUsers; +} + +export function anyoneDiedInTOARaid(data: TOAOptions) { + return normalizeTOAUsers(data).some(userArr => userArr.some(user => user.deaths.length > 0)); +} diff --git a/src/lib/util/globalInteractions.ts b/src/lib/util/globalInteractions.ts index e318b2ee88..7fad4d2234 100644 --- a/src/lib/util/globalInteractions.ts +++ b/src/lib/util/globalInteractions.ts @@ -4,13 +4,13 @@ import { ButtonBuilder, ButtonStyle } from 'discord.js'; import { Time, removeFromArr, uniqueArr } from 'e'; import { Bank } from 'oldschooljs'; -import { Cooldowns } from '../../mahoji/lib/Cooldowns'; import { cancelGEListingCommand } from '../../mahoji/lib/abstracted_commands/cancelGEListingCommand'; import { autoContract } from '../../mahoji/lib/abstracted_commands/farmingContractCommand'; import { shootingStarsCommand, starCache } from '../../mahoji/lib/abstracted_commands/shootingStarsCommand'; import type { ClueTier } from '../clues/clueTiers'; import { BitField, PerkTier } from '../constants'; +import { RateLimitManager } from '@sapphire/ratelimits'; import { InteractionID } from '../InteractionID'; import { runCommand } from '../settings/settings'; import { toaHelpCommand } from '../simulation/toa'; @@ -55,6 +55,8 @@ function isValidGlobalInteraction(str: string): str is GlobalInteractionAction { return globalInteractionActions.includes(str as GlobalInteractionAction); } +const buttonRatelimiter = new RateLimitManager(Time.Second * 5, 2); + export function makeOpenCasketButton(tier: ClueTier) { const name: Uppercase = tier.name.toUpperCase() as Uppercase; const id: GlobalInteractionAction = `OPEN_${name}_CASKET`; @@ -222,14 +224,17 @@ async function giveawayButtonHandler(user: MUser, customID: string, interaction: } async function repeatTripHandler(user: MUser, interaction: ButtonInteraction) { - if (user.minionIsBusy) return interactionReply(interaction, { content: 'Your minion is busy.' }); + if (user.minionIsBusy) return interactionReply(interaction, { content: 'Your minion is busy.', ephemeral: true }); const trips = await fetchRepeatTrips(interaction.user.id); - if (trips.length === 0) + if (trips.length === 0) { return interactionReply(interaction, { content: "Couldn't find a trip to repeat.", ephemeral: true }); + } const id = interaction.customId; const split = id.split('_'); const matchingActivity = trips.find(i => i.type === split[2]); - if (!matchingActivity) return repeatTrip(interaction, trips[0]); + if (!matchingActivity) { + return repeatTrip(interaction, trips[0]); + } return repeatTrip(interaction, matchingActivity); } @@ -331,10 +336,10 @@ export async function interactionHook(interaction: Interaction) { const id = interaction.customId; const userID = interaction.user.id; - const cd = Cooldowns.get(userID, 'button', Time.Second * 3); - if (cd !== null) { + const ratelimit = buttonRatelimiter.acquire(userID); + if (ratelimit.limited) { return interactionReply(interaction, { - content: `You're on cooldown from clicking buttons, please wait: ${formatDuration(cd, true)}.`, + content: `You're on cooldown from clicking buttons, please wait: ${formatDuration(ratelimit.remainingTime, true)}.`, ephemeral: true }); } diff --git a/src/lib/util/linkedAccountsUtil.ts b/src/lib/util/linkedAccountsUtil.ts deleted file mode 100644 index 7542c65a36..0000000000 --- a/src/lib/util/linkedAccountsUtil.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { User } from '@prisma/client'; - -import { mahojiUsersSettingsFetch } from '../../mahoji/mahojiSettings'; -import { MUserClass } from '../MUser'; - -async function syncLinkedAccountPerks(user: MUser) { - const main = user.user.main_account; - const allAccounts: string[] = [...user.user.ironman_alts]; - if (main) { - allAccounts.push(main); - } - const allUsers = await Promise.all( - allAccounts.map(a => - mahojiUsersSettingsFetch(a, { - id: true, - premium_balance_tier: true, - premium_balance_expiry_date: true, - bitfield: true, - ironman_alts: true, - main_account: true, - minion_ironman: true - }) - ) - ); - allUsers.map(u => new MUserClass(u as User)); -} - -export async function syncLinkedAccounts() { - const users = await prisma.user.findMany({ - where: { - ironman_alts: { - isEmpty: false - } - }, - select: { - id: true, - ironman_alts: true, - premium_balance_tier: true, - premium_balance_expiry_date: true, - bitfield: true - } - }); - for (const u of users) { - const mUser = new MUserClass(u as User); - await syncLinkedAccountPerks(mUser); - } -} diff --git a/src/lib/util/repeatStoredTrip.ts b/src/lib/util/repeatStoredTrip.ts index 85ed69c2b7..f83374e8f0 100644 --- a/src/lib/util/repeatStoredTrip.ts +++ b/src/lib/util/repeatStoredTrip.ts @@ -63,7 +63,7 @@ import { itemNameFromID } from '../util'; import { giantsFoundryAlloys } from './../../mahoji/lib/abstracted_commands/giantsFoundryCommand'; import type { NightmareZoneActivityTaskOptions, UnderwaterAgilityThievingTaskOptions } from './../types/minions'; import getOSItem from './getOSItem'; -import { deferInteraction, interactionReply } from './interactionReply'; +import { interactionReply } from './interactionReply'; const taskCanBeRepeated = (activity: Activity) => { if (activity.type === activity_type_enum.ClueCompletion) { @@ -696,7 +696,6 @@ export async function repeatTrip( if (!data || !data.data || !data.type) { return interactionReply(interaction, { content: "Couldn't find any trip to repeat.", ephemeral: true }); } - await deferInteraction(interaction); const handler = tripHandlers[data.type]; return runCommand({ commandName: handler.commandName, diff --git a/src/lib/util/syncDisabledCommands.ts b/src/lib/util/syncDisabledCommands.ts new file mode 100644 index 0000000000..bb92a347b1 --- /dev/null +++ b/src/lib/util/syncDisabledCommands.ts @@ -0,0 +1,20 @@ +import { DISABLED_COMMANDS, globalConfig } from '../constants'; + +export async function syncDisabledCommands() { + const disabledCommands = await prisma.clientStorage.upsert({ + where: { + id: globalConfig.clientID + }, + select: { disabled_commands: true }, + create: { + id: globalConfig.clientID + }, + update: {} + }); + + if (disabledCommands.disabled_commands) { + for (const command of disabledCommands.disabled_commands) { + DISABLED_COMMANDS.add(command); + } + } +} diff --git a/src/mahoji/commands/leaderboard.ts b/src/mahoji/commands/leaderboard.ts index c07926510b..f1c2e39cd4 100644 --- a/src/mahoji/commands/leaderboard.ts +++ b/src/mahoji/commands/leaderboard.ts @@ -140,7 +140,7 @@ async function kcLb( ${ironmanOnly ? 'INNER JOIN "users" on "users"."id" = "user_stats"."user_id"::text' : ''} WHERE CAST("monster_scores"->>'${monster.id}' AS INTEGER) > 5 ${ironmanOnly ? ' AND "users"."minion.ironman" = true ' : ''} - ORDER BY kc DESC + ORDER BY score DESC LIMIT 2000;` ); diff --git a/src/mahoji/commands/minion.ts b/src/mahoji/commands/minion.ts index ef993a5d43..10df58df98 100644 --- a/src/mahoji/commands/minion.ts +++ b/src/mahoji/commands/minion.ts @@ -22,12 +22,12 @@ import type { AttackStyles } from '../../lib/minions/functions'; import { blowpipeCommand, blowpipeDarts } from '../../lib/minions/functions/blowpipeCommand'; import { degradeableItemsCommand } from '../../lib/minions/functions/degradeableItemsCommand'; import { allPossibleStyles, trainCommand } from '../../lib/minions/functions/trainCommand'; -import { roboChimpUserFetch } from '../../lib/roboChimp'; +import { roboChimpCache, roboChimpUserFetch } from '../../lib/roboChimp'; import { Minigames } from '../../lib/settings/minigames'; import Skills from '../../lib/skilling/skills'; import creatures from '../../lib/skilling/skills/hunter/creatures'; import { MUserStats } from '../../lib/structures/MUserStats'; -import { convertLVLtoXP, getUsername, isValidNickname } from '../../lib/util'; +import { convertLVLtoXP, isValidNickname } from '../../lib/util'; import { getKCByName } from '../../lib/util/getKCByName'; import getOSItem, { getItem } from '../../lib/util/getOSItem'; import { handleMahojiConfirmation } from '../../lib/util/handleMahojiConfirmation'; @@ -81,21 +81,10 @@ export async function getUserInfo(user: MUser) { const task = minionActivityCache.get(user.id); const taskText = task ? `${task.type}` : 'None'; - const premiumDate = Number(user.user.premium_balance_expiry_date); - const premiumTier = user.user.premium_balance_tier; - const result = { perkTier: user.perkTier(), isBlacklisted: BLACKLISTED_USERS.has(user.id), badges: user.badgesString, - mainAccount: - user.user.main_account !== null - ? `${user.user.username ?? 'Unknown Username'}[${user.user.main_account}]` - : 'None', - ironmanAlts: user.user.ironman_alts.map(async id => `${await getUsername(id)}[${id}]`), - premiumBalance: `${premiumDate ? new Date(premiumDate).toLocaleString() : ''} ${ - premiumTier ? `Tier ${premiumTier}` : '' - }`, isIronman: user.isIronman, bitfields, currentTask: taskText, @@ -107,16 +96,14 @@ export async function getUserInfo(user: MUser) { 2 ); + const roboCache = await roboChimpCache.get(user.id); return { ...result, everythingString: `${user.badgedUsername}[${user.id}] **Current Trip:** ${taskText} -**Perk Tier:** ${result.perkTier} +**Perk Tier:** ${roboCache?.perk_tier ?? 'None'} **Blacklisted:** ${result.isBlacklisted} **Badges:** ${result.badges} -**Main Account:** ${result.mainAccount} -**Ironman Alts:** ${result.ironmanAlts} -**Patron Balance:** ${result.premiumBalance} **Ironman:** ${result.isIronman} **Bitfields:** ${result.bitfields} **Patreon Connected:** ${result.patreon} diff --git a/src/mahoji/commands/rp.ts b/src/mahoji/commands/rp.ts index c000ca9c45..cc6aa846b2 100644 --- a/src/mahoji/commands/rp.ts +++ b/src/mahoji/commands/rp.ts @@ -20,7 +20,6 @@ import { GrandExchange } from '../../lib/grandExchange'; import { marketPricemap } from '../../lib/marketPrices'; import { unEquipAllCommand } from '../../lib/minions/functions/unequipAllCommand'; import { unequipPet } from '../../lib/minions/functions/unequipPet'; -import { allPerkBitfields } from '../../lib/perkTiers'; import { premiumPatronTime } from '../../lib/premiumPatronTime'; import { TeamLoot } from '../../lib/simulation/TeamLoot'; @@ -31,8 +30,6 @@ import getOSItem from '../../lib/util/getOSItem'; import { handleMahojiConfirmation } from '../../lib/util/handleMahojiConfirmation'; import { deferInteraction } from '../../lib/util/interactionReply'; import itemIsTradeable from '../../lib/util/itemIsTradeable'; -import { syncLinkedAccounts } from '../../lib/util/linkedAccountsUtil'; -import { makeBadgeString } from '../../lib/util/makeBadgeString'; import { makeBankImage } from '../../lib/util/makeBankImage'; import { migrateUser } from '../../lib/util/migrateUser'; import { parseBank } from '../../lib/util/parseStringBank'; @@ -53,7 +50,7 @@ const itemFilters = [ } ]; -async function redisSync() { +async function usernameSync() { const roboChimpUsersToCache = ( await roboChimpClient.user.findMany({ where: { @@ -136,18 +133,7 @@ async function redisSync() { } }); - for (const user of allNewUsers) { - redis.setUser(user.id, { username: user.username }); - } response.push(`Cached ${allNewUsers.length} usernames.`); - - const arrayOfIronmenAndBadges: { badges: number[]; id: string; ironman: boolean }[] = await prisma.$queryRawUnsafe( - 'SELECT "badges", "id", "minion.ironman" as "ironman" FROM users WHERE ARRAY_LENGTH(badges, 1) > 0 OR "minion.ironman" = true;' - ); - for (const user of arrayOfIronmenAndBadges) { - redis.setUser(user.id, { osb_badges: makeBadgeString(user.badges, user.ironman) }); - } - response.push(`Cached ${arrayOfIronmenAndBadges.length} badges.`); return response.join(', '); } @@ -671,7 +657,7 @@ Date: ${dateFm(date)}`; return 'Finished.'; } if (options.action?.redis_sync) { - const result = await redisSync(); + const result = await usernameSync(); return result; } if (options.action?.networth_sync) { @@ -707,20 +693,6 @@ ORDER BY item_id ASC;`); return returnStringOrFile(`[${result.map(i => i.item_id).join(',')}]`); } - if (options.action?.patreon_reset) { - const bitfieldsToRemove = [ - BitField.IsPatronTier1, - BitField.IsPatronTier2, - BitField.IsPatronTier3, - BitField.IsPatronTier4, - BitField.IsPatronTier5, - BitField.IsPatronTier6 - ]; - await prisma.$queryRaw`UPDATE users SET bitfield = bitfield - '{${bitfieldsToRemove.join(',')}'::int[];`; - await syncLinkedAccounts(); - return 'Finished.'; - } - if (options.player?.set_buy_date) { const userToCheck = await mUserFetch(options.player.set_buy_date.user.user.id); const res = SnowflakeUtil.deconstruct(options.player.set_buy_date.message_id); @@ -928,12 +900,6 @@ ORDER BY item_id ASC;`); const destUser = await mUserFetch(dest.user.id); if (isProtectedAccount(destUser)) return 'You cannot clobber that account.'; - if (allPerkBitfields.some(pt => destUser.bitfield.includes(pt))) { - await handleMahojiConfirmation( - interaction, - `The target user, ${destUser.logName}, has a Patreon Tier; are you really sure you want to DELETE all data from that account?` - ); - } const sourceXp = sumArr(Object.values(sourceUser.skillsAsXP)); const destXp = sumArr(Object.values(destUser.skillsAsXP)); if (destXp > sourceXp) { diff --git a/src/mahoji/lib/abstracted_commands/ironmanCommand.ts b/src/mahoji/lib/abstracted_commands/ironmanCommand.ts index 49d92228b2..2855556e3b 100644 --- a/src/mahoji/lib/abstracted_commands/ironmanCommand.ts +++ b/src/mahoji/lib/abstracted_commands/ironmanCommand.ts @@ -95,13 +95,9 @@ After becoming an ironman: const mUser = (await mUserFetch(user.id)).user; type KeysThatArentReset = - | 'ironman_alts' - | 'main_account' | 'bank_bg_hex' | 'bank_sort_weightings' | 'bank_sort_method' - | 'premium_balance_expiry_date' - | 'premium_balance_tier' | 'minion_bought_date' | 'id' | 'pets' @@ -127,15 +123,11 @@ After becoming an ironman: const createOptions: Required> = { id: user.id, - main_account: mUser.main_account, - ironman_alts: mUser.ironman_alts, bank_bg_hex: mUser.bank_bg_hex, bank_sort_method: mUser.bank_sort_method, bank_sort_weightings: mUser.bank_sort_weightings as ItemBank, minion_bought_date: mUser.minion_bought_date, RSN: mUser.RSN, - premium_balance_expiry_date: mUser.premium_balance_expiry_date, - premium_balance_tier: mUser.premium_balance_tier, pets: mUser.pets as ItemBank, bitfield: bitFieldsToKeep.filter(i => user.bitfield.includes(i)) }; diff --git a/src/mahoji/lib/abstracted_commands/minionKill.ts b/src/mahoji/lib/abstracted_commands/minionKill.ts index 10df8f822b..d1104807b5 100644 --- a/src/mahoji/lib/abstracted_commands/minionKill.ts +++ b/src/mahoji/lib/abstracted_commands/minionKill.ts @@ -137,7 +137,7 @@ export async function minionKillCommand( method: PvMMethod | undefined, wilderness: boolean | undefined, solo: boolean | undefined -) { +): Promise { if (user.minionIsBusy) { return 'Your minion is busy.'; } @@ -223,9 +223,10 @@ export async function minionKillCommand( wildyBurst }); - // Check requirements const [hasReqs, reason] = hasMonsterRequirements(user, monster); - if (!hasReqs) return reason ?? "You don't have the requirements to fight this monster"; + if (!hasReqs) { + return typeof reason === 'string' ? reason : "You don't have the requirements to fight this monster"; + } if (monster.diaryRequirement) { const [hasDiary, _, diaryGroup] = await userhasDiaryTier(user, monster.diaryRequirement); diff --git a/src/mahoji/lib/events.ts b/src/mahoji/lib/events.ts index 478d38c865..5673359333 100644 --- a/src/mahoji/lib/events.ts +++ b/src/mahoji/lib/events.ts @@ -2,17 +2,12 @@ import type { ItemBank } from 'oldschooljs/dist/meta/types'; import { bulkUpdateCommands } from '@oldschoolgg/toolkit'; import { production } from '../../config'; -import { cacheBadges } from '../../lib/badges'; -import { syncBlacklists } from '../../lib/blacklists'; -import { Channel, DISABLED_COMMANDS, META_CONSTANTS, globalConfig } from '../../lib/constants'; +import { Channel, META_CONSTANTS, globalConfig } from '../../lib/constants'; import { initCrons } from '../../lib/crons'; -import { GrandExchange } from '../../lib/grandExchange'; import { initTickers } from '../../lib/tickers'; -import { runTimedLoggedFn } from '../../lib/util'; import { cacheCleanup } from '../../lib/util/cachedUserIDs'; import { mahojiClientSettingsFetch } from '../../lib/util/clientSettings'; -import { syncLinkedAccounts } from '../../lib/util/linkedAccountsUtil'; import { sendToChannelID } from '../../lib/util/webhook'; import { CUSTOM_PRICE_CACHE } from '../commands/sell'; @@ -23,28 +18,8 @@ export async function syncCustomPrices() { } } -async function syncDisabledCommands() { - const disabledCommands = await prisma.clientStorage.upsert({ - where: { - id: globalConfig.clientID - }, - select: { disabled_commands: true }, - create: { - id: globalConfig.clientID - }, - update: {} - }); - - if (disabledCommands.disabled_commands) { - for (const command of disabledCommands.disabled_commands) { - DISABLED_COMMANDS.add(command); - } - } -} - export async function onStartup() { globalClient.application.commands.fetch({ guildId: production ? undefined : globalConfig.testingServerID }); - if (!production) { console.log('Syncing commands locally...'); await bulkUpdateCommands({ @@ -54,18 +29,8 @@ export async function onStartup() { }); } - runTimedLoggedFn('Sync Disabled Commands', syncDisabledCommands); - - runTimedLoggedFn('Sync Blacklist', syncBlacklists); - - runTimedLoggedFn('Syncing prices', syncCustomPrices); - - runTimedLoggedFn('Caching badges', cacheBadges); cacheCleanup(); - runTimedLoggedFn('Sync Linked Accounts', syncLinkedAccounts); - runTimedLoggedFn('Init Grand Exchange', GrandExchange.init.bind(GrandExchange)); - initCrons(); initTickers(); diff --git a/src/mahoji/lib/inhibitors.ts b/src/mahoji/lib/inhibitors.ts index 1a6b67ff3f..e781587709 100644 --- a/src/mahoji/lib/inhibitors.ts +++ b/src/mahoji/lib/inhibitors.ts @@ -1,12 +1,11 @@ +import { PerkTier, formatDuration } from '@oldschoolgg/toolkit'; import type { DMChannel, Guild, GuildMember, InteractionReplyOptions, TextChannel, User } from 'discord.js'; import { ComponentType, PermissionsBitField } from 'discord.js'; -import { PerkTier } from '@oldschoolgg/toolkit'; -import { formatDuration } from '@oldschoolgg/toolkit'; import { OWNER_IDS, SupportServer } from '../../config'; import { BLACKLISTED_GUILDS, BLACKLISTED_USERS } from '../../lib/blacklists'; import { BadgesEnum, BitField, Channel, DISABLED_COMMANDS, minionBuyButton } from '../../lib/constants'; -import { perkTierCache, syncPerkTierOfUser } from '../../lib/perkTiers'; +import { getPerkTierSync } from '../../lib/roboChimp'; import type { CategoryFlag } from '../../lib/types'; import { minionIsBusy } from '../../lib/util/minionIsBusy'; import { mahojiGuildSettingsFetch, untrustedGuildSettingsCache } from '../guildSettings'; @@ -143,11 +142,7 @@ const inhibitors: Inhibitor[] = [ run: async ({ member, guild, channel, user }) => { if (!guild || guild.id !== SupportServer) return false; if (channel.id !== Channel.General) return false; - - let perkTier = perkTierCache.get(user.id); - if (!perkTier) { - perkTier = syncPerkTierOfUser(user); - } + const perkTier = getPerkTierSync(user.id); if (member && perkTier >= PerkTier.Two) { return false; } diff --git a/src/mahoji/lib/preCommand.ts b/src/mahoji/lib/preCommand.ts index bc5895b79f..4cebf8e6e1 100644 --- a/src/mahoji/lib/preCommand.ts +++ b/src/mahoji/lib/preCommand.ts @@ -27,14 +27,12 @@ export async function preCommand({ | undefined | { reason: InteractionReplyOptions; - silent: boolean; dontRunPostCommand?: boolean; } > { CACHED_ACTIVE_USER_IDS.add(userID); if (globalClient.isShuttingDown) { return { - silent: true, reason: { content: 'The bot is currently restarting, please try again later.' }, dontRunPostCommand: true }; @@ -47,7 +45,7 @@ export async function preCommand({ user.checkBankBackground(); if (userIsBusy(userID) && !bypassInhibitors && !busyImmuneCommands.includes(abstractCommand.name)) { - return { silent: true, reason: { content: 'You cannot use a command right now.' }, dontRunPostCommand: true }; + return { reason: { content: 'You cannot use a command right now.' }, dontRunPostCommand: true }; } if (!busyImmuneCommands.includes(abstractCommand.name)) modifyBusyCounter(userID, 1); diff --git a/src/tasks/minions/minigames/toaActivity.ts b/src/tasks/minions/minigames/toaActivity.ts index ba0fb0ab86..fe502a03f2 100644 --- a/src/tasks/minions/minigames/toaActivity.ts +++ b/src/tasks/minions/minigames/toaActivity.ts @@ -11,14 +11,9 @@ import { toaCL } from '../../../lib/data/CollectionsExport'; import { trackLoot } from '../../../lib/lootTrack'; import { getMinigameScore, incrementMinigameScore } from '../../../lib/settings/settings'; import { TeamLoot } from '../../../lib/simulation/TeamLoot'; -import { - calcTOALoot, - calculateXPFromRaid, - normalizeTOAUsers, - toaOrnamentKits, - toaPetTransmogItems -} from '../../../lib/simulation/toa'; +import { calcTOALoot, calculateXPFromRaid, toaOrnamentKits, toaPetTransmogItems } from '../../../lib/simulation/toa'; import type { TOAOptions } from '../../../lib/types/minions'; +import { normalizeTOAUsers } from '../../../lib/util'; import { handleTripFinish } from '../../../lib/util/handleTripFinish'; import { assert } from '../../../lib/util/logError'; import { updateBankSetting } from '../../../lib/util/updateBankSetting'; diff --git a/tests/integration/leaderboard.test.ts b/tests/integration/leaderboard.test.ts new file mode 100644 index 0000000000..d4445932ac --- /dev/null +++ b/tests/integration/leaderboard.test.ts @@ -0,0 +1,15 @@ +import { describe, test } from 'vitest'; + +import { leaderboardCommand } from '../../src/mahoji/commands/leaderboard'; +import { createTestUser } from './util'; + +describe('Leaderboard', async () => { + test('KC Leaderboard', async () => { + const user = await createTestUser(); + await user.runCommand(leaderboardCommand, { + kc: { + monster: 'man' + } + }); + }); +}); diff --git a/tests/integration/redis.test.ts b/tests/integration/redis.test.ts new file mode 100644 index 0000000000..1bb270439c --- /dev/null +++ b/tests/integration/redis.test.ts @@ -0,0 +1,103 @@ +import { expect, test } from 'vitest'; + +import { TSRedis } from '@oldschoolgg/toolkit/TSRedis'; +import { sleep } from 'e'; +import { BadgesEnum, BitField, globalConfig } from '../../src/lib/constants'; +import { getUsersPerkTier } from '../../src/lib/perkTiers'; +import { getPerkTierSync, roboChimpCache } from '../../src/lib/roboChimp'; +import { createTestUser } from './util'; + +function makeSender() { + return new TSRedis({ mocked: !globalConfig.redisPort, port: globalConfig.redisPort }); +} + +test.concurrent('Should add patron badge', async () => { + const user = await createTestUser(); + expect(user.user.badges).not.includes(BadgesEnum.Patron); + const _redis = makeSender(); + _redis.publish({ + type: 'patron_tier_change', + discord_ids: [user.id], + new_tier: 1, + old_tier: 0, + first_time_patron: false + }); + await sleep(250); + await user.sync(); + expect(user.user.badges).includes(BadgesEnum.Patron); +}); + +test.concurrent('Should remove patron badge', async () => { + const user = await createTestUser(undefined, { badges: [BadgesEnum.Patron] }); + expect(user.user.badges).includes(BadgesEnum.Patron); + const _redis = makeSender(); + _redis.publish({ + type: 'patron_tier_change', + discord_ids: [user.id], + new_tier: 0, + old_tier: 1, + first_time_patron: false + }); + await sleep(250); + await user.sync(); + expect(user.user.badges).not.includes(BadgesEnum.Patron); +}); + +test.concurrent('Should add to cache', async () => { + const users = [await createTestUser(), await createTestUser(), await createTestUser()]; + await roboChimpClient.user.createMany({ + data: users.map(u => ({ + id: BigInt(u.id), + perk_tier: 5 + })) + }); + const _redis = makeSender(); + _redis.publish({ + type: 'patron_tier_change', + discord_ids: users.map(u => u.id), + new_tier: 5, + old_tier: 2, + first_time_patron: false + }); + await sleep(250); + for (const user of users) { + const cached = roboChimpCache.get(user.id); + expect(getPerkTierSync(user.id)).toEqual(5); + expect(cached!.perk_tier).toEqual(5); + } +}); + +test.concurrent('Should remove from cache', async () => { + const users = [await createTestUser(), await createTestUser(), await createTestUser()]; + await roboChimpClient.user.createMany({ + data: users.map(u => ({ + id: BigInt(u.id), + perk_tier: 0 + })) + }); + const _redis = makeSender(); + _redis.publish({ + type: 'patron_tier_change', + discord_ids: users.map(u => u.id), + new_tier: 0, + old_tier: 5, + first_time_patron: false + }); + await sleep(250); + for (const user of users) { + expect(getPerkTierSync(user.id)).toEqual(0); + const cached = roboChimpCache.get(user.id); + expect(cached).toEqual(undefined); + } +}); + +test.concurrent('Should recognize special bitfields', async () => { + const users = [ + await createTestUser(undefined, { bitfield: [BitField.HasPermanentTierOne] }), + await createTestUser(undefined, { bitfield: [BitField.BothBotsMaxedFreeTierOnePerks] }) + ]; + for (const user of users) { + expect(getUsersPerkTier(user)).toEqual(2); + expect(getPerkTierSync(user)).toEqual(2); + } +}); diff --git a/tests/unit/getUsersPerkTier.test.ts b/tests/unit/getUsersPerkTier.test.ts deleted file mode 100644 index 220df522cc..0000000000 --- a/tests/unit/getUsersPerkTier.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Time } from 'e'; -import { describe, expect, test } from 'vitest'; - -import { MUserClass } from '../../src/lib/MUser'; -import { BitField, PerkTier } from '../../src/lib/constants'; -import { getUsersPerkTier } from '../../src/lib/perkTiers'; -import { mockMUser } from './utils'; - -describe('getUsersPerkTier', () => { - test('general', () => { - expect(getUsersPerkTier([])).toEqual(0); - expect(getUsersPerkTier(mockMUser())).toEqual(0); - expect(getUsersPerkTier(mockMUser({ bitfield: [BitField.IsPatronTier3] }))).toEqual(PerkTier.Four); - expect(getUsersPerkTier(mockMUser({ bitfield: [BitField.isModerator] }))).toEqual(PerkTier.Four); - }); - test('balance', () => { - const user = mockMUser({ premium_balance_expiry_date: Date.now() + Time.Day, premium_balance_tier: 3 }); - expect(user instanceof MUserClass).toEqual(true); - expect(user.user.premium_balance_tier !== null).toEqual(true); - expect(user.perkTier()).toEqual(PerkTier.Four); - }); -}); diff --git a/tests/unit/utils.ts b/tests/unit/utils.ts index b2a77220eb..c32e36475b 100644 --- a/tests/unit/utils.ts +++ b/tests/unit/utils.ts @@ -36,8 +36,6 @@ interface MockUserArgs { skills_prayer?: number; skills_fishing?: number; GP?: number; - premium_balance_tier?: number; - premium_balance_expiry_date?: number; bitfield?: BitField[]; id?: string; } @@ -81,8 +79,6 @@ const mockUser = (overrides?: MockUserArgs): User => { skills_slayer: 0, skills_hitpoints: overrides?.skills_hitpoints ?? convertLVLtoXP(10), GP: overrides?.GP ?? 0, - premium_balance_tier: overrides?.premium_balance_tier, - premium_balance_expiry_date: overrides?.premium_balance_expiry_date, ironman_alts: [], bitfield: overrides?.bitfield ?? [], username: 'Magnaboy', diff --git a/yarn.lock b/yarn.lock index f85bfc1ad6..ff1a4da40a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -679,15 +679,16 @@ __metadata: languageName: node linkType: hard -"@oldschoolgg/toolkit@git+https://github.com/oldschoolgg/toolkit.git#3eb432f0349a32fde98e981c42653d927d91e7e2": +"@oldschoolgg/toolkit@git+https://github.com/oldschoolgg/toolkit.git#3550582efbdf04929f0a2c5114ed069a61551807": version: 0.0.24 - resolution: "@oldschoolgg/toolkit@https://github.com/oldschoolgg/toolkit.git#commit=3eb432f0349a32fde98e981c42653d927d91e7e2" + resolution: "@oldschoolgg/toolkit@https://github.com/oldschoolgg/toolkit.git#commit=3550582efbdf04929f0a2c5114ed069a61551807" dependencies: decimal.js: "npm:^10.4.3" deep-object-diff: "npm:^1.1.9" deepmerge: "npm:4.3.1" e: "npm:0.2.33" emoji-regex: "npm:^10.2.1" + fast-deep-equal: "npm:^3.1.3" ioredis: "npm:^5.4.1" ioredis-mock: "npm:^8.9.0" math-expression-evaluator: "npm:^1.3.14" @@ -696,7 +697,7 @@ __metadata: peerDependencies: discord.js: ^14.15.3 oldschooljs: ^2.5.9 - checksum: 10c0/e3dbf7ef73a2fdbc6e9beed5ec37d12ac246528d63185e387f855d2b0f64e2501db92ce0da940f00f6eba3c352e50d2f9b6e1be707471f30ffb9b9d07e599139 + checksum: 10c0/8a968e143bc34c38d15ab29f1ecde613340e58c93341d51368651c6af52228652449be13a5a1c299bc836e0ff9a5a9c7c6a104198096d73345dd3601e06655ae languageName: node linkType: hard @@ -1216,6 +1217,13 @@ __metadata: languageName: node linkType: hard +"@sapphire/ratelimits@npm:^2.4.9": + version: 2.4.9 + resolution: "@sapphire/ratelimits@npm:2.4.9" + checksum: 10c0/e2e7da0ab8180914b42807044a14a786474f179e5e5e19b8b920a5ddc255b81263cbadf3d1c7ec60e2fbffc581d4de2e99b294bfec27d35874759d1d1b537cc7 + languageName: node + linkType: hard + "@sapphire/shapeshift@npm:^3.9.7": version: 3.9.7 resolution: "@sapphire/shapeshift@npm:3.9.7" @@ -1871,6 +1879,13 @@ __metadata: languageName: node linkType: hard +"base64-js@npm:^1.3.1": + version: 1.5.1 + resolution: "base64-js@npm:1.5.1" + checksum: 10c0/f23823513b63173a001030fae4f2dabe283b99a9d324ade3ad3d148e218134676f1ee8568c877cd79ec1c53158dcf2d2ba527a97c606618928ba99dd930102bf + languageName: node + linkType: hard + "binary-extensions@npm:^2.0.0": version: 2.3.0 resolution: "binary-extensions@npm:2.3.0" @@ -1878,6 +1893,17 @@ __metadata: languageName: node linkType: hard +"bl@npm:^4.1.0": + version: 4.1.0 + resolution: "bl@npm:4.1.0" + dependencies: + buffer: "npm:^5.5.0" + inherits: "npm:^2.0.4" + readable-stream: "npm:^3.4.0" + checksum: 10c0/02847e1d2cb089c9dc6958add42e3cdeaf07d13f575973963335ac0fdece563a50ac770ac4c8fa06492d2dd276f6cc3b7f08c7cd9c7a7ad0f8d388b2a28def5f + languageName: node + linkType: hard + "brace-expansion@npm:^1.1.7": version: 1.1.11 resolution: "brace-expansion@npm:1.1.11" @@ -1906,6 +1932,16 @@ __metadata: languageName: node linkType: hard +"buffer@npm:^5.5.0": + version: 5.7.1 + resolution: "buffer@npm:5.7.1" + dependencies: + base64-js: "npm:^1.3.1" + ieee754: "npm:^1.1.13" + checksum: 10c0/27cac81cff434ed2876058d72e7c4789d11ff1120ef32c9de48f59eab58179b66710c488987d295ae89a228f835fc66d088652dffeb8e3ba8659f80eb091d55e + languageName: node + linkType: hard + "bufferutil@npm:^4.0.8": version: 4.0.8 resolution: "bufferutil@npm:4.0.8" @@ -1958,7 +1994,7 @@ __metadata: languageName: node linkType: hard -"chalk@npm:^4.1.2": +"chalk@npm:^4.1.0, chalk@npm:^4.1.2": version: 4.1.2 resolution: "chalk@npm:4.1.2" dependencies: @@ -2017,6 +2053,22 @@ __metadata: languageName: node linkType: hard +"cli-cursor@npm:^3.1.0": + version: 3.1.0 + resolution: "cli-cursor@npm:3.1.0" + dependencies: + restore-cursor: "npm:^3.1.0" + checksum: 10c0/92a2f98ff9037d09be3dfe1f0d749664797fb674bf388375a2207a1203b69d41847abf16434203e0089212479e47a358b13a0222ab9fccfe8e2644a7ccebd111 + languageName: node + linkType: hard + +"cli-spinners@npm:^2.5.0": + version: 2.9.2 + resolution: "cli-spinners@npm:2.9.2" + checksum: 10c0/907a1c227ddf0d7a101e7ab8b300affc742ead4b4ebe920a5bf1bc6d45dce2958fcd195eb28fa25275062fe6fa9b109b93b63bc8033396ed3bcb50297008b3a3 + languageName: node + linkType: hard + "cliui@npm:^8.0.1": version: 8.0.1 resolution: "cliui@npm:8.0.1" @@ -2028,6 +2080,13 @@ __metadata: languageName: node linkType: hard +"clone@npm:^1.0.2": + version: 1.0.4 + resolution: "clone@npm:1.0.4" + checksum: 10c0/2176952b3649293473999a95d7bebfc9dc96410f6cbd3d2595cf12fd401f63a4bf41a7adbfd3ab2ff09ed60cb9870c58c6acdd18b87767366fabfc163700f13b + languageName: node + linkType: hard + "cluster-key-slot@npm:^1.1.0": version: 1.1.2 resolution: "cluster-key-slot@npm:1.1.2" @@ -2161,6 +2220,15 @@ __metadata: languageName: node linkType: hard +"defaults@npm:^1.0.3": + version: 1.0.4 + resolution: "defaults@npm:1.0.4" + dependencies: + clone: "npm:^1.0.2" + checksum: 10c0/9cfbe498f5c8ed733775db62dfd585780387d93c17477949e1670bfcfb9346e0281ce8c4bf9f4ac1fc0f9b851113bd6dc9e41182ea1644ccd97de639fa13c35a + languageName: node + linkType: hard + "delayed-stream@npm:~1.0.0": version: 1.0.0 resolution: "delayed-stream@npm:1.0.0" @@ -2216,6 +2284,23 @@ __metadata: languageName: node linkType: hard +"dpdm@npm:^3.14.0": + version: 3.14.0 + resolution: "dpdm@npm:3.14.0" + dependencies: + chalk: "npm:^4.1.2" + fs-extra: "npm:^11.1.1" + glob: "npm:^10.3.4" + ora: "npm:^5.4.1" + tslib: "npm:^2.6.2" + typescript: "npm:^5.2.2" + yargs: "npm:^17.7.2" + bin: + dpdm: lib/bin/dpdm.js + checksum: 10c0/2d98064230d68bcc545da80783e9fab0cfb62e2862173af9d3c1648144376f6f7722bfccb5df91b59ed727928ed81d104ae74b53f35565cc5079e44d4d3a706c + languageName: node + linkType: hard + "e@npm:0.2.33, e@npm:^0.2.33": version: 0.2.33 resolution: "e@npm:0.2.33" @@ -2480,6 +2565,17 @@ __metadata: languageName: node linkType: hard +"fs-extra@npm:^11.1.1": + version: 11.2.0 + resolution: "fs-extra@npm:11.2.0" + dependencies: + graceful-fs: "npm:^4.2.0" + jsonfile: "npm:^6.0.1" + universalify: "npm:^2.0.0" + checksum: 10c0/d77a9a9efe60532d2e790e938c81a02c1b24904ef7a3efb3990b835514465ba720e99a6ea56fd5e2db53b4695319b644d76d5a0e9988a2beef80aa7b1da63398 + languageName: node + linkType: hard + "fs-minipass@npm:^2.0.0": version: 2.1.0 resolution: "fs-minipass@npm:2.1.0" @@ -2605,6 +2701,22 @@ __metadata: languageName: node linkType: hard +"glob@npm:^10.3.4": + version: 10.4.5 + resolution: "glob@npm:10.4.5" + dependencies: + foreground-child: "npm:^3.1.0" + jackspeak: "npm:^3.1.2" + minimatch: "npm:^9.0.4" + minipass: "npm:^7.1.2" + package-json-from-dist: "npm:^1.0.0" + path-scurry: "npm:^1.11.1" + bin: + glob: dist/esm/bin.mjs + checksum: 10c0/19a9759ea77b8e3ca0a43c2f07ecddc2ad46216b786bb8f993c445aee80d345925a21e5280c7b7c6c59e860a0154b84e4b2b60321fea92cd3c56b4a7489f160e + languageName: node + linkType: hard + "glob@npm:^7.1.4": version: 7.2.3 resolution: "glob@npm:7.2.3" @@ -2619,7 +2731,7 @@ __metadata: languageName: node linkType: hard -"graceful-fs@npm:^4.2.6": +"graceful-fs@npm:^4.1.6, graceful-fs@npm:^4.2.0, graceful-fs@npm:^4.2.6": version: 4.2.11 resolution: "graceful-fs@npm:4.2.11" checksum: 10c0/386d011a553e02bc594ac2ca0bd6d9e4c22d7fa8cfbfc448a6d148c59ea881b092db9dbe3547ae4b88e55f1b01f7c4a2ecc53b310c042793e63aa44cf6c257f2 @@ -2699,6 +2811,13 @@ __metadata: languageName: node linkType: hard +"ieee754@npm:^1.1.13": + version: 1.2.1 + resolution: "ieee754@npm:1.2.1" + checksum: 10c0/b0782ef5e0935b9f12883a2e2aa37baa75da6e66ce6515c168697b42160807d9330de9a32ec1ed73149aea02e0d822e572bca6f1e22bdcbd2149e13b050b17bb + languageName: node + linkType: hard + "ignore-by-default@npm:^1.0.1": version: 1.0.1 resolution: "ignore-by-default@npm:1.0.1" @@ -2754,7 +2873,7 @@ __metadata: languageName: node linkType: hard -"inherits@npm:2": +"inherits@npm:2, inherits@npm:^2.0.3, inherits@npm:^2.0.4": version: 2.0.4 resolution: "inherits@npm:2.0.4" checksum: 10c0/4e531f648b29039fb7426fb94075e6545faa1eb9fe83c29f0b6d9e7263aceb4289d2d4557db0d428188eeb449cc7c5e77b0a0b2c4e248ff2a65933a0dee49ef2 @@ -2845,6 +2964,13 @@ __metadata: languageName: node linkType: hard +"is-interactive@npm:^1.0.0": + version: 1.0.0 + resolution: "is-interactive@npm:1.0.0" + checksum: 10c0/dd47904dbf286cd20aa58c5192161be1a67138485b9836d5a70433b21a45442e9611b8498b8ab1f839fc962c7620667a50535fdfb4a6bc7989b8858645c06b4d + languageName: node + linkType: hard + "is-lambda@npm:^1.0.1": version: 1.0.1 resolution: "is-lambda@npm:1.0.1" @@ -2866,6 +2992,13 @@ __metadata: languageName: node linkType: hard +"is-unicode-supported@npm:^0.1.0": + version: 0.1.0 + resolution: "is-unicode-supported@npm:0.1.0" + checksum: 10c0/00cbe3455c3756be68d2542c416cab888aebd5012781d6819749fefb15162ff23e38501fe681b3d751c73e8ff561ac09a5293eba6f58fdf0178462ce6dcb3453 + languageName: node + linkType: hard + "isexe@npm:^2.0.0": version: 2.0.0 resolution: "isexe@npm:2.0.0" @@ -2971,6 +3104,19 @@ __metadata: languageName: node linkType: hard +"jsonfile@npm:^6.0.1": + version: 6.1.0 + resolution: "jsonfile@npm:6.1.0" + dependencies: + graceful-fs: "npm:^4.1.6" + universalify: "npm:^2.0.0" + dependenciesMeta: + graceful-fs: + optional: true + checksum: 10c0/4f95b5e8a5622b1e9e8f33c96b7ef3158122f595998114d1e7f03985649ea99cb3cd99ce1ed1831ae94c8c8543ab45ebd044207612f31a56fd08462140e46865 + languageName: node + linkType: hard + "local-pkg@npm:^0.5.0": version: 0.5.0 resolution: "local-pkg@npm:0.5.0" @@ -3016,6 +3162,16 @@ __metadata: languageName: node linkType: hard +"log-symbols@npm:^4.1.0": + version: 4.1.0 + resolution: "log-symbols@npm:4.1.0" + dependencies: + chalk: "npm:^4.1.0" + is-unicode-supported: "npm:^0.1.0" + checksum: 10c0/67f445a9ffa76db1989d0fa98586e5bc2fd5247260dafb8ad93d9f0ccd5896d53fb830b0e54dade5ad838b9de2006c826831a3c528913093af20dff8bd24aca6 + languageName: node + linkType: hard + "loupe@npm:^2.3.6": version: 2.3.6 resolution: "loupe@npm:2.3.6" @@ -3169,6 +3325,13 @@ __metadata: languageName: node linkType: hard +"mimic-fn@npm:^2.1.0": + version: 2.1.0 + resolution: "mimic-fn@npm:2.1.0" + checksum: 10c0/b26f5479d7ec6cc2bce275a08f146cf78f5e7b661b18114e2506dd91ec7ec47e7a25bf4360e5438094db0560bcc868079fb3b1fb3892b833c1ecbf63f80c95a4 + languageName: node + linkType: hard + "mimic-fn@npm:^4.0.0": version: 4.0.0 resolution: "mimic-fn@npm:4.0.0" @@ -3523,6 +3686,15 @@ __metadata: languageName: node linkType: hard +"onetime@npm:^5.1.0": + version: 5.1.2 + resolution: "onetime@npm:5.1.2" + dependencies: + mimic-fn: "npm:^2.1.0" + checksum: 10c0/ffcef6fbb2692c3c40749f31ea2e22677a876daea92959b8a80b521d95cca7a668c884d8b2045d1d8ee7d56796aa405c405462af112a1477594cc63531baeb8f + languageName: node + linkType: hard + "onetime@npm:^6.0.0": version: 6.0.0 resolution: "onetime@npm:6.0.0" @@ -3543,6 +3715,23 @@ __metadata: languageName: node linkType: hard +"ora@npm:^5.4.1": + version: 5.4.1 + resolution: "ora@npm:5.4.1" + dependencies: + bl: "npm:^4.1.0" + chalk: "npm:^4.1.0" + cli-cursor: "npm:^3.1.0" + cli-spinners: "npm:^2.5.0" + is-interactive: "npm:^1.0.0" + is-unicode-supported: "npm:^0.1.0" + log-symbols: "npm:^4.1.0" + strip-ansi: "npm:^6.0.0" + wcwidth: "npm:^1.0.1" + checksum: 10c0/10ff14aace236d0e2f044193362b22edce4784add08b779eccc8f8ef97195cae1248db8ec1ec5f5ff076f91acbe573f5f42a98c19b78dba8c54eefff983cae85 + languageName: node + linkType: hard + "os-tmpdir@npm:~1.0.2": version: 1.0.2 resolution: "os-tmpdir@npm:1.0.2" @@ -3928,6 +4117,17 @@ __metadata: languageName: node linkType: hard +"readable-stream@npm:^3.4.0": + version: 3.6.2 + resolution: "readable-stream@npm:3.6.2" + dependencies: + inherits: "npm:^2.0.3" + string_decoder: "npm:^1.1.1" + util-deprecate: "npm:^1.0.1" + checksum: 10c0/e37be5c79c376fdd088a45fa31ea2e423e5d48854be7a22a58869b4e84d25047b193f6acb54f1012331e1bcd667ffb569c01b99d36b0bd59658fb33f513511b7 + languageName: node + linkType: hard + "readdirp@npm:~3.6.0": version: 3.6.0 resolution: "readdirp@npm:3.6.0" @@ -4018,6 +4218,16 @@ __metadata: languageName: node linkType: hard +"restore-cursor@npm:^3.1.0": + version: 3.1.0 + resolution: "restore-cursor@npm:3.1.0" + dependencies: + onetime: "npm:^5.1.0" + signal-exit: "npm:^3.0.2" + checksum: 10c0/8051a371d6aa67ff21625fa94e2357bd81ffdc96267f3fb0fc4aaf4534028343836548ef34c240ffa8c25b280ca35eb36be00b3cb2133fa4f51896d7e73c6b4f + languageName: node + linkType: hard + "retry@npm:^0.12.0": version: 0.12.0 resolution: "retry@npm:0.12.0" @@ -4098,8 +4308,9 @@ __metadata: dependencies: "@biomejs/biome": "npm:^1.8.3" "@napi-rs/canvas": "npm:^0.1.53" - "@oldschoolgg/toolkit": "git+https://github.com/oldschoolgg/toolkit.git#3eb432f0349a32fde98e981c42653d927d91e7e2" + "@oldschoolgg/toolkit": "git+https://github.com/oldschoolgg/toolkit.git#3550582efbdf04929f0a2c5114ed069a61551807" "@prisma/client": "npm:^5.16.1" + "@sapphire/ratelimits": "npm:^2.4.9" "@sapphire/snowflake": "npm:^3.5.3" "@sapphire/time-utilities": "npm:^1.6.0" "@sentry/node": "npm:^8.15.0" @@ -4113,6 +4324,7 @@ __metadata: concurrently: "npm:^8.2.2" discord.js: "npm:^14.15.3" dotenv: "npm:^16.4.5" + dpdm: "npm:^3.14.0" e: "npm:0.2.33" esbuild: "npm:0.21.5" fast-deep-equal: "npm:^3.1.3" @@ -4157,6 +4369,13 @@ __metadata: languageName: node linkType: hard +"safe-buffer@npm:~5.2.0": + version: 5.2.1 + resolution: "safe-buffer@npm:5.2.1" + checksum: 10c0/6501914237c0a86e9675d4e51d89ca3c21ffd6a31642efeba25ad65720bce6921c9e7e974e5be91a786b25aa058b5303285d3c15dbabf983a919f5f630d349f3 + languageName: node + linkType: hard + "safer-buffer@npm:>= 2.1.2 < 3.0.0": version: 2.1.2 resolution: "safer-buffer@npm:2.1.2" @@ -4230,6 +4449,13 @@ __metadata: languageName: node linkType: hard +"signal-exit@npm:^3.0.2": + version: 3.0.7 + resolution: "signal-exit@npm:3.0.7" + checksum: 10c0/25d272fa73e146048565e08f3309d5b942c1979a6f4a58a8c59d5fa299728e9c2fcd1a759ec870863b1fd38653670240cd420dad2ad9330c71f36608a6a1c912 + languageName: node + linkType: hard + "signal-exit@npm:^4.0.1, signal-exit@npm:^4.1.0": version: 4.1.0 resolution: "signal-exit@npm:4.1.0" @@ -4370,6 +4596,15 @@ __metadata: languageName: node linkType: hard +"string_decoder@npm:^1.1.1": + version: 1.3.0 + resolution: "string_decoder@npm:1.3.0" + dependencies: + safe-buffer: "npm:~5.2.0" + checksum: 10c0/810614ddb030e271cd591935dcd5956b2410dd079d64ff92a1844d6b7588bf992b3e1b69b0f4d34a3e06e0bd73046ac646b5264c1987b20d0601f81ef35d731d + languageName: node + linkType: hard + "strip-ansi-cjs@npm:strip-ansi@^6.0.1, strip-ansi@npm:^6.0.0, strip-ansi@npm:^6.0.1": version: 6.0.1 resolution: "strip-ansi@npm:6.0.1" @@ -4578,7 +4813,7 @@ __metadata: languageName: node linkType: hard -"typescript@npm:^5.5.3": +"typescript@npm:^5.2.2, typescript@npm:^5.5.3": version: 5.5.3 resolution: "typescript@npm:5.5.3" bin: @@ -4588,7 +4823,7 @@ __metadata: languageName: node linkType: hard -"typescript@patch:typescript@npm%3A^5.5.3#optional!builtin": +"typescript@patch:typescript@npm%3A^5.2.2#optional!builtin, typescript@patch:typescript@npm%3A^5.5.3#optional!builtin": version: 5.5.3 resolution: "typescript@patch:typescript@npm%3A5.5.3#optional!builtin::version=5.5.3&hash=379a07" bin: @@ -4651,6 +4886,20 @@ __metadata: languageName: node linkType: hard +"universalify@npm:^2.0.0": + version: 2.0.1 + resolution: "universalify@npm:2.0.1" + checksum: 10c0/73e8ee3809041ca8b818efb141801a1004e3fc0002727f1531f4de613ea281b494a40909596dae4a042a4fb6cd385af5d4db2e137b1362e0e91384b828effd3a + languageName: node + linkType: hard + +"util-deprecate@npm:^1.0.1": + version: 1.0.2 + resolution: "util-deprecate@npm:1.0.2" + checksum: 10c0/41a5bdd214df2f6c3ecf8622745e4a366c4adced864bc3c833739791aeeeb1838119af7daed4ba36428114b5c67dcda034a79c882e97e43c03e66a4dd7389942 + languageName: node + linkType: hard + "uuid@npm:8.3.2": version: 8.3.2 resolution: "uuid@npm:8.3.2" @@ -4765,6 +5014,15 @@ __metadata: languageName: node linkType: hard +"wcwidth@npm:^1.0.1": + version: 1.0.1 + resolution: "wcwidth@npm:1.0.1" + dependencies: + defaults: "npm:^1.0.3" + checksum: 10c0/5b61ca583a95e2dd85d7078400190efd452e05751a64accb8c06ce4db65d7e0b0cde9917d705e826a2e05cc2548f61efde115ffa374c3e436d04be45c889e5b4 + languageName: node + linkType: hard + "webidl-conversions@npm:^3.0.0": version: 3.0.1 resolution: "webidl-conversions@npm:3.0.1" From 0f1a93c591fc22ffb504d02972e03a0a16d71c28 Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Tue, 16 Jul 2024 14:46:12 +1000 Subject: [PATCH 062/145] Disable bank background checking for now --- src/mahoji/lib/preCommand.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mahoji/lib/preCommand.ts b/src/mahoji/lib/preCommand.ts index 4cebf8e6e1..d8b1a96a0c 100644 --- a/src/mahoji/lib/preCommand.ts +++ b/src/mahoji/lib/preCommand.ts @@ -43,7 +43,7 @@ export async function preCommand({ username }); - user.checkBankBackground(); + // TODO: user.checkBankBackground(); if (userIsBusy(userID) && !bypassInhibitors && !busyImmuneCommands.includes(abstractCommand.name)) { return { reason: { content: 'You cannot use a command right now.' }, dontRunPostCommand: true }; } From e341045273c7ca8819059f13bc0be807d7c95160 Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Tue, 16 Jul 2024 15:43:05 +1000 Subject: [PATCH 063/145] Fixes/improvements --- prisma/robochimp.prisma | 19 ++- prisma/schema.prisma | 50 +++--- src/index.ts | 4 +- src/lib/patreonUtils.ts | 2 +- src/lib/perkTier.ts | 77 +++++++++ src/lib/perkTiers.ts | 2 +- src/lib/roboChimp.ts | 81 +--------- src/lib/util/findGroupOfUser.ts | 15 ++ src/lib/util/minionStatus.ts | 2 +- src/mahoji/commands/activities.ts | 3 +- src/mahoji/commands/minion.ts | 5 +- .../lib/abstracted_commands/collectCommand.ts | 145 +---------------- .../lib/abstracted_commands/statCommand.ts | 2 +- src/mahoji/lib/collectables.ts | 146 ++++++++++++++++++ src/mahoji/lib/inhibitors.ts | 2 +- src/tasks/minions/collectingActivity.ts | 2 +- tests/integration/redis.test.ts | 2 +- 17 files changed, 296 insertions(+), 263 deletions(-) create mode 100644 src/lib/perkTier.ts create mode 100644 src/lib/util/findGroupOfUser.ts create mode 100644 src/mahoji/lib/collectables.ts diff --git a/prisma/robochimp.prisma b/prisma/robochimp.prisma index 73fc468661..25d6275b83 100644 --- a/prisma/robochimp.prisma +++ b/prisma/robochimp.prisma @@ -55,18 +55,19 @@ model User { osb_mastery Float? bso_mastery Float? - tag Tag[] - store_bitfield Int[] testing_points Float @default(0) testing_points_balance Float @default(0) - perk_tier Int @default(0) + perk_tier Int @default(0) premium_balance_tier Int? premium_balance_expiry_date BigInt? - ironman_alts String[] - main_account String? + + user_group_id String? @db.Uuid + userGroup UserGroup? @relation(fields: [user_group_id], references: [id]) + + tag Tag[] @@map("user") } @@ -98,3 +99,11 @@ model StoreCode { @@map("store_code") } + +model UserGroup { + id String @id @default(uuid()) @db.Uuid + + users User[] + + @@map("user_group") +} diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 19b3e0e739..83d0ae4762 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -60,8 +60,8 @@ model Analytic { model ClientStorage { id String @id @db.VarChar(19) - userBlacklist String[] @db.VarChar(19) - guildBlacklist String[] @db.VarChar(19) + userBlacklist String[] @default([]) @db.VarChar(19) + guildBlacklist String[] @default([]) @db.VarChar(19) commandStats Json @default("{}") @db.Json totalCommandsUsed Int @default(0) prices Json @default("{}") @db.Json @@ -125,7 +125,7 @@ model ClientStorage { degraded_items_cost Json @default("{}") @db.Json tks_cost Json @default("{}") @db.Json tks_loot Json @default("{}") @db.Json - disabled_commands String[] @db.VarChar(32) + disabled_commands String[] @default([]) @db.VarChar(32) gp_tax_balance BigInt @default(0) gotr_cost Json @default("{}") @db.Json gotr_loot Json @default("{}") @db.Json @@ -206,7 +206,7 @@ model Giveaway { message_id String @db.VarChar(19) reaction_id String? @db.VarChar(19) - users_entered String[] + users_entered String[] @default([]) @@index([completed, finish_date]) @@map("giveaway") @@ -214,11 +214,11 @@ model Giveaway { model Guild { id String @id @db.VarChar(19) - disabledCommands String[] + disabledCommands String[] @default([]) jmodComments String? @db.VarChar(19) petchannel String? @db.VarChar(19) tweetchannel String? @db.VarChar(19) - staffOnlyChannels String[] @db.VarChar(19) + staffOnlyChannels String[] @default([]) @db.VarChar(19) @@map("guilds") } @@ -309,19 +309,19 @@ model User { bank Json @default("{}") @db.Json collectionLogBank Json @default("{}") @db.JsonB blowpipe Json @default("{\"scales\":0,\"dartID\":null,\"dartQuantity\":0}") @db.Json - ironman_alts String[] + ironman_alts String[] @default([]) main_account String? - slayer_unlocks Int[] @map("slayer.unlocks") - slayer_blocked_ids Int[] @map("slayer.blocked_ids") + slayer_unlocks Int[] @default([]) @map("slayer.unlocks") + slayer_blocked_ids Int[] @default([]) @map("slayer.blocked_ids") slayer_last_task Int @default(0) @map("slayer.last_task") - badges Int[] - bitfield Int[] + badges Int[] @default([]) + bitfield Int[] @default([]) temp_cl Json @default("{}") @db.Json last_temp_cl_reset DateTime? @db.Timestamp(6) minion_equippedPet Int? @map("minion.equippedPet") minion_farmingContract Json? @map("minion.farmingContract") @db.Json minion_birdhouseTraps Json? @map("minion.birdhouseTraps") @db.Json - finished_quest_ids Int[] + finished_quest_ids Int[] @default([]) // Relations farmedCrops FarmedCrop[] @@ -332,19 +332,19 @@ model User { // Configs/Settings minion_defaultCompostToUse CropUpgradeType @default(compost) @map("minion.defaultCompostToUse") auto_farm_filter AutoFarmFilterEnum @default(AllFarm) - favoriteItems Int[] - favorite_alchables Int[] - favorite_food Int[] - favorite_bh_seeds Int[] + favoriteItems Int[] @default([]) + favorite_alchables Int[] @default([]) + favorite_food Int[] @default([]) + favorite_bh_seeds Int[] @default([]) minion_defaultPay Boolean @default(false) @map("minion.defaultPay") minion_icon String? @map("minion.icon") minion_name String? @map("minion.name") bank_bg_hex String? bankBackground Int @default(1) - attack_style String[] - combat_options Int[] + attack_style String[] @default([]) + combat_options Int[] @default([]) slayer_remember_master String? @map("slayer.remember_master") - slayer_autoslay_options Int[] @map("slayer.autoslay_options") + slayer_autoslay_options Int[] @default([]) @map("slayer.autoslay_options") bank_sort_method String? @db.VarChar(16) bank_sort_weightings Json @default("{}") @db.Json gambling_lockout_expiry DateTime? @@ -407,9 +407,9 @@ model User { zeal_tokens Int @default(0) slayer_points Int @default(0) @map("slayer.points") - completed_ca_task_ids Int[] + completed_ca_task_ids Int[] @default([]) - store_bitfield Int[] + store_bitfield Int[] @default([]) // Migrate farmingPatches_herb Json? @map("farmingPatches.herb") @db.Json @@ -671,7 +671,7 @@ model EconomyTransaction { model StashUnit { stash_id Int user_id BigInt - items_contained Int[] + items_contained Int[] @default([]) has_built Boolean @@unique([stash_id, user_id]) @@ -699,7 +699,7 @@ model UserStats { farming_plant_cost_bank Json @default("{}") @db.Json farming_harvest_loot_bank Json @default("{}") @db.Json - cl_array Int[] + cl_array Int[] @default([]) cl_array_length Int @default(0) buy_cost_bank Json @default("{}") @db.Json @@ -1050,14 +1050,14 @@ model Bingo { is_global Boolean @default(false) - organizers String[] + organizers String[] @default([]) start_date DateTime @default(now()) @db.Timestamp(6) duration_days Int team_size Int title String notifications_channel_id String @db.VarChar(19) ticket_price BigInt - bingo_tiles Json[] + bingo_tiles Json[] @default([]) was_finalized Boolean @default(false) guild_id String @db.VarChar(19) diff --git a/src/index.ts b/src/index.ts index e5a34d6ae5..a74bec53d8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,6 +19,7 @@ import { economyLog } from './lib/economyLogs'; import { onMessage } from './lib/events'; import { GrandExchange } from './lib/grandExchange'; import { modalInteractionHook } from './lib/modals'; +import { populateRoboChimpCache } from './lib/perkTier'; import { runStartupScripts } from './lib/startupScripts'; import { OldSchoolBotClient } from './lib/structures/OldSchoolBotClient'; import { assert, runTimedLoggedFn } from './lib/util'; @@ -201,7 +202,8 @@ async function main() { runTimedLoggedFn('Sync Blacklist', syncBlacklists), runTimedLoggedFn('Syncing prices', syncCustomPrices), runTimedLoggedFn('Caching badges', cacheBadges), - runTimedLoggedFn('Init Grand Exchange', () => GrandExchange.init()) + runTimedLoggedFn('Init Grand Exchange', () => GrandExchange.init()), + runTimedLoggedFn('populateRoboChimpCache', populateRoboChimpCache) ]); await runTimedLoggedFn('Log In', () => client.login(globalConfig.botToken)); console.log(`Logged in as ${globalClient.user.username}`); diff --git a/src/lib/patreonUtils.ts b/src/lib/patreonUtils.ts index 934d1fa225..bcc964ce44 100644 --- a/src/lib/patreonUtils.ts +++ b/src/lib/patreonUtils.ts @@ -1,5 +1,5 @@ import { BadgesEnum } from './constants'; -import { populateRoboChimpCache } from './roboChimp'; +import { populateRoboChimpCache } from './perkTier'; export async function handleDeletedPatron(userID: string[]) { const users = await prisma.user.findMany({ diff --git a/src/lib/perkTier.ts b/src/lib/perkTier.ts new file mode 100644 index 0000000000..8b70c2f3df --- /dev/null +++ b/src/lib/perkTier.ts @@ -0,0 +1,77 @@ +import { PerkTier } from '@oldschoolgg/toolkit'; +import { pick } from 'lodash'; +import { SupportServer } from '../config'; +import { BitField, Roles } from './constants'; +import type { RobochimpUser } from './roboChimp'; + +const robochimpCachedKeys = [ + 'bits', + 'github_id', + 'patreon_id', + 'perk_tier', + 'user_group_id', + 'premium_balance_expiry_date', + 'premium_balance_tier' +] as const; +type CachedRoboChimpUser = Pick; + +export const roboChimpCache = new Map(); + +export async function populateRoboChimpCache() { + const users = await roboChimpClient.user.findMany({ + select: { + id: true, + bits: true, + github_id: true, + patreon_id: true, + perk_tier: true, + premium_balance_expiry_date: true, + premium_balance_tier: true, + user_group_id: true + }, + where: { + perk_tier: { + not: 0 + } + } + }); + for (const user of users) { + roboChimpCache.set(user.id.toString(), user); + } + debugLog(`Populated RoboChimp cache with ${users.length} users.`); +} + +export function cacheRoboChimpUser(user: RobochimpUser) { + if (user.perk_tier === 0) return; + roboChimpCache.set(user.id.toString(), pick(user, robochimpCachedKeys)); +} + +export function getPerkTierSync(user: string | MUser) { + const elligibleTiers = []; + if (typeof user !== 'string') { + if ( + [BitField.isContributor, BitField.isModerator, BitField.IsWikiContributor].some(bit => + user.bitfield.includes(bit) + ) + ) { + return PerkTier.Four; + } + + if ( + user.bitfield.includes(BitField.IsPatronTier1) || + user.bitfield.includes(BitField.HasPermanentTierOne) || + user.bitfield.includes(BitField.BothBotsMaxedFreeTierOnePerks) + ) { + elligibleTiers.push(PerkTier.Two); + } else { + const guild = globalClient.guilds.cache.get(SupportServer); + const member = guild?.members.cache.get(user.id); + if (member && [Roles.Booster].some(roleID => member.roles.cache.has(roleID))) { + elligibleTiers.push(PerkTier.One); + } + } + } + + elligibleTiers.push(roboChimpCache.get(typeof user === 'string' ? user : user.id)?.perk_tier ?? 0); + return Math.max(...elligibleTiers); +} diff --git a/src/lib/perkTiers.ts b/src/lib/perkTiers.ts index 8488b2a049..7a62aeb247 100644 --- a/src/lib/perkTiers.ts +++ b/src/lib/perkTiers.ts @@ -1,6 +1,6 @@ import { SupportServer } from '../config'; import { BitField, PerkTier, Roles } from './constants'; -import { getPerkTierSync } from './roboChimp'; +import { getPerkTierSync } from './perkTier'; export const allPerkBitfields: BitField[] = [ BitField.IsPatronTier6, diff --git a/src/lib/roboChimp.ts b/src/lib/roboChimp.ts index 4b6b1ea2fb..bfb465d5c9 100644 --- a/src/lib/roboChimp.ts +++ b/src/lib/roboChimp.ts @@ -1,14 +1,13 @@ -import { PerkTier, formatOrdinal } from '@oldschoolgg/toolkit'; +import { formatOrdinal } from '@oldschoolgg/toolkit'; import type { TriviaQuestion, User } from '@prisma/robochimp'; import { calcWhatPercent, round, sumArr } from 'e'; import deepEqual from 'fast-deep-equal'; -import { pick } from 'lodash'; import type { Bank } from 'oldschooljs'; -import { SupportServer } from '../config'; -import { BOT_TYPE, BitField, Roles, globalConfig, masteryKey } from './constants'; +import { BOT_TYPE, globalConfig, masteryKey } from './constants'; import { getTotalCl } from './data/Collections'; import { calculateMastery } from './mastery'; +import { cacheRoboChimpUser } from './perkTier'; import { MUserStats } from './structures/MUserStats'; export type RobochimpUser = User; @@ -118,77 +117,3 @@ WHERE osb_cl_percent >= (SELECT osb_cl_percent FROM public.user WHERE id = ${Big return formatOrdinal(clPercentRank); } - -const robochimpCachedKeys = [ - 'bits', - 'github_id', - 'patreon_id', - 'perk_tier', - 'main_account', - 'ironman_alts', - 'premium_balance_expiry_date', - 'premium_balance_tier' -] as const; -type CachedRoboChimpUser = Pick; - -export const roboChimpCache = new Map(); - -export async function populateRoboChimpCache() { - const users = await roboChimpClient.user.findMany({ - select: { - id: true, - bits: true, - github_id: true, - patreon_id: true, - perk_tier: true, - main_account: true, - ironman_alts: true, - premium_balance_expiry_date: true, - premium_balance_tier: true - }, - where: { - perk_tier: { - not: 0 - } - } - }); - for (const user of users) { - roboChimpCache.set(user.id.toString(), user); - } - debugLog(`Populated RoboChimp cache with ${users.length} users.`); -} - -function cacheRoboChimpUser(user: RobochimpUser) { - if (user.perk_tier === 0) return; - roboChimpCache.set(user.id.toString(), pick(user, robochimpCachedKeys)); -} - -export function getPerkTierSync(user: string | MUser) { - const elligibleTiers = []; - if (typeof user !== 'string') { - if ( - [BitField.isContributor, BitField.isModerator, BitField.IsWikiContributor].some(bit => - user.bitfield.includes(bit) - ) - ) { - return PerkTier.Four; - } - - if ( - user.bitfield.includes(BitField.IsPatronTier1) || - user.bitfield.includes(BitField.HasPermanentTierOne) || - user.bitfield.includes(BitField.BothBotsMaxedFreeTierOnePerks) - ) { - elligibleTiers.push(PerkTier.Two); - } else { - const guild = globalClient.guilds.cache.get(SupportServer); - const member = guild?.members.cache.get(user.id); - if (member && [Roles.Booster].some(roleID => member.roles.cache.has(roleID))) { - elligibleTiers.push(PerkTier.One); - } - } - } - - elligibleTiers.push(roboChimpCache.get(typeof user === 'string' ? user : user.id)?.perk_tier ?? 0); - return Math.max(...elligibleTiers); -} diff --git a/src/lib/util/findGroupOfUser.ts b/src/lib/util/findGroupOfUser.ts new file mode 100644 index 0000000000..e9528d5b0c --- /dev/null +++ b/src/lib/util/findGroupOfUser.ts @@ -0,0 +1,15 @@ +export async function findGroupOfUser(userID: string) { + const user = await roboChimpClient.user.findUnique({ + where: { + id: BigInt(userID) + } + }); + if (!user || !user.user_group_id) return [userID]; + const group = await roboChimpClient.user.findMany({ + where: { + user_group_id: user.user_group_id + } + }); + if (!group) return [userID]; + return group.map(u => u.id.toString()); +} diff --git a/src/lib/util/minionStatus.ts b/src/lib/util/minionStatus.ts index 6be916359d..ce9d46bc23 100644 --- a/src/lib/util/minionStatus.ts +++ b/src/lib/util/minionStatus.ts @@ -2,8 +2,8 @@ import { toTitleCase } from '@oldschoolgg/toolkit'; import { increaseNumByPercent, reduceNumByPercent } from 'e'; import { SkillsEnum } from 'oldschooljs/dist/constants'; -import { collectables } from '../../mahoji/lib/abstracted_commands/collectCommand'; import { shades, shadesLogs } from '../../mahoji/lib/abstracted_commands/shadesOfMortonCommand'; +import { collectables } from '../../mahoji/lib/collectables'; import { ClueTiers } from '../clues/clueTiers'; import { Emoji } from '../constants'; import killableMonsters from '../minions/data/killableMonsters'; diff --git a/src/mahoji/commands/activities.ts b/src/mahoji/commands/activities.ts index 1d0fa18e7b..f922033e57 100644 --- a/src/mahoji/commands/activities.ts +++ b/src/mahoji/commands/activities.ts @@ -22,7 +22,7 @@ import { championsChallengeCommand } from '../lib/abstracted_commands/championsC import { chargeGloriesCommand } from '../lib/abstracted_commands/chargeGloriesCommand'; import { chargeWealthCommand } from '../lib/abstracted_commands/chargeWealthCommand'; import { chompyHuntClaimCommand, chompyHuntCommand } from '../lib/abstracted_commands/chompyHuntCommand'; -import { collectCommand, collectables } from '../lib/abstracted_commands/collectCommand'; +import { collectCommand } from '../lib/abstracted_commands/collectCommand'; import { decantCommand } from '../lib/abstracted_commands/decantCommand'; import { driftNetCommand } from '../lib/abstracted_commands/driftNetCommand'; import { enchantCommand } from '../lib/abstracted_commands/enchantCommand'; @@ -35,6 +35,7 @@ import { sawmillCommand } from '../lib/abstracted_commands/sawmillCommand'; import { scatterCommand } from '../lib/abstracted_commands/scatterCommand'; import { underwaterAgilityThievingCommand } from '../lib/abstracted_commands/underwaterCommand'; import { warriorsGuildCommand } from '../lib/abstracted_commands/warriorsGuildCommand'; +import { collectables } from '../lib/collectables'; import { ownedItemOption } from '../lib/mahojiCommandOptions'; import type { OSBMahojiCommand } from '../lib/util'; diff --git a/src/mahoji/commands/minion.ts b/src/mahoji/commands/minion.ts index 10df58df98..4c2f348e8f 100644 --- a/src/mahoji/commands/minion.ts +++ b/src/mahoji/commands/minion.ts @@ -22,7 +22,8 @@ import type { AttackStyles } from '../../lib/minions/functions'; import { blowpipeCommand, blowpipeDarts } from '../../lib/minions/functions/blowpipeCommand'; import { degradeableItemsCommand } from '../../lib/minions/functions/degradeableItemsCommand'; import { allPossibleStyles, trainCommand } from '../../lib/minions/functions/trainCommand'; -import { roboChimpCache, roboChimpUserFetch } from '../../lib/roboChimp'; +import { roboChimpCache } from '../../lib/perkTier'; +import { roboChimpUserFetch } from '../../lib/roboChimp'; import { Minigames } from '../../lib/settings/minigames'; import Skills from '../../lib/skilling/skills'; import creatures from '../../lib/skilling/skills/hunter/creatures'; @@ -96,7 +97,7 @@ export async function getUserInfo(user: MUser) { 2 ); - const roboCache = await roboChimpCache.get(user.id); + const roboCache = roboChimpCache.get(user.id); return { ...result, everythingString: `${user.badgedUsername}[${user.id}] diff --git a/src/mahoji/lib/abstracted_commands/collectCommand.ts b/src/mahoji/lib/abstracted_commands/collectCommand.ts index 7d976683c4..d12bf8d581 100644 --- a/src/mahoji/lib/abstracted_commands/collectCommand.ts +++ b/src/mahoji/lib/abstracted_commands/collectCommand.ts @@ -1,159 +1,16 @@ import { Time } from 'e'; import { Bank } from 'oldschooljs'; -import type { Item } from 'oldschooljs/dist/meta/types'; import { WildernessDiary, userhasDiaryTier } from '../../../lib/diaries'; import type { SkillsEnum } from '../../../lib/skilling/types'; -import type { Skills } from '../../../lib/types'; import type { CollectingOptions } from '../../../lib/types/minions'; import { formatDuration, stringMatches } from '../../../lib/util'; import addSubTaskToActivityTask from '../../../lib/util/addSubTaskToActivityTask'; import { calcMaxTripLength } from '../../../lib/util/calcMaxTripLength'; -import getOSItem from '../../../lib/util/getOSItem'; import { updateBankSetting } from '../../../lib/util/updateBankSetting'; +import { collectables } from '../collectables'; import { getPOH } from './pohCommand'; -interface Collectable { - item: Item; - skillReqs?: Skills; - itemCost?: Bank; - quantity: number; - duration: number; - qpRequired?: number; -} - -export const collectables: Collectable[] = [ - { - item: getOSItem('Blue dragon scale'), - quantity: 26, - itemCost: new Bank({ - 'Water rune': 1, - 'Air rune': 3, - 'Law rune': 1 - }), - skillReqs: { - agility: 70, - magic: 37 - }, - duration: Time.Minute * 2 - }, - { - item: getOSItem('Mort myre fungus'), - quantity: 100, - itemCost: new Bank({ - 'Prayer potion(4)': 1, - 'Ring of dueling(8)': 1 - }), - skillReqs: { - prayer: 50 - }, - duration: Time.Minute * 8.3, - qpRequired: 32 - }, - { - item: getOSItem('Flax'), - quantity: 28, - duration: Time.Minute * 1.68 - }, - { - item: getOSItem('Swamp toad'), - quantity: 28, - duration: Time.Minute * 1.68 - }, - { - item: getOSItem("Red spiders' eggs"), - quantity: 80, - itemCost: new Bank({ - 'Stamina potion(4)': 1 - }), - duration: Time.Minute * 8.5 - }, - { - item: getOSItem('Wine of zamorak'), - quantity: 27, - itemCost: new Bank({ - 'Law rune': 27, - 'Air rune': 27 - }), - skillReqs: { - magic: 33 - }, - duration: Time.Minute * 3.12 - }, - { - item: getOSItem('White berries'), - quantity: 27, - qpRequired: 22, - skillReqs: { - ranged: 60, - thieving: 50, - agility: 56, - crafting: 10, - fletching: 5, - cooking: 30 - }, - duration: Time.Minute * 4.05 - }, - { - item: getOSItem('Snape grass'), - quantity: 120, - itemCost: new Bank({ - 'Law rune': 12, - 'Astral rune': 12 - }), - duration: Time.Minute * 6.5, - qpRequired: 72 - }, - { - item: getOSItem('Snake weed'), - quantity: 150, - itemCost: new Bank({ - 'Ring of dueling(8)': 1 - }), - duration: Time.Minute * 30, - qpRequired: 3 - }, - { - item: getOSItem('Bucket of sand'), - quantity: 30, - itemCost: new Bank({ - 'Law rune': 1, - Coins: 30 * 25 - }), - duration: Time.Minute, - qpRequired: 30 - }, - { - item: getOSItem('Jangerberries'), - quantity: 224, - itemCost: new Bank({ - 'Ring of dueling(8)': 1 - }), - skillReqs: { - agility: 10 - }, - duration: Time.Minute * 24 - }, - // Miniquest to get Tarn's diary for Salve amulet (e)/(ei) - { - item: getOSItem("Tarn's diary"), - quantity: 1, - itemCost: new Bank({ - 'Prayer potion(4)': 2 - }), - skillReqs: { - slayer: 40, - attack: 60, - strength: 60, - ranged: 60, - defence: 60, - magic: 60 - }, - duration: 10 * Time.Minute, - qpRequired: 100 - } -]; - export async function collectCommand( user: MUser, channelID: string, diff --git a/src/mahoji/lib/abstracted_commands/statCommand.ts b/src/mahoji/lib/abstracted_commands/statCommand.ts index 8d4092a356..43c81d0ce0 100644 --- a/src/mahoji/lib/abstracted_commands/statCommand.ts +++ b/src/mahoji/lib/abstracted_commands/statCommand.ts @@ -30,7 +30,7 @@ import { createChart } from '../../../lib/util/chart'; import { getItem } from '../../../lib/util/getOSItem'; import { makeBankImage } from '../../../lib/util/makeBankImage'; import { Cooldowns } from '../Cooldowns'; -import { collectables } from './collectCommand'; +import { collectables } from '../collectables'; interface DataPiece { name: string; diff --git a/src/mahoji/lib/collectables.ts b/src/mahoji/lib/collectables.ts new file mode 100644 index 0000000000..28f0775291 --- /dev/null +++ b/src/mahoji/lib/collectables.ts @@ -0,0 +1,146 @@ +import { Time } from 'e'; +import { Bank } from 'oldschooljs'; +import type { Item } from 'oldschooljs/dist/meta/types'; +import type { Skills } from '../../lib/types'; +import getOSItem from '../../lib/util/getOSItem'; + +interface Collectable { + item: Item; + skillReqs?: Skills; + itemCost?: Bank; + quantity: number; + duration: number; + qpRequired?: number; +} + +export const collectables: Collectable[] = [ + { + item: getOSItem('Blue dragon scale'), + quantity: 26, + itemCost: new Bank({ + 'Water rune': 1, + 'Air rune': 3, + 'Law rune': 1 + }), + skillReqs: { + agility: 70, + magic: 37 + }, + duration: Time.Minute * 2 + }, + { + item: getOSItem('Mort myre fungus'), + quantity: 100, + itemCost: new Bank({ + 'Prayer potion(4)': 1, + 'Ring of dueling(8)': 1 + }), + skillReqs: { + prayer: 50 + }, + duration: Time.Minute * 8.3, + qpRequired: 32 + }, + { + item: getOSItem('Flax'), + quantity: 28, + duration: Time.Minute * 1.68 + }, + { + item: getOSItem('Swamp toad'), + quantity: 28, + duration: Time.Minute * 1.68 + }, + { + item: getOSItem("Red spiders' eggs"), + quantity: 80, + itemCost: new Bank({ + 'Stamina potion(4)': 1 + }), + duration: Time.Minute * 8.5 + }, + { + item: getOSItem('Wine of zamorak'), + quantity: 27, + itemCost: new Bank({ + 'Law rune': 27, + 'Air rune': 27 + }), + skillReqs: { + magic: 33 + }, + duration: Time.Minute * 3.12 + }, + { + item: getOSItem('White berries'), + quantity: 27, + qpRequired: 22, + skillReqs: { + ranged: 60, + thieving: 50, + agility: 56, + crafting: 10, + fletching: 5, + cooking: 30 + }, + duration: Time.Minute * 4.05 + }, + { + item: getOSItem('Snape grass'), + quantity: 120, + itemCost: new Bank({ + 'Law rune': 12, + 'Astral rune': 12 + }), + duration: Time.Minute * 6.5, + qpRequired: 72 + }, + { + item: getOSItem('Snake weed'), + quantity: 150, + itemCost: new Bank({ + 'Ring of dueling(8)': 1 + }), + duration: Time.Minute * 30, + qpRequired: 3 + }, + { + item: getOSItem('Bucket of sand'), + quantity: 30, + itemCost: new Bank({ + 'Law rune': 1, + Coins: 30 * 25 + }), + duration: Time.Minute, + qpRequired: 30 + }, + { + item: getOSItem('Jangerberries'), + quantity: 224, + itemCost: new Bank({ + 'Ring of dueling(8)': 1 + }), + skillReqs: { + agility: 10 + }, + duration: Time.Minute * 24 + }, + // Miniquest to get Tarn's diary for Salve amulet (e)/(ei) + { + item: getOSItem("Tarn's diary"), + quantity: 1, + itemCost: new Bank({ + 'Prayer potion(4)': 2 + }), + skillReqs: { + slayer: 40, + attack: 60, + strength: 60, + ranged: 60, + defence: 60, + magic: 60 + }, + duration: 10 * Time.Minute, + qpRequired: 100 + } +]; diff --git a/src/mahoji/lib/inhibitors.ts b/src/mahoji/lib/inhibitors.ts index e781587709..d6c53f9f76 100644 --- a/src/mahoji/lib/inhibitors.ts +++ b/src/mahoji/lib/inhibitors.ts @@ -5,7 +5,7 @@ import { ComponentType, PermissionsBitField } from 'discord.js'; import { OWNER_IDS, SupportServer } from '../../config'; import { BLACKLISTED_GUILDS, BLACKLISTED_USERS } from '../../lib/blacklists'; import { BadgesEnum, BitField, Channel, DISABLED_COMMANDS, minionBuyButton } from '../../lib/constants'; -import { getPerkTierSync } from '../../lib/roboChimp'; +import { getPerkTierSync } from '../../lib/perkTier'; import type { CategoryFlag } from '../../lib/types'; import { minionIsBusy } from '../../lib/util/minionIsBusy'; import { mahojiGuildSettingsFetch, untrustedGuildSettingsCache } from '../guildSettings'; diff --git a/src/tasks/minions/collectingActivity.ts b/src/tasks/minions/collectingActivity.ts index b4b7d65865..8f9f1557f1 100644 --- a/src/tasks/minions/collectingActivity.ts +++ b/src/tasks/minions/collectingActivity.ts @@ -5,7 +5,7 @@ import { MorytaniaDiary, userhasDiaryTier } from '../../lib/diaries'; import type { CollectingOptions } from '../../lib/types/minions'; import { handleTripFinish } from '../../lib/util/handleTripFinish'; import { updateBankSetting } from '../../lib/util/updateBankSetting'; -import { collectables } from '../../mahoji/lib/abstracted_commands/collectCommand'; +import { collectables } from '../../mahoji/lib/collectables'; export const collectingTask: MinionTask = { type: 'Collecting', diff --git a/tests/integration/redis.test.ts b/tests/integration/redis.test.ts index 1bb270439c..34e9e32598 100644 --- a/tests/integration/redis.test.ts +++ b/tests/integration/redis.test.ts @@ -3,8 +3,8 @@ import { expect, test } from 'vitest'; import { TSRedis } from '@oldschoolgg/toolkit/TSRedis'; import { sleep } from 'e'; import { BadgesEnum, BitField, globalConfig } from '../../src/lib/constants'; +import { getPerkTierSync, roboChimpCache } from '../../src/lib/perkTier'; import { getUsersPerkTier } from '../../src/lib/perkTiers'; -import { getPerkTierSync, roboChimpCache } from '../../src/lib/roboChimp'; import { createTestUser } from './util'; function makeSender() { From 57859fcd2b4df265d686df2b4b7930039b4f345d Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Tue, 16 Jul 2024 16:04:44 +1000 Subject: [PATCH 064/145] Patron fixes --- package.json | 2 +- src/lib/perkTier.ts | 33 --------------------------------- src/lib/perkTiers.ts | 18 +++++++++--------- src/mahoji/lib/inhibitors.ts | 3 +-- tests/integration/redis.test.ts | 13 +++++++++---- 5 files changed, 20 insertions(+), 49 deletions(-) diff --git a/package.json b/package.json index 62d25f4bb0..eb0b66aeb6 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "watch:tsc": "tsc -w -p src", "wipedist": "node -e \"try { require('fs').rmSync('dist', { recursive: true }) } catch(_){}\"", "cleanup": "yarn && yarn wipedist && yarn lint && yarn build && yarn test && npm i -g dpdm && dpdm --exit-code circular:1 --progress=false --warning=false --tree=false ./dist/index.js", - "test": "concurrently --raw --kill-others-on-fail \"tsc -p src\" \"yarn test:lint\" \"yarn test:unit\" \"yarn build:esbuild\"", + "test": "concurrently --raw --kill-others-on-fail \"tsc -p src && yarn test:circular\" \"yarn test:lint\" \"yarn test:unit\"", "test:lint": "biome check --diagnostic-level=error", "test:unit": "vitest run --coverage --config vitest.unit.config.mts", "test:docker": "docker-compose up --build --abort-on-container-exit", diff --git a/src/lib/perkTier.ts b/src/lib/perkTier.ts index 8b70c2f3df..a4ccaffc4b 100644 --- a/src/lib/perkTier.ts +++ b/src/lib/perkTier.ts @@ -1,7 +1,4 @@ -import { PerkTier } from '@oldschoolgg/toolkit'; import { pick } from 'lodash'; -import { SupportServer } from '../config'; -import { BitField, Roles } from './constants'; import type { RobochimpUser } from './roboChimp'; const robochimpCachedKeys = [ @@ -45,33 +42,3 @@ export function cacheRoboChimpUser(user: RobochimpUser) { if (user.perk_tier === 0) return; roboChimpCache.set(user.id.toString(), pick(user, robochimpCachedKeys)); } - -export function getPerkTierSync(user: string | MUser) { - const elligibleTiers = []; - if (typeof user !== 'string') { - if ( - [BitField.isContributor, BitField.isModerator, BitField.IsWikiContributor].some(bit => - user.bitfield.includes(bit) - ) - ) { - return PerkTier.Four; - } - - if ( - user.bitfield.includes(BitField.IsPatronTier1) || - user.bitfield.includes(BitField.HasPermanentTierOne) || - user.bitfield.includes(BitField.BothBotsMaxedFreeTierOnePerks) - ) { - elligibleTiers.push(PerkTier.Two); - } else { - const guild = globalClient.guilds.cache.get(SupportServer); - const member = guild?.members.cache.get(user.id); - if (member && [Roles.Booster].some(roleID => member.roles.cache.has(roleID))) { - elligibleTiers.push(PerkTier.One); - } - } - } - - elligibleTiers.push(roboChimpCache.get(typeof user === 'string' ? user : user.id)?.perk_tier ?? 0); - return Math.max(...elligibleTiers); -} diff --git a/src/lib/perkTiers.ts b/src/lib/perkTiers.ts index 7a62aeb247..025b1814af 100644 --- a/src/lib/perkTiers.ts +++ b/src/lib/perkTiers.ts @@ -1,6 +1,6 @@ import { SupportServer } from '../config'; import { BitField, PerkTier, Roles } from './constants'; -import { getPerkTierSync } from './perkTier'; +import { roboChimpCache } from './perkTier'; export const allPerkBitfields: BitField[] = [ BitField.IsPatronTier6, @@ -37,31 +37,31 @@ export function getUsersPerkTier(user: MUser): PerkTier | 0 { } } - const cached = getPerkTierSync(user.id); - if (cached > 0) { - elligibleTiers.push(cached); + const roboChimpCached = roboChimpCache.get(user.id); + if (roboChimpCached) { + elligibleTiers.push(roboChimpCached.perk_tier); } const bitfield = user.bitfield; if (bitfield.includes(BitField.IsPatronTier6)) { - return elligibleTiers.push(PerkTier.Seven); + elligibleTiers.push(PerkTier.Seven); } if (bitfield.includes(BitField.IsPatronTier5)) { - return elligibleTiers.push(PerkTier.Six); + elligibleTiers.push(PerkTier.Six); } if (bitfield.includes(BitField.IsPatronTier4)) { - return elligibleTiers.push(PerkTier.Five); + elligibleTiers.push(PerkTier.Five); } if (bitfield.includes(BitField.IsPatronTier3)) { - return elligibleTiers.push(PerkTier.Four); + elligibleTiers.push(PerkTier.Four); } if (bitfield.includes(BitField.IsPatronTier2)) { - return elligibleTiers.push(PerkTier.Three); + elligibleTiers.push(PerkTier.Three); } return Math.max(...elligibleTiers, 0); diff --git a/src/mahoji/lib/inhibitors.ts b/src/mahoji/lib/inhibitors.ts index d6c53f9f76..ed7430f148 100644 --- a/src/mahoji/lib/inhibitors.ts +++ b/src/mahoji/lib/inhibitors.ts @@ -5,7 +5,6 @@ import { ComponentType, PermissionsBitField } from 'discord.js'; import { OWNER_IDS, SupportServer } from '../../config'; import { BLACKLISTED_GUILDS, BLACKLISTED_USERS } from '../../lib/blacklists'; import { BadgesEnum, BitField, Channel, DISABLED_COMMANDS, minionBuyButton } from '../../lib/constants'; -import { getPerkTierSync } from '../../lib/perkTier'; import type { CategoryFlag } from '../../lib/types'; import { minionIsBusy } from '../../lib/util/minionIsBusy'; import { mahojiGuildSettingsFetch, untrustedGuildSettingsCache } from '../guildSettings'; @@ -142,7 +141,7 @@ const inhibitors: Inhibitor[] = [ run: async ({ member, guild, channel, user }) => { if (!guild || guild.id !== SupportServer) return false; if (channel.id !== Channel.General) return false; - const perkTier = getPerkTierSync(user.id); + const perkTier = user.perkTier(); if (member && perkTier >= PerkTier.Two) { return false; } diff --git a/tests/integration/redis.test.ts b/tests/integration/redis.test.ts index 34e9e32598..ead9262f7e 100644 --- a/tests/integration/redis.test.ts +++ b/tests/integration/redis.test.ts @@ -3,7 +3,7 @@ import { expect, test } from 'vitest'; import { TSRedis } from '@oldschoolgg/toolkit/TSRedis'; import { sleep } from 'e'; import { BadgesEnum, BitField, globalConfig } from '../../src/lib/constants'; -import { getPerkTierSync, roboChimpCache } from '../../src/lib/perkTier'; +import { roboChimpCache } from '../../src/lib/perkTier'; import { getUsersPerkTier } from '../../src/lib/perkTiers'; import { createTestUser } from './util'; @@ -62,7 +62,7 @@ test.concurrent('Should add to cache', async () => { await sleep(250); for (const user of users) { const cached = roboChimpCache.get(user.id); - expect(getPerkTierSync(user.id)).toEqual(5); + expect(getUsersPerkTier(user)).toEqual(5); expect(cached!.perk_tier).toEqual(5); } }); @@ -85,7 +85,7 @@ test.concurrent('Should remove from cache', async () => { }); await sleep(250); for (const user of users) { - expect(getPerkTierSync(user.id)).toEqual(0); + expect(getUsersPerkTier(user)).toEqual(0); const cached = roboChimpCache.get(user.id); expect(cached).toEqual(undefined); } @@ -98,6 +98,11 @@ test.concurrent('Should recognize special bitfields', async () => { ]; for (const user of users) { expect(getUsersPerkTier(user)).toEqual(2); - expect(getPerkTierSync(user)).toEqual(2); } }); + +test.concurrent('Should sdffsddfss', async () => { + const user = await createTestUser(); + roboChimpCache.set(user.id, { perk_tier: 5 } as any); + expect(getUsersPerkTier(user)).toEqual(5); +}); From edfbbe52bfe12d2d1b9e35279bd9c55d11f9744b Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Tue, 16 Jul 2024 21:11:20 +1000 Subject: [PATCH 065/145] Change ratelimiting on global interactions --- src/lib/util/globalInteractions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/util/globalInteractions.ts b/src/lib/util/globalInteractions.ts index 7fad4d2234..1d3de71ea7 100644 --- a/src/lib/util/globalInteractions.ts +++ b/src/lib/util/globalInteractions.ts @@ -55,7 +55,7 @@ function isValidGlobalInteraction(str: string): str is GlobalInteractionAction { return globalInteractionActions.includes(str as GlobalInteractionAction); } -const buttonRatelimiter = new RateLimitManager(Time.Second * 5, 2); +const buttonRatelimiter = new RateLimitManager(Time.Second * 2, 1); export function makeOpenCasketButton(tier: ClueTier) { const name: Uppercase = tier.name.toUpperCase() as Uppercase; From 85823ef4de2d8d3f679b77d943f7816468aa31db Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Tue, 16 Jul 2024 21:11:30 +1000 Subject: [PATCH 066/145] Dont log metric collecting --- src/lib/metrics.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/lib/metrics.ts b/src/lib/metrics.ts index ede0f2c20b..9bf21dfa47 100644 --- a/src/lib/metrics.ts +++ b/src/lib/metrics.ts @@ -69,7 +69,6 @@ export async function collectMetrics() { prisma_query_active_transactions: transformed.query_active_transactions as number }; h.reset(); - debugLog('Collected metrics', { ...metrics, type: 'COLLECT_METRICS' }); return metrics; } From 54ceb139fea92299020543d79cb33848ee95430b Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Tue, 16 Jul 2024 21:36:10 +1000 Subject: [PATCH 067/145] Capture stack traces in user queue functions --- src/lib/util/userQueues.ts | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/src/lib/util/userQueues.ts b/src/lib/util/userQueues.ts index 19bc1d041f..1d0cc83f2a 100644 --- a/src/lib/util/userQueues.ts +++ b/src/lib/util/userQueues.ts @@ -11,17 +11,16 @@ function getUserUpdateQueue(userID: string) { return currentQueue; } -export async function userQueueFn(userID: string, fn: () => Promise) { +export async function userQueueFn(userID: string, fn: () => Promise): Promise { const queue = getUserUpdateQueue(userID); return new Promise((resolve, reject) => { - queue.add(async () => { - try { - const result = await fn(); - resolve(result); - } catch (e) { - console.error(e); - reject(e); - } + queue.add(() => { + return fn() + .then(resolve) + .catch(e => { + console.error(e); + reject(e); + }); }); }); } From d15c2cbcff0d73960008737ba26417cf9087d4f9 Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Tue, 16 Jul 2024 21:36:38 +1000 Subject: [PATCH 068/145] Fix wildy food error --- src/lib/minions/functions/removeFoodFromUser.ts | 3 +-- src/mahoji/lib/abstracted_commands/minionKill.ts | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/lib/minions/functions/removeFoodFromUser.ts b/src/lib/minions/functions/removeFoodFromUser.ts index 9c621544ae..5aabe7ebee 100644 --- a/src/lib/minions/functions/removeFoodFromUser.ts +++ b/src/lib/minions/functions/removeFoodFromUser.ts @@ -67,8 +67,7 @@ export default async function removeFoodFromUser({ ); } else { await transactItems({ userID: user.id, itemsToRemove: foodToRemove }); - - updateBankSetting('economyStats_PVMCost', foodToRemove); + await updateBankSetting('economyStats_PVMCost', foodToRemove); return { foodRemoved: foodToRemove, diff --git a/src/mahoji/lib/abstracted_commands/minionKill.ts b/src/mahoji/lib/abstracted_commands/minionKill.ts index d1104807b5..94bd7ad621 100644 --- a/src/mahoji/lib/abstracted_commands/minionKill.ts +++ b/src/mahoji/lib/abstracted_commands/minionKill.ts @@ -823,7 +823,7 @@ export async function minionKillCommand( } else { antiPKSupplies.add('Super restore(4)', antiPkRestoresNeeded); } - if (user.bank.amount('Blighted karambwan') >= antiPkKarambwanNeeded) { + if (user.bank.amount('Blighted karambwan') >= antiPkKarambwanNeeded + 20) { antiPKSupplies.add('Blighted karambwan', antiPkKarambwanNeeded); } else { antiPKSupplies.add('Cooked karambwan', antiPkKarambwanNeeded); @@ -927,7 +927,7 @@ export async function minionKillCommand( // Remove items after food calc to prevent losing items if the user doesn't have the right amount of food. Example: Mossy key if (lootToRemove.length > 0) { - updateBankSetting('economyStats_PVMCost', lootToRemove); + await updateBankSetting('economyStats_PVMCost', lootToRemove); await user.specialRemoveItems(lootToRemove, { wildy: !!isInWilderness }); totalCost.add(lootToRemove); } From f7632c89418f45712b5d95378d2bc3006842761c Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Tue, 16 Jul 2024 21:49:43 +1000 Subject: [PATCH 069/145] Fix badges error --- src/lib/util.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/lib/util.ts b/src/lib/util.ts index 0f0ab2af7e..1822cb31ac 100644 --- a/src/lib/util.ts +++ b/src/lib/util.ts @@ -22,7 +22,7 @@ import { LRUCache } from 'lru-cache'; import { ADMIN_IDS, OWNER_IDS, SupportServer } from '../config'; import type { MUserClass } from './MUser'; import { PaginatedMessage } from './PaginatedMessage'; -import { BOT_TYPE_LOWERCASE, BitField, globalConfig, projectiles } from './constants'; +import { BitField, globalConfig, projectiles } from './constants'; import { getSimilarItems } from './data/similarItems'; import type { DefenceGearStat, GearSetupType, OffenceGearStat } from './gear/types'; import { GearSetupTypes, GearStat } from './gear/types'; @@ -40,6 +40,7 @@ import type { } from './types/minions'; import { getItem } from './util/getOSItem'; import itemID from './util/itemID'; +import { makeBadgeString } from './util/makeBadgeString'; import { itemNameFromID } from './util/smallUtils'; export * from '@oldschoolgg/toolkit'; @@ -331,8 +332,6 @@ export function skillingPetDropRate( return { petDropRate: dropRate }; } -const badgesKey = `${BOT_TYPE_LOWERCASE.toLowerCase()}_badges` as 'osb_badges' | 'bso_badges'; - const usernameWithBadgesCache = new LRUCache({ max: 2000 }); export async function getUsername(_id: string | bigint): Promise { @@ -345,15 +344,17 @@ export async function getUsername(_id: string | bigint): Promise { }, select: { username: true, - [badgesKey]: true + badges: true, + minion_ironman: true } }); if (!user?.username) return 'Unknown'; - const badges = user[badgesKey]; + const badges = makeBadgeString(user.badges, user.minion_ironman); const newValue = `${badges ? `${badges} ` : ''}${user.username}`; usernameWithBadgesCache.set(id, newValue); return newValue; } + export function getUsernameSync(_id: string | bigint) { return usernameWithBadgesCache.get(_id.toString()) ?? 'Unknown'; } From b7d4e68d14f0c630c4f3579328064e04eeb38022 Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Thu, 18 Jul 2024 15:07:01 +1000 Subject: [PATCH 070/145] Remove unused db columns, add command name enum --- prisma/schema.prisma | 278 +++++++++++++++++++++++++++++++++- src/lib/events.ts | 1 - src/lib/startupScripts.ts | 2 - src/lib/util/cachedUserIDs.ts | 19 ++- src/lib/util/commandUsage.ts | 3 - src/mahoji/commands/rp.ts | 78 ---------- src/mahoji/lib/postCommand.ts | 1 - src/mahoji/lib/preCommand.ts | 2 - 8 files changed, 279 insertions(+), 105 deletions(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 83d0ae4762..0eb8e34fd9 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -60,9 +60,6 @@ model Analytic { model ClientStorage { id String @id @db.VarChar(19) - userBlacklist String[] @default([]) @db.VarChar(19) - guildBlacklist String[] @default([]) @db.VarChar(19) - commandStats Json @default("{}") @db.Json totalCommandsUsed Int @default(0) prices Json @default("{}") @db.Json sold_items_bank Json @default("{}") @db.Json @@ -215,9 +212,7 @@ model Giveaway { model Guild { id String @id @db.VarChar(19) disabledCommands String[] @default([]) - jmodComments String? @db.VarChar(19) petchannel String? @db.VarChar(19) - tweetchannel String? @db.VarChar(19) staffOnlyChannels String[] @default([]) @db.VarChar(19) @@map("guilds") @@ -309,8 +304,6 @@ model User { bank Json @default("{}") @db.Json collectionLogBank Json @default("{}") @db.JsonB blowpipe Json @default("{\"scales\":0,\"dartID\":null,\"dartQuantity\":0}") @db.Json - ironman_alts String[] @default([]) - main_account String? slayer_unlocks Int[] @default([]) @map("slayer.unlocks") slayer_blocked_ids Int[] @default([]) @map("slayer.blocked_ids") slayer_last_task Int @default(0) @map("slayer.last_task") @@ -590,7 +583,6 @@ model CommandUsage { user_id BigInt command_name String @db.VarChar(32) is_continue Boolean @default(false) - flags Json? inhibited Boolean? @default(false) is_mention_command Boolean @default(false) @@ -1151,3 +1143,273 @@ model UserEvent { @@map("user_event") } + +enum command_name_enum { + testpotato + achievementdiary + activities + admin + aerialfish + agilityarena + alch + amrod + ash + ask + autoequip + autoslay + bal + bank + bankbg + barbassault + bgcolor + bingo + birdhouse + blastfurnace + blowpipe + bossrecords + botleagues + botstats + bs + bso + build + bury + buy + ca + cancel + capegamble + cash + casket + cast + castlewars + cd + championchallenge + channel + chargeglories + chargewealth + checkmasses + checkpatch + chompyhunt + choose + chop + christmas + cl + claim + clbank + clue + clues + cmd + collect + collectionlog + combat + combatoptions + compostbin + config + cook + cox + cracker + craft + create + daily + darkaltar + data + decant + defaultfarming + defender + diary + dice + dicebank + disable + dmm + driftnet + drop + drycalc + drystreak + duel + easter + economybank + emotes + enable + enchant + equip + eval + fake + fakearma + fakebandos + fakeely + fakepm + fakesara + fakescythe + fakezammy + faq + farm + farming + farmingcontract + favalch + favfood + favorite + favour + fightcaves + finish + fish + fishingtrawler + fletch + gamble + gauntlet + ge + gear + gearpresets + gearstats + gift + github + giveaway + gnomerestaurant + gp + groupkill + halloween + hans + harvest + hcim + hcimdeaths + help + hiscores + hunt + inbank + inferno + info + invite + ironman + is + itemtrivia + jmodcomments + jmodtweets + k + kc + kcgains + kill + lamp + lapcount + laps + lastmanstanding + lb + leaderboard + leagues + light + lms + loot + love + luckyimp + luckypick + lvl + m + magearena + magearena2 + mahoganyhomes + mass + mclue + mine + minigames + minion + minionstats + mix + monster + mostdrops + mta + mygiveaways + mypets + news + nightmare + offer + open + osrskc + patreon + pay + pestcontrol + pet + petmessages + petrate + petroll + pickpocket + ping + players + plunder + poh + poll + polls + prefix + price + pvp + quest + raid + randomevents + randquote + ranks + rc + redeem + reload + resetrng + revs + roguesden + roles + roll + rp + runecraft + runelite + s + sacrifice + sacrificedbank + sacrificegp + sacrificelog + sawmill + seedpack + sell + sellto + sendtoabutton + sepulchre + server + setrsn + shutdownlock + simulate + skillcape + slayer + slayershop + slayertask + smelt + smith + soulwars + stats + steal + streamertweets + support + tag + tearsofguthix + tempoross + tithefarm + tithefarmshop + tob + tokkulshop + tools + trade + train + trek + trekshop + trickortreat + trivia + tweets + uim + unequip + unequipall + use + user + virtualstats + volcanicmine + warriorsguild + wiki + wintertodt + world + wt + wyson + xp + xpgains + xpto99 + zalcano +} diff --git a/src/lib/events.ts b/src/lib/events.ts index c3141b9b2e..eb54efbf12 100644 --- a/src/lib/events.ts +++ b/src/lib/events.ts @@ -331,7 +331,6 @@ export async function onMessage(msg: Message) { guild_id: msg.guildId ? BigInt(msg.guildId) : undefined, command_name: command.name, args: msgContentWithoutCommand, - flags: undefined, inhibited: false, is_mention_command: true } diff --git a/src/lib/startupScripts.ts b/src/lib/startupScripts.ts index eb27427558..fb9af49e87 100644 --- a/src/lib/startupScripts.ts +++ b/src/lib/startupScripts.ts @@ -3,8 +3,6 @@ import { Items } from 'oldschooljs'; const startupScripts: { sql: string; ignoreErrors?: true }[] = []; const arrayColumns = [ - ['clientStorage', 'userBlacklist'], - ['clientStorage', 'guildBlacklist'], ['guilds', 'disabledCommands'], ['guilds', 'staffOnlyChannels'], ['users', 'badges'], diff --git a/src/lib/util/cachedUserIDs.ts b/src/lib/util/cachedUserIDs.ts index bf2d394118..492c0450e1 100644 --- a/src/lib/util/cachedUserIDs.ts +++ b/src/lib/util/cachedUserIDs.ts @@ -11,18 +11,17 @@ CACHED_ACTIVE_USER_IDS.add(globalConfig.clientID); for (const id of OWNER_IDS) CACHED_ACTIVE_USER_IDS.add(id); export async function syncActiveUserIDs() { - const [users, otherUsers] = await prisma.$transaction([ - prisma.$queryRawUnsafe<{ user_id: string }[]>(`SELECT DISTINCT(${Prisma.ActivityScalarFieldEnum.user_id}::text) + const users = await prisma.$queryRawUnsafe< + { user_id: string }[] + >(`SELECT DISTINCT(${Prisma.ActivityScalarFieldEnum.user_id}::text) FROM activity -WHERE finish_date > now() - INTERVAL '48 hours'`), - prisma.$queryRawUnsafe<{ id: string }[]>(`SELECT id -FROM users -WHERE ${Prisma.UserScalarFieldEnum.main_account} IS NOT NULL -OR CARDINALITY(${Prisma.UserScalarFieldEnum.ironman_alts}) > 0 -OR ${Prisma.UserScalarFieldEnum.bitfield} && ARRAY[2,3,4,5,6,7,8,12,11,21,19];`) - ]); +WHERE finish_date > now() - INTERVAL '48 hours'`); - for (const id of [...users.map(i => i.user_id), ...otherUsers.map(i => i.id)]) { + const perkTierUsers = await roboChimpClient.$queryRawUnsafe<{ id: string }[]>(`SELECT id::text +FROM "user" +WHERE perk_tier > 0;`); + + for (const id of [...users.map(i => i.user_id), ...perkTierUsers.map(i => i.id)]) { CACHED_ACTIVE_USER_IDS.add(id); } debugLog(`${CACHED_ACTIVE_USER_IDS.size} cached active user IDs`); diff --git a/src/lib/util/commandUsage.ts b/src/lib/util/commandUsage.ts index 365ec7b132..57036cf431 100644 --- a/src/lib/util/commandUsage.ts +++ b/src/lib/util/commandUsage.ts @@ -7,7 +7,6 @@ export function makeCommandUsage({ userID, channelID, guildID, - flags, commandName, args, isContinue, @@ -17,7 +16,6 @@ export function makeCommandUsage({ userID: string | bigint; channelID: string | bigint; guildID?: string | bigint | null; - flags: null | Record; commandName: string; args: CommandOptions; isContinue: null | boolean; @@ -30,7 +28,6 @@ export function makeCommandUsage({ args: getCommandArgs(commandName, args), channel_id: BigInt(channelID), guild_id: guildID ? BigInt(guildID) : null, - flags: flags ? (Object.keys(flags).length > 0 ? flags : undefined) : undefined, is_continue: isContinue ?? undefined, inhibited, continue_delta_millis: continueDeltaMillis diff --git a/src/mahoji/commands/rp.ts b/src/mahoji/commands/rp.ts index cc6aa846b2..b6499664b8 100644 --- a/src/mahoji/commands/rp.ts +++ b/src/mahoji/commands/rp.ts @@ -11,7 +11,6 @@ import { Bank } from 'oldschooljs'; import type { Item } from 'oldschooljs/dist/meta/types'; import { ADMIN_IDS, OWNER_IDS, SupportServer, production } from '../../config'; -import { mahojiUserSettingsUpdate } from '../../lib/MUser'; import { analyticsTick } from '../../lib/analytics'; import { BitField, Channel } from '../../lib/constants'; import { allCollectionLogsFlat } from '../../lib/data/Collections'; @@ -38,7 +37,6 @@ import { sendToChannelID } from '../../lib/util/webhook'; import { cancelUsersListings } from '../lib/abstracted_commands/cancelGEListingCommand'; import { gearSetupOption } from '../lib/mahojiCommandOptions'; import type { OSBMahojiCommand } from '../lib/util'; -import { mahojiUsersSettingsFetch } from '../mahojiSettings'; import { gifs } from './admin'; import { getUserInfo } from './minion'; import { sellPriceOfItem } from './sell'; @@ -326,25 +324,6 @@ export const rpCommand: OSBMahojiCommand = { } ] }, - { - type: ApplicationCommandOptionType.Subcommand, - name: 'add_ironman_alt', - description: 'Add an ironman alt account for a user', - options: [ - { - type: ApplicationCommandOptionType.User, - name: 'main', - description: 'The main', - required: true - }, - { - type: ApplicationCommandOptionType.User, - name: 'ironman_alt', - description: 'The ironman alt', - required: true - } - ] - }, { type: ApplicationCommandOptionType.Subcommand, name: 'view_user', @@ -569,7 +548,6 @@ export const rpCommand: OSBMahojiCommand = { user: MahojiUserOption; message_id: string; }; - add_ironman_alt?: { main: MahojiUserOption; ironman_alt: MahojiUserOption }; view_user?: { user: MahojiUserOption }; migrate_user?: { source: MahojiUserOption; dest: MahojiUserOption; reason?: string }; list_trades?: { @@ -824,62 +802,6 @@ ORDER BY item_id ASC;`); if (!toDelete) await adminUser.addItemsToBank({ items, collectionLog: false }); return `${toTitleCase(actionMsgPast)} ${items.toString().slice(0, 500)} from ${userToStealFrom.mention}`; } - if (options.player?.add_ironman_alt) { - const mainAccount = await mahojiUsersSettingsFetch(options.player.add_ironman_alt.main.user.id, { - minion_ironman: true, - id: true, - ironman_alts: true, - main_account: true - }); - const altAccount = await mahojiUsersSettingsFetch(options.player.add_ironman_alt.ironman_alt.user.id, { - minion_ironman: true, - bitfield: true, - id: true, - ironman_alts: true, - main_account: true - }); - const mainUser = await mUserFetch(mainAccount.id); - const altUser = await mUserFetch(altAccount.id); - if (mainAccount === altAccount) return "They're they same account."; - if (mainAccount.minion_ironman) return `${mainUser.usernameOrMention} is an ironman.`; - if (!altAccount.minion_ironman) return `${altUser.usernameOrMention} is not an ironman.`; - if (!altAccount.bitfield.includes(BitField.PermanentIronman)) { - return `${altUser.usernameOrMention} is not a *permanent* ironman.`; - } - - const peopleWithThisAltAlready = ( - await prisma.$queryRawUnsafe( - `SELECT id FROM users WHERE '${altAccount.id}' = ANY(ironman_alts);` - ) - ).length; - if (peopleWithThisAltAlready > 0) { - return `Someone already has ${altUser.usernameOrMention} as an ironman alt.`; - } - if (mainAccount.main_account) { - return `${mainUser.usernameOrMention} has a main account connected already.`; - } - if (altAccount.main_account) { - return `${altUser.usernameOrMention} has a main account connected already.`; - } - const mainAccountsAlts = mainAccount.ironman_alts; - if (mainAccountsAlts.includes(altAccount.id)) { - return `${mainUser.usernameOrMention} already has ${altUser.usernameOrMention} as an alt.`; - } - - await handleMahojiConfirmation( - interaction, - `Are you sure that \`${altUser.usernameOrMention}\` is the alt account of \`${mainUser.usernameOrMention}\`?` - ); - await mahojiUserSettingsUpdate(mainAccount.id, { - ironman_alts: { - push: altAccount.id - } - }); - await mahojiUserSettingsUpdate(altAccount.id, { - main_account: mainAccount.id - }); - return `You set \`${altUser.usernameOrMention}\` as the alt account of \`${mainUser.usernameOrMention}\`.`; - } if (options.player?.view_user) { const userToView = await mUserFetch(options.player.view_user.user.user.id); diff --git a/src/mahoji/lib/postCommand.ts b/src/mahoji/lib/postCommand.ts index a602a345f4..ffacdddc34 100644 --- a/src/mahoji/lib/postCommand.ts +++ b/src/mahoji/lib/postCommand.ts @@ -38,7 +38,6 @@ export async function postCommand({ commandName: abstractCommand.name, args, isContinue, - flags: null, inhibited, continueDeltaMillis }); diff --git a/src/mahoji/lib/preCommand.ts b/src/mahoji/lib/preCommand.ts index d8b1a96a0c..bea34d3e00 100644 --- a/src/mahoji/lib/preCommand.ts +++ b/src/mahoji/lib/preCommand.ts @@ -4,7 +4,6 @@ import type { InteractionReplyOptions, TextChannel, User } from 'discord.js'; import { modifyBusyCounter, userIsBusy } from '../../lib/busyCounterCache'; import { busyImmuneCommands } from '../../lib/constants'; -import { CACHED_ACTIVE_USER_IDS } from '../../lib/util/cachedUserIDs'; import type { AbstractCommand } from './inhibitors'; import { runInhibitors } from './inhibitors'; @@ -30,7 +29,6 @@ export async function preCommand({ dontRunPostCommand?: boolean; } > { - CACHED_ACTIVE_USER_IDS.add(userID); if (globalClient.isShuttingDown) { return { reason: { content: 'The bot is currently restarting, please try again later.' }, From 2ce3fcb67de1c2334a6c5335fe98836c3bd4141f Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Thu, 18 Jul 2024 15:14:50 +1000 Subject: [PATCH 071/145] Remove iron alts from startup scripts --- src/lib/startupScripts.ts | 1 - tests/unit/utils.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/src/lib/startupScripts.ts b/src/lib/startupScripts.ts index fb9af49e87..f15129be0e 100644 --- a/src/lib/startupScripts.ts +++ b/src/lib/startupScripts.ts @@ -13,7 +13,6 @@ const arrayColumns = [ ['users', 'favorite_bh_seeds'], ['users', 'attack_style'], ['users', 'combat_options'], - ['users', 'ironman_alts'], ['users', 'slayer.unlocks'], ['users', 'slayer.blocked_ids'], ['users', 'slayer.autoslay_options'] diff --git a/tests/unit/utils.ts b/tests/unit/utils.ts index c32e36475b..47d2a04c10 100644 --- a/tests/unit/utils.ts +++ b/tests/unit/utils.ts @@ -79,7 +79,6 @@ const mockUser = (overrides?: MockUserArgs): User => { skills_slayer: 0, skills_hitpoints: overrides?.skills_hitpoints ?? convertLVLtoXP(10), GP: overrides?.GP ?? 0, - ironman_alts: [], bitfield: overrides?.bitfield ?? [], username: 'Magnaboy', QP: overrides?.QP ?? 0, From db70b3494dc5b724a635347f37b512ad547bca57 Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Thu, 18 Jul 2024 21:14:28 +1000 Subject: [PATCH 072/145] Do max of 5 tasks per tick --- src/lib/Task.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/lib/Task.ts b/src/lib/Task.ts index 5ea4db1a2f..efc54e5357 100644 --- a/src/lib/Task.ts +++ b/src/lib/Task.ts @@ -199,7 +199,8 @@ export async function processPendingActivities() { lt: new Date() } : undefined - } + }, + take: 5 }); if (activities.length > 0) { From 0cb6c8ca11126b5ab0f598a449e4d1a2f8ec1f25 Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Thu, 18 Jul 2024 21:41:11 +1000 Subject: [PATCH 073/145] Add startupwait to tickers, log if >100ms --- src/lib/tickers.ts | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/src/lib/tickers.ts b/src/lib/tickers.ts index 9813ff8dba..d3f4a98c0d 100644 --- a/src/lib/tickers.ts +++ b/src/lib/tickers.ts @@ -1,3 +1,4 @@ +import { Stopwatch } from '@oldschoolgg/toolkit'; import type { TextChannel } from 'discord.js'; import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder } from 'discord.js'; import { Time, noOp, randInt, removeFromArr, shuffleArr } from 'e'; @@ -69,9 +70,16 @@ export interface Peak { /** * Tickers should idempotent, and be able to run at any time. */ -export const tickers: { name: string; interval: number; timer: NodeJS.Timeout | null; cb: () => Promise }[] = [ +export const tickers: { + name: string; + startupWait?: number; + interval: number; + timer: NodeJS.Timeout | null; + cb: () => Promise; +}[] = [ { name: 'giveaways', + startupWait: Time.Second * 30, interval: Time.Second * 10, timer: null, cb: async () => { @@ -109,6 +117,7 @@ export const tickers: { name: string; interval: number; timer: NodeJS.Timeout | }, { name: 'minion_activities', + startupWait: Time.Second * 10, timer: null, interval: production ? Time.Second * 5 : 500, cb: async () => { @@ -118,6 +127,7 @@ export const tickers: { name: string; interval: number; timer: NodeJS.Timeout | { name: 'daily_reminders', interval: Time.Minute * 3, + startupWait: Time.Minute, timer: null, cb: async () => { const result = await prisma.$queryRawUnsafe<{ id: string; last_daily_timestamp: bigint }[]>( @@ -155,6 +165,7 @@ WHERE bitfield && '{2,3,4,5,6,7,8,12,21,24}'::int[] AND user_stats."last_daily_t { name: 'wilderness_peak_times', timer: null, + startupWait: Time.Minute, interval: Time.Hour * 24, cb: async () => { let hoursUsed = 0; @@ -197,6 +208,7 @@ WHERE bitfield && '{2,3,4,5,6,7,8,12,21,24}'::int[] AND user_stats."last_daily_t }, { name: 'farming_reminder_ticker', + startupWait: Time.Minute, interval: Time.Minute * 3.5, timer: null, cb: async () => { @@ -317,6 +329,7 @@ WHERE bitfield && '{2,3,4,5,6,7,8,12,21,24}'::int[] AND user_stats."last_daily_t { name: 'support_channel_messages', timer: null, + startupWait: Time.Second * 22, interval: Time.Minute * 20, cb: async () => { if (!production) return; @@ -340,6 +353,7 @@ WHERE bitfield && '{2,3,4,5,6,7,8,12,21,24}'::int[] AND user_stats."last_daily_t }, { name: 'ge_channel_messages', + startupWait: Time.Second * 19, timer: null, interval: Time.Minute * 20, cb: async () => { @@ -361,6 +375,7 @@ WHERE bitfield && '{2,3,4,5,6,7,8,12,21,24}'::int[] AND user_stats."last_daily_t }, { name: 'ge_ticker', + startupWait: Time.Second * 30, timer: null, interval: Time.Second * 3, cb: async () => { @@ -370,7 +385,7 @@ WHERE bitfield && '{2,3,4,5,6,7,8,12,21,24}'::int[] AND user_stats."last_daily_t { name: 'Cache g.e prices and validate', timer: null, - interval: Time.Hour * 4, + interval: Time.Hour * 5, cb: async () => { await cacheGEPrices(); } @@ -383,7 +398,12 @@ export function initTickers() { const fn = async () => { try { if (globalClient.isShuttingDown) return; + const stopwatch = new Stopwatch().start(); await ticker.cb(); + stopwatch.stop(); + if (stopwatch.duration > 100) { + debugLog(`Ticker ${ticker.name} took ${stopwatch}`); + } } catch (err) { logError(err); debugLog(`${ticker.name} ticker errored`, { type: 'TICKER' }); @@ -391,6 +411,8 @@ export function initTickers() { ticker.timer = setTimeout(fn, ticker.interval); } }; - fn(); + setTimeout(() => { + fn(); + }, ticker.startupWait ?? 1); } } From b16de986fada8875c120eb08a56259139fbea851 Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Thu, 18 Jul 2024 23:21:01 +1000 Subject: [PATCH 074/145] Log method of replying that errored in interactionReply --- src/lib/util/interactionReply.ts | 6 +++++- src/lib/util/logError.ts | 9 +++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/lib/util/interactionReply.ts b/src/lib/util/interactionReply.ts index 36118215a4..4e001c1db8 100644 --- a/src/lib/util/interactionReply.ts +++ b/src/lib/util/interactionReply.ts @@ -16,12 +16,16 @@ import { logErrorForInteraction } from './logError'; export async function interactionReply(interaction: RepliableInteraction, response: string | InteractionReplyOptions) { let i: Promise | Promise | undefined = undefined; + let method = ''; if (interaction.replied) { + method = 'followUp'; i = interaction.followUp(response); } else if (interaction.deferred) { + method = 'editReply'; i = interaction.editReply(response); } else { + method = 'reply'; i = interaction.reply(response); } try { @@ -30,7 +34,7 @@ export async function interactionReply(interaction: RepliableInteraction, respon } catch (e: any) { if (e instanceof DiscordAPIError && e.code !== 10_008) { // 10_008 is unknown message, e.g. if someone deletes the message before it's replied to. - logErrorForInteraction(e, interaction); + logErrorForInteraction(e, interaction, { method, response: JSON.stringify(response).slice(0, 50) }); } return undefined; } diff --git a/src/lib/util/logError.ts b/src/lib/util/logError.ts index 75a85c0fcf..29a17924f2 100644 --- a/src/lib/util/logError.ts +++ b/src/lib/util/logError.ts @@ -34,13 +34,18 @@ export function logError(err: Error | unknown, context?: Record, } } -export function logErrorForInteraction(err: Error | unknown, interaction: Interaction) { +export function logErrorForInteraction( + err: Error | unknown, + interaction: Interaction, + extraContext?: Record +) { const context: Record = { user_id: interaction.user.id, channel_id: interaction.channelId, guild_id: interaction.guildId, interaction_id: interaction.id, - interaction_type: interaction.type + interaction_type: interaction.type, + ...extraContext }; if (interaction.isChatInputCommand()) { context.options = JSON.stringify( From 950daec95e62eb77a1c5945929e1e0000e446a86 Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Thu, 18 Jul 2024 23:21:10 +1000 Subject: [PATCH 075/145] Select less keys in return --- src/mahoji/lib/postCommand.ts | 8 +++++++- src/mahoji/mahojiSettings.ts | 5 +++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/mahoji/lib/postCommand.ts b/src/mahoji/lib/postCommand.ts index ffacdddc34..cc1c53b674 100644 --- a/src/mahoji/lib/postCommand.ts +++ b/src/mahoji/lib/postCommand.ts @@ -44,7 +44,10 @@ export async function postCommand({ try { await prisma.$transaction([ prisma.commandUsage.create({ - data: commandUsage + data: commandUsage, + select: { + id: true + } }), prisma.user.update({ where: { @@ -52,6 +55,9 @@ export async function postCommand({ }, data: { last_command_date: new Date() + }, + select: { + id: true } }) ]); diff --git a/src/mahoji/mahojiSettings.ts b/src/mahoji/mahojiSettings.ts index 4235a76244..094b20152f 100644 --- a/src/mahoji/mahojiSettings.ts +++ b/src/mahoji/mahojiSettings.ts @@ -89,7 +89,8 @@ export async function userStatsUpdate Date: Thu, 18 Jul 2024 23:21:24 +1000 Subject: [PATCH 076/145] Only fetch kc if necessary --- src/lib/minions/functions/announceLoot.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/minions/functions/announceLoot.ts b/src/lib/minions/functions/announceLoot.ts index b02fb73528..f444d52572 100644 --- a/src/lib/minions/functions/announceLoot.ts +++ b/src/lib/minions/functions/announceLoot.ts @@ -20,7 +20,6 @@ export default async function announceLoot({ }) { if (!_notifyDrops) return; const notifyDrops = _notifyDrops.flat(Number.POSITIVE_INFINITY); - const kc = await user.getKC(monsterID); const itemsToAnnounce = loot.clone().filter(i => notifyDrops.includes(i.id)); if (itemsToAnnounce.length > 0) { let notif = ''; @@ -30,6 +29,7 @@ export default async function announceLoot({ effectiveMonsters.find(m => m.id === monsterID)?.name }, **${team.lootRecipient.badgedUsername}** just received **${itemsToAnnounce}**!`; } else { + const kc = await user.getKC(monsterID); notif = `**${user.badgedUsername}'s** minion, ${minionName( user )}, just received **${itemsToAnnounce}**, their ${ From 4445e18bf4af6f7fabf09ad2991e8a8d8a34bb98 Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Thu, 18 Jul 2024 23:24:00 +1000 Subject: [PATCH 077/145] Dont do cache cleanup on start --- src/mahoji/lib/events.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/mahoji/lib/events.ts b/src/mahoji/lib/events.ts index 5673359333..45d7db1cca 100644 --- a/src/mahoji/lib/events.ts +++ b/src/mahoji/lib/events.ts @@ -6,7 +6,6 @@ import { Channel, META_CONSTANTS, globalConfig } from '../../lib/constants'; import { initCrons } from '../../lib/crons'; import { initTickers } from '../../lib/tickers'; -import { cacheCleanup } from '../../lib/util/cachedUserIDs'; import { mahojiClientSettingsFetch } from '../../lib/util/clientSettings'; import { sendToChannelID } from '../../lib/util/webhook'; import { CUSTOM_PRICE_CACHE } from '../commands/sell'; @@ -29,8 +28,6 @@ export async function onStartup() { }); } - cacheCleanup(); - initCrons(); initTickers(); From bc9c0a7dab46e711eb865e91b0b61aaa1da135ad Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Thu, 18 Jul 2024 23:24:23 +1000 Subject: [PATCH 078/145] Dont fetch MUser for repeat trip button --- src/lib/util/globalInteractions.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/lib/util/globalInteractions.ts b/src/lib/util/globalInteractions.ts index 1d3de71ea7..17f3f47f9b 100644 --- a/src/lib/util/globalInteractions.ts +++ b/src/lib/util/globalInteractions.ts @@ -223,8 +223,9 @@ async function giveawayButtonHandler(user: MUser, customID: string, interaction: return interactionReply(interaction, { content: 'You left the giveaway.', ephemeral: true }); } -async function repeatTripHandler(user: MUser, interaction: ButtonInteraction) { - if (user.minionIsBusy) return interactionReply(interaction, { content: 'Your minion is busy.', ephemeral: true }); +async function repeatTripHandler(interaction: ButtonInteraction) { + if (minionIsBusy(interaction.user.id)) + return interactionReply(interaction, { content: 'Your minion is busy.', ephemeral: true }); const trips = await fetchRepeatTrips(interaction.user.id); if (trips.length === 0) { return interactionReply(interaction, { content: "Couldn't find a trip to repeat.", ephemeral: true }); @@ -344,10 +345,11 @@ export async function interactionHook(interaction: Interaction) { }); } + if (id.includes('REPEAT_TRIP')) return repeatTripHandler(interaction); + const user = await mUserFetch(userID); if (id.includes('GIVEAWAY_')) return giveawayButtonHandler(user, id, interaction); - if (id.includes('REPEAT_TRIP')) return repeatTripHandler(user, interaction); if (id.startsWith('GPE_')) return handleGearPresetEquip(user, id, interaction); if (id.startsWith('PTR_')) return handlePinnedTripRepeat(user, id, interaction); if (id === 'TOA_CHECK') { @@ -516,7 +518,9 @@ export async function interactionHook(interaction: Interaction) { } case 'AUTO_FARMING_CONTRACT': { const response = await autoContract(await mUserFetch(user.id), options.channelID, user.id); - if (response) interactionReply(interaction, response); + if (response) { + return interactionReply(interaction, response); + } return; } case 'FARMING_CONTRACT_EASIER': { From 0147bf23108dbffece87fe204e9df81c394cd268 Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Fri, 19 Jul 2024 00:06:06 +1000 Subject: [PATCH 079/145] Make indexes on g.e in startup scripts --- src/lib/startupScripts.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/lib/startupScripts.ts b/src/lib/startupScripts.ts index f15129be0e..4b9bf1e1c9 100644 --- a/src/lib/startupScripts.ts +++ b/src/lib/startupScripts.ts @@ -130,6 +130,19 @@ startupScripts.push({ sql: 'CREATE UNIQUE INDEX IF NOT EXISTS activity_only_one_task ON activity (user_id, completed) WHERE NOT completed;' }); +startupScripts.push({ + sql: `CREATE INDEX idx_ge_listing_buy_filter_sort +ON ge_listing (type, fulfilled_at, cancelled_at, user_id, asking_price_per_item DESC, created_at ASC);` +}); +startupScripts.push({ + sql: `CREATE INDEX idx_ge_listing_sell_filter_sort +ON ge_listing (type, fulfilled_at, cancelled_at, user_id, asking_price_per_item ASC, created_at ASC);` +}); + +startupScripts.push({ + sql: `CREATE INDEX ge_transaction_sell_listing_id_created_at_idx +ON ge_transaction (sell_listing_id, created_at DESC);` +}); const itemMetaDataNames = Items.map(item => `(${item.id}, '${item.name.replace(/'/g, "''")}')`).join(', '); const itemMetaDataQuery = ` INSERT INTO item_metadata (id, name) From e2242503a4c9aa756a07b71b0400715cb1c122f4 Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Fri, 19 Jul 2024 00:14:34 +1000 Subject: [PATCH 080/145] Use different way to check ironman g.e listings --- .../lib/abstracted_commands/ironmanCommand.ts | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/src/mahoji/lib/abstracted_commands/ironmanCommand.ts b/src/mahoji/lib/abstracted_commands/ironmanCommand.ts index 2855556e3b..a2d779c4c9 100644 --- a/src/mahoji/lib/abstracted_commands/ironmanCommand.ts +++ b/src/mahoji/lib/abstracted_commands/ironmanCommand.ts @@ -4,7 +4,6 @@ import type { ChatInputCommandInteraction } from 'discord.js'; import type { ItemBank } from 'oldschooljs/dist/meta/types'; import { BitField } from '../../../lib/constants'; -import { GrandExchange } from '../../../lib/grandExchange'; import { roboChimpUserFetch } from '../../../lib/roboChimp'; import { assert } from '../../../lib/util'; @@ -67,8 +66,25 @@ export async function ironmanCommand( return "You can't become an ironman because you have active bingos."; } - const activeGEListings = await GrandExchange.fetchActiveListings(); - if ([...activeGEListings.buyListings, ...activeGEListings.sellListings].some(i => i.user_id === user.id)) { + const activeListings = await prisma.gEListing.findMany({ + where: { + user_id: user.id, + quantity_remaining: { + gt: 0 + }, + fulfilled_at: null, + cancelled_at: null + }, + include: { + buyTransactions: true, + sellTransactions: true + }, + orderBy: { + created_at: 'desc' + } + }); + // Return early if no active listings. + if (activeListings.length !== 0) { return `You can't become an ironman because you have active Grand Exchange listings. Cancel them and try again: ${mentionCommand( globalClient, 'ge', From cbeb2dcdfd22930799d40f2b9af82ed578b999c1 Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Fri, 19 Jul 2024 00:14:45 +1000 Subject: [PATCH 081/145] Make g.e ticker run less often --- src/lib/tickers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/tickers.ts b/src/lib/tickers.ts index d3f4a98c0d..8754b934d1 100644 --- a/src/lib/tickers.ts +++ b/src/lib/tickers.ts @@ -377,7 +377,7 @@ WHERE bitfield && '{2,3,4,5,6,7,8,12,21,24}'::int[] AND user_stats."last_daily_t name: 'ge_ticker', startupWait: Time.Second * 30, timer: null, - interval: Time.Second * 3, + interval: Time.Second * 10, cb: async () => { await GrandExchange.tick(); } From dd67e663374c8e29f5e4fd88c66c84e51e4efd57 Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Fri, 19 Jul 2024 00:20:54 +1000 Subject: [PATCH 082/145] G.E optimizations --- src/lib/grandExchange.ts | 62 ++++++++++++++++++++++++++-------------- 1 file changed, 40 insertions(+), 22 deletions(-) diff --git a/src/lib/grandExchange.ts b/src/lib/grandExchange.ts index 31f4a9c74e..c17257bcec 100644 --- a/src/lib/grandExchange.ts +++ b/src/lib/grandExchange.ts @@ -1,7 +1,7 @@ import type { GEListing, GETransaction } from '@prisma/client'; import { GEListingType } from '@prisma/client'; import { ButtonBuilder, ButtonStyle, bold, userMention } from 'discord.js'; -import { Time, calcPercentOfNum, clamp, noOp, sumArr } from 'e'; +import { Time, calcPercentOfNum, clamp, noOp, sumArr, uniqueArr } from 'e'; import { LRUCache } from 'lru-cache'; import { Bank } from 'oldschooljs'; import type { Item, ItemBank } from 'oldschooljs/dist/meta/types'; @@ -713,29 +713,38 @@ ${type} ${toKMB(quantity)} ${item.name} for ${toKMB(price)} each, for a total of } async fetchActiveListings() { - const [buyListings, sellListings, clientStorage, currentBankRaw] = await prisma.$transaction([ - prisma.gEListing.findMany({ - where: { - type: GEListingType.Buy, - fulfilled_at: null, - cancelled_at: null, - user_id: { not: null } + const buyListings = await prisma.gEListing.findMany({ + where: { + type: GEListingType.Buy, + fulfilled_at: null, + cancelled_at: null, + user_id: { + not: null, + notIn: Array.from(BLACKLISTED_USERS) + } + }, + orderBy: [ + { + asking_price_per_item: 'desc' }, - orderBy: [ - { - asking_price_per_item: 'desc' - }, - { - created_at: 'asc' - } - ] - }), + { + created_at: 'asc' + } + ] + }); + const [sellListings, clientStorage, currentBankRaw] = await prisma.$transaction([ prisma.gEListing.findMany({ where: { type: GEListingType.Sell, fulfilled_at: null, cancelled_at: null, - user_id: { not: null } + user_id: { + not: null, + notIn: Array.from(BLACKLISTED_USERS) + }, + item_id: { + in: uniqueArr(buyListings.map(i => i.item_id)) + } }, orderBy: [ { @@ -853,13 +862,22 @@ Difference: ${shouldHave.difference(currentBank)}`); private async _tick() { if (!this.ready) return; if (this.locked) return; - const { buyListings: _buyListings, sellListings: _sellListings } = await this.fetchActiveListings(); + const { buyListings, sellListings } = await this.fetchActiveListings(); - // Filter out listings from Blacklisted users: - const buyListings = _buyListings.filter(l => !BLACKLISTED_USERS.has(l.user_id!)); - const sellListings = _sellListings.filter(l => !BLACKLISTED_USERS.has(l.user_id!)); + const minimumSellPricePerItem = new Map(); + for (const sellListing of sellListings) { + const currentPrice = minimumSellPricePerItem.get(sellListing.item_id); + if (currentPrice === undefined || sellListing.asking_price_per_item < currentPrice) { + minimumSellPricePerItem.set(sellListing.item_id, Number(sellListing.asking_price_per_item)); + } + } for (const buyListing of buyListings) { + const minPrice = minimumSellPricePerItem.get(buyListing.item_id); + if (minPrice === undefined || buyListing.asking_price_per_item < minPrice) { + continue; + } + // These are all valid, matching sell listings we can match with this buy listing. const matchingSellListings = sellListings.filter( sellListing => From 96ba851c4ed960a9ba06088efb8133624cd07bbb Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Fri, 19 Jul 2024 00:30:25 +1000 Subject: [PATCH 083/145] Dont fetch api user in preCommand --- src/mahoji/lib/inhibitors.ts | 30 +++++------------------------- src/mahoji/lib/preCommand.ts | 1 - 2 files changed, 5 insertions(+), 26 deletions(-) diff --git a/src/mahoji/lib/inhibitors.ts b/src/mahoji/lib/inhibitors.ts index ed7430f148..19da2ee254 100644 --- a/src/mahoji/lib/inhibitors.ts +++ b/src/mahoji/lib/inhibitors.ts @@ -1,5 +1,5 @@ import { PerkTier, formatDuration } from '@oldschoolgg/toolkit'; -import type { DMChannel, Guild, GuildMember, InteractionReplyOptions, TextChannel, User } from 'discord.js'; +import type { DMChannel, Guild, GuildMember, InteractionReplyOptions, TextChannel } from 'discord.js'; import { ComponentType, PermissionsBitField } from 'discord.js'; import { OWNER_IDS, SupportServer } from '../../config'; @@ -29,7 +29,6 @@ export interface AbstractCommand { interface Inhibitor { name: string; run: (options: { - APIUser: User; user: MUser; command: AbstractCommand; guild: Guild | null; @@ -52,23 +51,6 @@ const inhibitors: Inhibitor[] = [ }, canBeDisabled: false }, - { - name: 'bots', - run: async ({ APIUser, user }) => { - if (!APIUser.bot) return false; - if ( - ![ - '798308589373489172', // BIRDIE#1963 - '902745429685469264' // Randy#0008 - ].includes(user.id) - ) { - return { content: 'Bots cannot use commands.' }; - } - return false; - }, - canBeDisabled: false, - silent: true - }, { name: 'hasMinion', run: async ({ user, command }) => { @@ -120,9 +102,9 @@ const inhibitors: Inhibitor[] = [ }, { name: 'disabled', - run: async ({ command, guild, APIUser }) => { + run: async ({ command, guild, user }) => { if ( - !OWNER_IDS.includes(APIUser.id) && + !OWNER_IDS.includes(user.id) && (command.attributes?.enabled === false || DISABLED_COMMANDS.has(command.name)) ) { return { content: 'This command is globally disabled.' }; @@ -239,11 +221,9 @@ export async function runInhibitors({ member, command, guild, - bypassInhibitors, - APIUser + bypassInhibitors }: { user: MUser; - APIUser: User; channel: TextChannel | DMChannel; member: GuildMember | null; command: AbstractCommand; @@ -252,7 +232,7 @@ export async function runInhibitors({ }): Promise { for (const { run, canBeDisabled, silent } of inhibitors) { if (bypassInhibitors && canBeDisabled) continue; - const result = await run({ user, channel, member, command, guild, APIUser }); + const result = await run({ user, channel, member, command, guild }); if (result !== false) { return { reason: result, silent: Boolean(silent) }; } diff --git a/src/mahoji/lib/preCommand.ts b/src/mahoji/lib/preCommand.ts index bea34d3e00..c27af0c7b8 100644 --- a/src/mahoji/lib/preCommand.ts +++ b/src/mahoji/lib/preCommand.ts @@ -53,7 +53,6 @@ export async function preCommand({ const inhibitResult = await runInhibitors({ user, - APIUser: await globalClient.fetchUser(user.id), guild: guild ?? null, member: member ?? null, command: abstractCommand, From ae2ccd559c29952633a0b2fa6636e174ea3f19dc Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Fri, 19 Jul 2024 00:32:29 +1000 Subject: [PATCH 084/145] Fix bug with userstatsbankupdate --- src/mahoji/mahojiSettings.ts | 2 +- tests/integration/commands/sacrifice.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/mahoji/mahojiSettings.ts b/src/mahoji/mahojiSettings.ts index 094b20152f..1d70e30797 100644 --- a/src/mahoji/mahojiSettings.ts +++ b/src/mahoji/mahojiSettings.ts @@ -128,7 +128,7 @@ export async function userStatsBankUpdate(userID: string, key: keyof UserStats, u => ({ [key]: bank.clone().add(u[key] as ItemBank).bank }), - {} + { [key]: true } ); } diff --git a/tests/integration/commands/sacrifice.test.ts b/tests/integration/commands/sacrifice.test.ts index 9a35f2705b..0abd4fc483 100644 --- a/tests/integration/commands/sacrifice.test.ts +++ b/tests/integration/commands/sacrifice.test.ts @@ -34,8 +34,8 @@ describe('Sacrifice Command', async () => { ); const stats = await user.fetchStats({ sacrificed_bank: true }); expect(user.bank.toString()).toBe(new Bank().toString()); - expect(new Bank(stats.sacrificed_bank as ItemBank).equals(new Bank().add('Coal', 20).add('Trout', 2))).toBe( - true + expect(new Bank(stats.sacrificed_bank as ItemBank).toString()).toEqual( + new Bank().add('Coal', 20).add('Trout', 2).toString() ); expect(user.user.sacrificedValue).toEqual(BigInt(3180)); const clientSettings = await mahojiClientSettingsFetch({ economyStats_sacrificedBank: true }); From fda826c10493c24bbdc65e5a1fe954018b7fc0da Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Fri, 19 Jul 2024 01:10:42 +1000 Subject: [PATCH 085/145] Fixes --- src/lib/grandExchange.ts | 55 +++++++++++++++++++++++++++++++++++++-- src/mahoji/commands/ge.ts | 8 ++++-- 2 files changed, 59 insertions(+), 4 deletions(-) diff --git a/src/lib/grandExchange.ts b/src/lib/grandExchange.ts index c17257bcec..ae6e5b5c6a 100644 --- a/src/lib/grandExchange.ts +++ b/src/lib/grandExchange.ts @@ -790,8 +790,59 @@ ${type} ${toKMB(quantity)} ${item.name} for ${toKMB(price)} each, for a total of async checkGECanFullFilAllListings() { const shouldHave = new Bank(); - const { buyListings, sellListings, currentBank } = await this.fetchActiveListings(); - + const [buyListings, sellListings, currentBankRaw] = await prisma.$transaction([ + prisma.gEListing.findMany({ + where: { + type: GEListingType.Buy, + fulfilled_at: null, + cancelled_at: null, + user_id: { + not: null, + notIn: Array.from(BLACKLISTED_USERS) + } + }, + orderBy: [ + { + asking_price_per_item: 'desc' + }, + { + created_at: 'asc' + } + ] + }), + prisma.gEListing.findMany({ + where: { + type: GEListingType.Sell, + fulfilled_at: null, + cancelled_at: null, + user_id: { + not: null, + notIn: Array.from(BLACKLISTED_USERS) + } + }, + orderBy: [ + { + asking_price_per_item: 'asc' + }, + { + created_at: 'asc' + } + ], + // Take the last purchase transaction for each sell listing + include: { + sellTransactions: { + orderBy: { + created_at: 'desc' + }, + take: 1 + } + } + }), + prisma.$queryRawUnsafe<{ bank: ItemBank }[]>( + 'SELECT json_object_agg(item_id, quantity) as bank FROM ge_bank WHERE quantity != 0;' + ) + ]); + const currentBank = new Bank(currentBankRaw[0].bank); // How much GP the g.e still has from this listing for (const listing of buyListings) { shouldHave.add('Coins', Number(listing.asking_price_per_item) * listing.quantity_remaining); diff --git a/src/mahoji/commands/ge.ts b/src/mahoji/commands/ge.ts index f5b39721c7..55666c1a35 100644 --- a/src/mahoji/commands/ge.ts +++ b/src/mahoji/commands/ge.ts @@ -10,6 +10,8 @@ import { PerkTier } from '../../lib/constants'; import { GrandExchange, createGECancelButton } from '../../lib/grandExchange'; import { marketPricemap } from '../../lib/marketPrices'; +import { Bank } from 'oldschooljs'; +import type { ItemBank } from 'oldschooljs/dist/meta/types'; import { formatDuration, itemNameFromID, makeComponents, returnStringOrFile, toKMB } from '../../lib/util'; import { createChart } from '../../lib/util/chart'; import getOSItem from '../../lib/util/getOSItem'; @@ -19,6 +21,7 @@ import itemIsTradeable from '../../lib/util/itemIsTradeable'; import { cancelGEListingCommand } from '../lib/abstracted_commands/cancelGEListingCommand'; import { itemOption, tradeableItemArr } from '../lib/mahojiCommandOptions'; import type { OSBMahojiCommand } from '../lib/util'; +import { mahojiUsersSettingsFetch } from '../mahojiSettings'; export type GEListingWithTransactions = GEListing & { buyTransactions: GETransaction[]; @@ -135,9 +138,10 @@ export const geCommand: OSBMahojiCommand = { description: 'The item you want to sell.', required: true, autocomplete: async (value, { id }) => { - const user = await mUserFetch(id); + const raw = await mahojiUsersSettingsFetch(id, { bank: true }); + const bank = new Bank(raw.bank as ItemBank); - return user.bank + return bank .items() .filter(i => i[0].tradeable_on_ge) .filter(i => (!value ? true : i[0].name.toLowerCase().includes(value.toLowerCase()))) From eead36eb13dc4c214efaae4ba4e19693e77d695c Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Fri, 19 Jul 2024 01:14:58 +1000 Subject: [PATCH 086/145] Fix indexes --- src/lib/startupScripts.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/lib/startupScripts.ts b/src/lib/startupScripts.ts index 4b9bf1e1c9..1c279f0b4a 100644 --- a/src/lib/startupScripts.ts +++ b/src/lib/startupScripts.ts @@ -131,16 +131,16 @@ startupScripts.push({ }); startupScripts.push({ - sql: `CREATE INDEX idx_ge_listing_buy_filter_sort + sql: `CREATE INDEX IF NOT EXISTS idx_ge_listing_buy_filter_sort ON ge_listing (type, fulfilled_at, cancelled_at, user_id, asking_price_per_item DESC, created_at ASC);` }); startupScripts.push({ - sql: `CREATE INDEX idx_ge_listing_sell_filter_sort + sql: `CREATE INDEX IF NOT EXISTS idx_ge_listing_sell_filter_sort ON ge_listing (type, fulfilled_at, cancelled_at, user_id, asking_price_per_item ASC, created_at ASC);` }); startupScripts.push({ - sql: `CREATE INDEX ge_transaction_sell_listing_id_created_at_idx + sql: `CREATE INDEX IF NOT EXISTS ge_transaction_sell_listing_id_created_at_idx ON ge_transaction (sell_listing_id, created_at DESC);` }); const itemMetaDataNames = Items.map(item => `(${item.id}, '${item.name.replace(/'/g, "''")}')`).join(', '); From bf95e5e8fd5fee1ef0fd2dfed484743b9d562f25 Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Fri, 19 Jul 2024 01:37:32 +1000 Subject: [PATCH 087/145] Move g.e caching to a cronjob/prestartup --- src/index.ts | 4 +++- src/lib/crons.ts | 6 ++++++ src/lib/tickers.ts | 10 ---------- 3 files changed, 9 insertions(+), 11 deletions(-) diff --git a/src/index.ts b/src/index.ts index a74bec53d8..1689d5b50c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,6 +18,7 @@ import { Channel, Events, META_CONSTANTS, gitHash, globalConfig } from './lib/co import { economyLog } from './lib/economyLogs'; import { onMessage } from './lib/events'; import { GrandExchange } from './lib/grandExchange'; +import { cacheGEPrices } from './lib/marketPrices'; import { modalInteractionHook } from './lib/modals'; import { populateRoboChimpCache } from './lib/perkTier'; import { runStartupScripts } from './lib/startupScripts'; @@ -203,7 +204,8 @@ async function main() { runTimedLoggedFn('Syncing prices', syncCustomPrices), runTimedLoggedFn('Caching badges', cacheBadges), runTimedLoggedFn('Init Grand Exchange', () => GrandExchange.init()), - runTimedLoggedFn('populateRoboChimpCache', populateRoboChimpCache) + runTimedLoggedFn('populateRoboChimpCache', populateRoboChimpCache), + runTimedLoggedFn('Cache G.E Prices', cacheGEPrices) ]); await runTimedLoggedFn('Log In', () => client.login(globalConfig.botToken)); console.log(`Logged in as ${globalClient.user.username}`); diff --git a/src/lib/crons.ts b/src/lib/crons.ts index ec39cdec60..a5742fef2d 100644 --- a/src/lib/crons.ts +++ b/src/lib/crons.ts @@ -1,6 +1,7 @@ import { schedule } from 'node-cron'; import { analyticsTick } from './analytics'; +import { cacheGEPrices } from './marketPrices'; import { cacheCleanup } from './util/cachedUserIDs'; export function initCrons() { @@ -39,4 +40,9 @@ GROUP BY item_id;`); debugLog('Cache cleanup cronjob starting'); cacheCleanup(); }); + + schedule('35 */48 * * *', async () => { + debugLog('cacheGEPrices cronjob starting'); + await cacheGEPrices(); + }); } diff --git a/src/lib/tickers.ts b/src/lib/tickers.ts index 8754b934d1..d5555cd497 100644 --- a/src/lib/tickers.ts +++ b/src/lib/tickers.ts @@ -9,7 +9,6 @@ import { mahojiUserSettingsUpdate } from './MUser'; import { processPendingActivities } from './Task'; import { BitField, Channel, PeakTier, informationalButtons } from './constants'; import { GrandExchange } from './grandExchange'; -import { cacheGEPrices } from './marketPrices'; import { collectMetrics } from './metrics'; import { queryCountStore } from './settings/prisma'; import { runCommand } from './settings/settings'; @@ -165,7 +164,6 @@ WHERE bitfield && '{2,3,4,5,6,7,8,12,21,24}'::int[] AND user_stats."last_daily_t { name: 'wilderness_peak_times', timer: null, - startupWait: Time.Minute, interval: Time.Hour * 24, cb: async () => { let hoursUsed = 0; @@ -381,14 +379,6 @@ WHERE bitfield && '{2,3,4,5,6,7,8,12,21,24}'::int[] AND user_stats."last_daily_t cb: async () => { await GrandExchange.tick(); } - }, - { - name: 'Cache g.e prices and validate', - timer: null, - interval: Time.Hour * 5, - cb: async () => { - await cacheGEPrices(); - } } ]; From 7aaa44d9d8cf52fdd39e3a74a7418f78c418eff1 Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Fri, 19 Jul 2024 01:37:52 +1000 Subject: [PATCH 088/145] G.E fixes --- src/lib/grandExchange.ts | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/src/lib/grandExchange.ts b/src/lib/grandExchange.ts index ae6e5b5c6a..fdbebd31a3 100644 --- a/src/lib/grandExchange.ts +++ b/src/lib/grandExchange.ts @@ -8,12 +8,12 @@ import type { Item, ItemBank } from 'oldschooljs/dist/meta/types'; import PQueue from 'p-queue'; import { ADMIN_IDS, OWNER_IDS, production } from '../config'; -import { BLACKLISTED_USERS } from './blacklists'; import { BitField, ONE_TRILLION, PerkTier, globalConfig } from './constants'; import { marketPricemap } from './marketPrices'; import type { RobochimpUser } from './roboChimp'; import { roboChimpUserFetch } from './roboChimp'; +import { BLACKLISTED_USERS } from './blacklists'; import { fetchTableBank, makeTransactFromTableBankQueries } from './tableBank'; import { assert, generateGrandExchangeID, getInterval, itemNameFromID, makeComponents, toKMB } from './util'; import { mahojiClientSettingsFetch, mahojiClientSettingsUpdate } from './util/clientSettings'; @@ -719,8 +719,7 @@ ${type} ${toKMB(quantity)} ${item.name} for ${toKMB(price)} each, for a total of fulfilled_at: null, cancelled_at: null, user_id: { - not: null, - notIn: Array.from(BLACKLISTED_USERS) + not: null } }, orderBy: [ @@ -739,8 +738,7 @@ ${type} ${toKMB(quantity)} ${item.name} for ${toKMB(price)} each, for a total of fulfilled_at: null, cancelled_at: null, user_id: { - not: null, - notIn: Array.from(BLACKLISTED_USERS) + not: null }, item_id: { in: uniqueArr(buyListings.map(i => i.item_id)) @@ -797,8 +795,7 @@ ${type} ${toKMB(quantity)} ${item.name} for ${toKMB(price)} each, for a total of fulfilled_at: null, cancelled_at: null, user_id: { - not: null, - notIn: Array.from(BLACKLISTED_USERS) + not: null } }, orderBy: [ @@ -816,8 +813,7 @@ ${type} ${toKMB(quantity)} ${item.name} for ${toKMB(price)} each, for a total of fulfilled_at: null, cancelled_at: null, user_id: { - not: null, - notIn: Array.from(BLACKLISTED_USERS) + not: null } }, orderBy: [ @@ -925,7 +921,11 @@ Difference: ${shouldHave.difference(currentBank)}`); for (const buyListing of buyListings) { const minPrice = minimumSellPricePerItem.get(buyListing.item_id); - if (minPrice === undefined || buyListing.asking_price_per_item < minPrice) { + if (!buyListing.user_id || minPrice === undefined || buyListing.asking_price_per_item < minPrice) { + continue; + } + + if (BLACKLISTED_USERS.has(buyListing.user_id)) { continue; } @@ -935,7 +935,9 @@ Difference: ${shouldHave.difference(currentBank)}`); sellListing.item_id === buyListing.item_id && // "Trades succeed when one player's buy offer is greater than or equal to another player's sell offer." buyListing.asking_price_per_item >= sellListing.asking_price_per_item && - buyListing.user_id !== sellListing.user_id + buyListing.user_id !== sellListing.user_id && + sellListing.user_id !== null && + !BLACKLISTED_USERS.has(sellListing.user_id) ); /** From e0318b83763f9aa11be2b5ba9da0d6d18953d291 Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Fri, 19 Jul 2024 02:08:27 +1000 Subject: [PATCH 089/145] Wait for 10 seconds uptime before allowing interactions --- src/index.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/index.ts b/src/index.ts index 1689d5b50c..e4a52bfb19 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,7 +8,7 @@ import { MahojiClient } from '@oldschoolgg/toolkit'; import { init } from '@sentry/node'; import type { TextChannel } from 'discord.js'; import { GatewayIntentBits, Options, Partials } from 'discord.js'; -import { isObject } from 'e'; +import { Time, isObject } from 'e'; import { SENTRY_DSN, SupportServer } from './config'; import { syncActivityCache } from './lib/Task'; @@ -144,15 +144,24 @@ client.on('interactionCreate', async interaction => { if (BLACKLISTED_USERS.has(interaction.user.id)) return; if (interaction.guildId && BLACKLISTED_GUILDS.has(interaction.guildId)) return; - if (!client.isReady()) { + if (globalClient.isShuttingDown) { if (interaction.isRepliable()) { await interaction.reply({ content: - 'Old School Bot is currently down for maintenance/updates, please try again in a couple minutes! Thank you <3', + 'Old School Bot is currently shutting down for maintenance/updates, please try again in a couple minutes! Thank you <3', ephemeral: true }); } + return; + } + if (!client.isReady() || !client.uptime || client.uptime < Time.Second * 10) { + if (interaction.isRepliable()) { + await interaction.reply({ + content: 'Old School Bot is currently rebooting, please try again in a few seconds! Thank you <3', + ephemeral: true + }); + } return; } From e369645f0c902d6146adff88934529d934780d84 Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Fri, 19 Jul 2024 16:02:07 +1000 Subject: [PATCH 090/145] Improve userstats update to be safe from nuking data --- package.json | 2 +- src/lib/colosseum.ts | 2 +- src/lib/randomEvents.ts | 2 +- src/lib/simulation/toa.ts | 2 +- src/lib/util.ts | 5 ++ src/lib/util/handleTripFinish.ts | 2 +- src/mahoji/commands/buy.ts | 9 +-- src/mahoji/commands/create.ts | 4 +- src/mahoji/commands/offer.ts | 2 +- src/mahoji/commands/sacrifice.ts | 2 +- src/mahoji/commands/sell.ts | 10 +-- src/mahoji/commands/testpotato.ts | 7 +- .../lib/abstracted_commands/farmingCommand.ts | 2 +- .../giantsFoundryCommand.ts | 2 +- .../shadesOfMortonCommand.ts | 2 +- .../lib/abstracted_commands/tobCommand.ts | 2 +- src/mahoji/mahojiSettings.ts | 69 ++++++------------- .../PrayerActivity/scatteringActivity.ts | 2 +- src/tasks/minions/agilityActivity.ts | 7 +- src/tasks/minions/colosseumActivity.ts | 2 +- src/tasks/minions/farmingActivity.ts | 6 +- .../minigames/giantsFoundryActivity.ts | 2 +- .../minions/minigames/puroPuroActivity.ts | 2 +- src/tasks/minions/minigames/toaActivity.ts | 23 +++---- src/tasks/minions/minigames/tobActivity.ts | 2 +- src/tasks/minions/woodcuttingActivity.ts | 2 +- tests/integration/migrateUser.test.ts | 7 +- tests/integration/rolesTask.test.ts | 4 +- tests/integration/userStats.test.ts | 4 +- 29 files changed, 82 insertions(+), 107 deletions(-) diff --git a/package.json b/package.json index eb0b66aeb6..7f8bf63a30 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "watch:tsc": "tsc -w -p src", "wipedist": "node -e \"try { require('fs').rmSync('dist', { recursive: true }) } catch(_){}\"", "cleanup": "yarn && yarn wipedist && yarn lint && yarn build && yarn test && npm i -g dpdm && dpdm --exit-code circular:1 --progress=false --warning=false --tree=false ./dist/index.js", - "test": "concurrently --raw --kill-others-on-fail \"tsc -p src && yarn test:circular\" \"yarn test:lint\" \"yarn test:unit\"", + "test": "concurrently --raw --kill-others-on-fail \"tsc -p src && yarn test:circular\" \"yarn test:lint\" \"yarn test:unit\" \"tsc -p tests/integration --noEmit\" \"tsc -p tests/unit --noEmit\"", "test:lint": "biome check --diagnostic-level=error", "test:unit": "vitest run --coverage --config vitest.unit.config.mts", "test:docker": "docker-compose up --build --abort-on-container-exit", diff --git a/src/lib/colosseum.ts b/src/lib/colosseum.ts index 26022a0cd6..1ddd0229ca 100644 --- a/src/lib/colosseum.ts +++ b/src/lib/colosseum.ts @@ -620,7 +620,7 @@ export async function colosseumCommand(user: MUser, channelID: string) { messages.push(`Removed ${realCost}`); await updateBankSetting('colo_cost', realCost); - await userStatsBankUpdate(user.id, 'colo_cost', realCost); + await userStatsBankUpdate(user, 'colo_cost', realCost); await trackLoot({ totalCost: realCost, id: 'colo', diff --git a/src/lib/randomEvents.ts b/src/lib/randomEvents.ts index 0a8fbc51c4..a600bbc11e 100644 --- a/src/lib/randomEvents.ts +++ b/src/lib/randomEvents.ts @@ -215,6 +215,6 @@ export async function triggerRandomEvent(user: MUser, type: activity_type_enum, } loot.add(event.loot.roll()); await transactItems({ userID: user.id, itemsToAdd: loot, collectionLog: true }); - await userStatsBankUpdate(user.id, 'random_event_completions_bank', new Bank().add(event.id)); + await userStatsBankUpdate(user, 'random_event_completions_bank', new Bank().add(event.id)); messages.push(`Did ${event.name} random event and got ${loot}`); } diff --git a/src/lib/simulation/toa.ts b/src/lib/simulation/toa.ts index b5f0249a5f..8e0d27684f 100644 --- a/src/lib/simulation/toa.ts +++ b/src/lib/simulation/toa.ts @@ -1230,7 +1230,7 @@ export async function toaStartCommand( user: u }); } - await userStatsBankUpdate(u.id, 'toa_cost', realCost); + await userStatsBankUpdate(u, 'toa_cost', realCost); const effectiveCost = realCost.clone(); totalCost.add(effectiveCost); diff --git a/src/lib/util.ts b/src/lib/util.ts index 1822cb31ac..b55f5ff655 100644 --- a/src/lib/util.ts +++ b/src/lib/util.ts @@ -18,6 +18,7 @@ import { Time, objectEntries } from 'e'; import type { Bank } from 'oldschooljs'; import { bool, integer, nativeMath, nodeCrypto, real } from 'random-js'; +import type { Prisma } from '@prisma/client'; import { LRUCache } from 'lru-cache'; import { ADMIN_IDS, OWNER_IDS, SupportServer } from '../config'; import type { MUserClass } from './MUser'; @@ -440,3 +441,7 @@ export function normalizeTOAUsers(data: TOAOptions) { export function anyoneDiedInTOARaid(data: TOAOptions) { return normalizeTOAUsers(data).some(userArr => userArr.some(user => user.deaths.length > 0)); } + +export type JsonKeys = { + [K in keyof T]: T[K] extends Prisma.JsonValue ? K : never; +}[keyof T]; diff --git a/src/lib/util/handleTripFinish.ts b/src/lib/util/handleTripFinish.ts index b5e1614913..91f371e5c7 100644 --- a/src/lib/util/handleTripFinish.ts +++ b/src/lib/util/handleTripFinish.ts @@ -66,7 +66,7 @@ const tripFinishEffects: TripFinishEffect[] = [ if (imp && imp.bank.length > 0) { const many = imp.bank.length > 1; messages.push(`Caught ${many ? 'some' : 'an'} impling${many ? 's' : ''}, you received: ${imp.bank}`); - userStatsBankUpdate(user.id, 'passive_implings_bank', imp.bank); + userStatsBankUpdate(user, 'passive_implings_bank', imp.bank); await transactItems({ userID: user.id, itemsToAdd: imp.bank, collectionLog: true }); } } diff --git a/src/mahoji/commands/buy.ts b/src/mahoji/commands/buy.ts index bfb55140be..a0eb017be8 100644 --- a/src/mahoji/commands/buy.ts +++ b/src/mahoji/commands/buy.ts @@ -16,7 +16,7 @@ import { updateBankSetting } from '../../lib/util/updateBankSetting'; import { buyFossilIslandNotes } from '../lib/abstracted_commands/buyFossilIslandNotes'; import { buyKitten } from '../lib/abstracted_commands/buyKitten'; import type { OSBMahojiCommand } from '../lib/util'; -import { mahojiParseNumber, multipleUserStatsBankUpdate } from '../mahojiSettings'; +import { mahojiParseNumber, userStatsUpdate } from '../mahojiSettings'; const allBuyablesAutocomplete = [...Buyables, { name: 'Kitten' }, { name: 'Fossil Island Notes' }]; @@ -151,12 +151,13 @@ export const buyCommand: OSBMahojiCommand = { .remove('Coins', totalCost.amount('Coins')).bank; if (Object.keys(costBankExcludingGP).length === 0) costBankExcludingGP = undefined; + const currentStats = await user.fetchStats({ buy_cost_bank: true, buy_loot_bank: true }); await Promise.all([ updateBankSetting('buy_cost_bank', totalCost), updateBankSetting('buy_loot_bank', outItems), - multipleUserStatsBankUpdate(user.id, { - buy_cost_bank: totalCost, - buy_loot_bank: outItems + userStatsUpdate(user.id, { + buy_cost_bank: totalCost.clone().add(currentStats.buy_cost_bank as ItemBank).bank, + buy_loot_bank: outItems.clone().add(currentStats.buy_loot_bank as ItemBank).bank }), prisma.buyCommandTransaction.create({ data: { diff --git a/src/mahoji/commands/create.ts b/src/mahoji/commands/create.ts index f78bcd21b1..5fb0db5c3d 100644 --- a/src/mahoji/commands/create.ts +++ b/src/mahoji/commands/create.ts @@ -203,8 +203,8 @@ export const createCommand: OSBMahojiCommand = { await updateBankSetting('create_cost', inItems); await updateBankSetting('create_loot', outItems); - await userStatsBankUpdate(user.id, 'create_cost_bank', inItems); - await userStatsBankUpdate(user.id, 'create_loot_bank', outItems); + await userStatsBankUpdate(user, 'create_cost_bank', inItems); + await userStatsBankUpdate(user, 'create_loot_bank', outItems); if (action === 'revert') { return `You reverted ${inItems} into ${outItems}.${extraMessage}`; diff --git a/src/mahoji/commands/offer.ts b/src/mahoji/commands/offer.ts index d4432485cd..dd52db93ae 100644 --- a/src/mahoji/commands/offer.ts +++ b/src/mahoji/commands/offer.ts @@ -187,7 +187,7 @@ export const offerCommand: OSBMahojiCommand = { itemsToAdd: loot, itemsToRemove: cost }); - await userStatsBankUpdate(user.id, 'bird_eggs_offered_bank', cost); + await userStatsBankUpdate(user, 'bird_eggs_offered_bank', cost); notifyUniques(user, egg.name, evilChickenOutfit, loot, quantity); diff --git a/src/mahoji/commands/sacrifice.ts b/src/mahoji/commands/sacrifice.ts index 0f34b3d7c1..31916e619a 100644 --- a/src/mahoji/commands/sacrifice.ts +++ b/src/mahoji/commands/sacrifice.ts @@ -21,7 +21,7 @@ import { sellPriceOfItem } from './sell'; async function trackSacBank(user: MUser, bank: Bank) { await Promise.all([ updateBankSetting('economyStats_sacrificedBank', bank), - userStatsBankUpdate(user.id, 'sacrificed_bank', bank) + userStatsBankUpdate(user, 'sacrificed_bank', bank) ]); const stats = await user.fetchStats({ sacrificed_bank: true }); return new Bank(stats.sacrificed_bank as ItemBank); diff --git a/src/mahoji/commands/sell.ts b/src/mahoji/commands/sell.ts index 432012da65..7253fbf2a9 100644 --- a/src/mahoji/commands/sell.ts +++ b/src/mahoji/commands/sell.ts @@ -3,7 +3,7 @@ import type { Prisma } from '@prisma/client'; import { ApplicationCommandOptionType } from 'discord.js'; import { clamp, reduceNumByPercent } from 'e'; import { Bank } from 'oldschooljs'; -import type { Item, ItemBank } from 'oldschooljs/dist/meta/types'; +import type { Item } from 'oldschooljs/dist/meta/types'; import { MAX_INT_JAVA } from '../../lib/constants'; @@ -14,7 +14,7 @@ import { parseBank } from '../../lib/util/parseStringBank'; import { updateBankSetting } from '../../lib/util/updateBankSetting'; import { filterOption } from '../lib/mahojiCommandOptions'; import type { OSBMahojiCommand } from '../lib/util'; -import { updateClientGPTrackSetting, userStatsUpdate } from '../mahojiSettings'; +import { updateClientGPTrackSetting, userStatsBankUpdate, userStatsUpdate } from '../mahojiSettings'; /** * - Hardcoded prices @@ -265,14 +265,14 @@ export const sellCommand: OSBMahojiCommand = { await Promise.all([ updateClientGPTrackSetting('gp_sell', totalPrice), updateBankSetting('sold_items_bank', bankToSell), + userStatsBankUpdate(user, 'items_sold_bank', bankToSell), userStatsUpdate( user.id, - userStats => ({ - items_sold_bank: new Bank(userStats.items_sold_bank as ItemBank).add(bankToSell).bank, + { sell_gp: { increment: totalPrice } - }), + }, {} ), prisma.botItemSell.createMany({ data: botItemSellData }) diff --git a/src/mahoji/commands/testpotato.ts b/src/mahoji/commands/testpotato.ts index 4cc5505d3c..d429bc1bdb 100644 --- a/src/mahoji/commands/testpotato.ts +++ b/src/mahoji/commands/testpotato.ts @@ -879,14 +879,15 @@ ${droprates.join('\n')}`), stringMatches(m.name, options.setmonsterkc?.monster ?? '') ); if (!monster) return 'Invalid monster'; + const stats = await user.fetchStats({ monster_scores: true }); await userStatsUpdate( user.id, - ({ monster_scores }) => ({ + { monster_scores: { - ...(monster_scores as Record), + ...(stats.monster_scores as Record), [monster.id]: options.setmonsterkc?.kc ?? 1 } - }), + }, {} ); return `Set your ${monster.name} KC to ${options.setmonsterkc.kc ?? 1}.`; diff --git a/src/mahoji/lib/abstracted_commands/farmingCommand.ts b/src/mahoji/lib/abstracted_commands/farmingCommand.ts index c0b24fa88b..9d094766df 100644 --- a/src/mahoji/lib/abstracted_commands/farmingCommand.ts +++ b/src/mahoji/lib/abstracted_commands/farmingCommand.ts @@ -299,7 +299,7 @@ export async function farmingPlantCommand({ } }); - await userStatsBankUpdate(user.id, 'farming_plant_cost_bank', cost); + await userStatsBankUpdate(user, 'farming_plant_cost_bank', cost); await addSubTaskToActivityTask({ plantsName: plant.name, diff --git a/src/mahoji/lib/abstracted_commands/giantsFoundryCommand.ts b/src/mahoji/lib/abstracted_commands/giantsFoundryCommand.ts index 4706d86de3..63bb5cfa19 100644 --- a/src/mahoji/lib/abstracted_commands/giantsFoundryCommand.ts +++ b/src/mahoji/lib/abstracted_commands/giantsFoundryCommand.ts @@ -228,7 +228,7 @@ export async function giantsFoundryStartCommand( } ] }); - await userStatsBankUpdate(user.id, 'gf_cost', totalCost); + await userStatsBankUpdate(user, 'gf_cost', totalCost); await addSubTaskToActivityTask({ quantity, diff --git a/src/mahoji/lib/abstracted_commands/shadesOfMortonCommand.ts b/src/mahoji/lib/abstracted_commands/shadesOfMortonCommand.ts index c03f36826f..610571d084 100644 --- a/src/mahoji/lib/abstracted_commands/shadesOfMortonCommand.ts +++ b/src/mahoji/lib/abstracted_commands/shadesOfMortonCommand.ts @@ -298,7 +298,7 @@ export async function shadesOfMortonStartCommand(user: MUser, channelID: string, if (!user.owns(cost)) return `You don't own: ${cost}.`; await user.removeItemsFromBank(cost); - await userStatsBankUpdate(user.id, 'shades_of_morton_cost_bank', cost); + await userStatsBankUpdate(user, 'shades_of_morton_cost_bank', cost); await addSubTaskToActivityTask({ userID: user.id, diff --git a/src/mahoji/lib/abstracted_commands/tobCommand.ts b/src/mahoji/lib/abstracted_commands/tobCommand.ts index 3b1897abad..c8f9d1930b 100644 --- a/src/mahoji/lib/abstracted_commands/tobCommand.ts +++ b/src/mahoji/lib/abstracted_commands/tobCommand.ts @@ -427,7 +427,7 @@ export async function tobStartCommand( .add(u.gear.range.ammo?.item, 100) .multiply(qty) ); - await userStatsBankUpdate(u.id, 'tob_cost', realCost); + await userStatsBankUpdate(u, 'tob_cost', realCost); const effectiveCost = realCost.clone().remove('Coins', realCost.amount('Coins')); totalCost.add(effectiveCost); if (u.gear.melee.hasEquipped('Abyssal tentacle')) { diff --git a/src/mahoji/mahojiSettings.ts b/src/mahoji/mahojiSettings.ts index 1d70e30797..75d08466b9 100644 --- a/src/mahoji/mahojiSettings.ts +++ b/src/mahoji/mahojiSettings.ts @@ -1,6 +1,6 @@ import { evalMathExpression } from '@oldschoolgg/toolkit'; import type { Prisma, User, UserStats } from '@prisma/client'; -import { isFunction, objectEntries, round } from 'e'; +import { isObject, objectEntries, round } from 'e'; import { Bank } from 'oldschooljs'; import type { SelectedUserStats } from '../lib/MUser'; @@ -11,6 +11,7 @@ import type { Rune } from '../lib/skilling/skills/runecraft'; import { hasGracefulEquipped } from '../lib/structures/Gear'; import type { ItemBank } from '../lib/types'; import { + type JsonKeys, anglerBoosts, formatItemReqs, hasSkillReqs, @@ -71,37 +72,14 @@ export function getMahojiBank(user: { bank: Prisma.JsonValue }) { export async function userStatsUpdate( userID: string, - data: Omit | ((u: UserStats) => Prisma.UserStatsUpdateInput), + data: Omit, selectKeys?: T ): Promise> { const id = BigInt(userID); - let keys: object | undefined = selectKeys; if (!selectKeys || Object.keys(selectKeys).length === 0) { keys = { user_id: true }; } - - if (isFunction(data)) { - const userStats = await prisma.userStats.upsert({ - create: { - user_id: id - }, - update: {}, - where: { - user_id: id - }, - select: keys - }); - - return (await prisma.userStats.update({ - data: data(userStats), - where: { - user_id: id - }, - select: keys - })) as SelectedUserStats; - } - await prisma.userStats.upsert({ create: { user_id: id @@ -122,27 +100,19 @@ export async function userStatsUpdate; } -export async function userStatsBankUpdate(userID: string, key: keyof UserStats, bank: Bank) { - await userStatsUpdate( - userID, - u => ({ - [key]: bank.clone().add(u[key] as ItemBank).bank - }), - { [key]: true } - ); -} - -export async function multipleUserStatsBankUpdate(userID: string, updates: Partial>) { +export async function userStatsBankUpdate(user: MUser, key: JsonKeys, bank: Bank) { + if (!key) throw new Error('No key provided to userStatsBankUpdate'); + const stats = await user.fetchStats({ [key]: true }); + const currentItemBank = stats[key] as ItemBank; + if (!isObject(currentItemBank)) { + throw new Error(`Key ${key} is not an object.`); + } await userStatsUpdate( - userID, - u => { - const updateObj: Prisma.UserStatsUpdateInput = {}; - for (const [key, bank] of objectEntries(updates)) { - updateObj[key] = bank?.clone().add(u[key] as ItemBank).bank; - } - return updateObj; + user.id, + { + [key]: bank.clone().add(currentItemBank).bank }, - {} + { [key]: true } ); } @@ -340,12 +310,13 @@ export async function addToGPTaxBalance(userID: string | string, amount: number) ]); } -export async function addToOpenablesScores(mahojiUser: MUser, kcBank: Bank) { +export async function addToOpenablesScores(user: MUser, kcBank: Bank) { + const stats = await user.fetchStats({ openable_scores: true }); const { openable_scores: newOpenableScores } = await userStatsUpdate( - mahojiUser.id, - ({ openable_scores }) => ({ - openable_scores: new Bank(openable_scores as ItemBank).add(kcBank).bank - }), + user.id, + { + openable_scores: new Bank(stats.openable_scores as ItemBank).add(kcBank).bank + }, { openable_scores: true } ); return new Bank(newOpenableScores as ItemBank); diff --git a/src/tasks/minions/PrayerActivity/scatteringActivity.ts b/src/tasks/minions/PrayerActivity/scatteringActivity.ts index 3a3ab92b9d..a94d07b378 100644 --- a/src/tasks/minions/PrayerActivity/scatteringActivity.ts +++ b/src/tasks/minions/PrayerActivity/scatteringActivity.ts @@ -24,7 +24,7 @@ export const scatteringTask: MinionTask = { const str = `${user}, ${user.minionName} finished scattering ${quantity}x ${ash.name}. ${xpRes}`; - await userStatsBankUpdate(user.id, 'scattered_ashes_bank', new Bank().add(ash.inputId, quantity)); + await userStatsBankUpdate(user, 'scattered_ashes_bank', new Bank().add(ash.inputId, quantity)); handleTripFinish(user, channelID, str, undefined, data, null); } diff --git a/src/tasks/minions/agilityActivity.ts b/src/tasks/minions/agilityActivity.ts index fbf394197d..1d09a9fbe6 100644 --- a/src/tasks/minions/agilityActivity.ts +++ b/src/tasks/minions/agilityActivity.ts @@ -70,11 +70,12 @@ export const agilityTask: MinionTask = { const xpReceived = (quantity - lapsFailed / 2) * (typeof course.xp === 'number' ? course.xp : course.xp(currentLevel)); + const stats = await user.fetchStats({ laps_scores: true }); const { laps_scores: newLapScores } = await userStatsUpdate( user.id, - ({ laps_scores }) => ({ - laps_scores: addItemToBank(laps_scores as ItemBank, course.id, quantity - lapsFailed) - }), + { + laps_scores: addItemToBank(stats.laps_scores as ItemBank, course.id, quantity - lapsFailed) + }, { laps_scores: true } ); diff --git a/src/tasks/minions/colosseumActivity.ts b/src/tasks/minions/colosseumActivity.ts index 305b05b6d5..8b38486804 100644 --- a/src/tasks/minions/colosseumActivity.ts +++ b/src/tasks/minions/colosseumActivity.ts @@ -67,7 +67,7 @@ export const colosseumTask: MinionTask = { const { previousCL } = await user.addItemsToBank({ items: loot, collectionLog: true }); await updateBankSetting('colo_loot', loot); - await userStatsBankUpdate(user.id, 'colo_loot', loot); + await userStatsBankUpdate(user, 'colo_loot', loot); await trackLoot({ totalLoot: loot, id: 'colo', diff --git a/src/tasks/minions/farmingActivity.ts b/src/tasks/minions/farmingActivity.ts index 7ab0a38dd3..f3538c8bb5 100644 --- a/src/tasks/minions/farmingActivity.ts +++ b/src/tasks/minions/farmingActivity.ts @@ -228,7 +228,7 @@ export const farmingTask: MinionTask = { const uncleanedHerbLoot = new Bank().add(plantToHarvest.outputCrop, cropYield); await user.addItemsToCollectionLog(uncleanedHerbLoot); const cleanedHerbLoot = new Bank().add(plantToHarvest.cleanHerbCrop, cropYield); - await userStatsBankUpdate(user.id, 'herbs_cleaned_while_farming_bank', cleanedHerbLoot); + await userStatsBankUpdate(user, 'herbs_cleaned_while_farming_bank', cleanedHerbLoot); } if (plantToHarvest.name === 'Limpwurt') { @@ -437,13 +437,13 @@ export const farmingTask: MinionTask = { infoStr.push(`\n${user.minionName} tells you to come back after your plants have finished growing!`); } - updateBankSetting('farming_loot_bank', loot); + await updateBankSetting('farming_loot_bank', loot); await transactItems({ userID: user.id, collectionLog: true, itemsToAdd: loot }); - await userStatsBankUpdate(user.id, 'farming_harvest_loot_bank', loot); + await userStatsBankUpdate(user, 'farming_harvest_loot_bank', loot); if (pid) { await prisma.farmedCrop.update({ where: { diff --git a/src/tasks/minions/minigames/giantsFoundryActivity.ts b/src/tasks/minions/minigames/giantsFoundryActivity.ts index 1b9facbf8f..059bcc0974 100644 --- a/src/tasks/minions/minigames/giantsFoundryActivity.ts +++ b/src/tasks/minions/minigames/giantsFoundryActivity.ts @@ -100,7 +100,7 @@ export const giantsFoundryTask: MinionTask = { } ] }); - await userStatsBankUpdate(user.id, 'gf_loot', loot); + await userStatsBankUpdate(user, 'gf_loot', loot); handleTripFinish(user, channelID, str, undefined, data, itemsAdded); } diff --git a/src/tasks/minions/minigames/puroPuroActivity.ts b/src/tasks/minions/minigames/puroPuroActivity.ts index 7ecf566691..7dd9701703 100644 --- a/src/tasks/minions/minigames/puroPuroActivity.ts +++ b/src/tasks/minions/minigames/puroPuroActivity.ts @@ -164,7 +164,7 @@ export const puroPuroTask: MinionTask = { itemsToRemove: itemCost }); - userStatsBankUpdate(user.id, 'puropuro_implings_bank', bank); + userStatsBankUpdate(user, 'puropuro_implings_bank', bank); handleTripFinish(user, channelID, str, undefined, data, bank); } diff --git a/src/tasks/minions/minigames/toaActivity.ts b/src/tasks/minions/minigames/toaActivity.ts index fe502a03f2..86a2b9880d 100644 --- a/src/tasks/minions/minigames/toaActivity.ts +++ b/src/tasks/minions/minigames/toaActivity.ts @@ -159,21 +159,16 @@ export const toaTask: MinionTask = { itemsAddedTeamLoot.add(userID, itemsAdded); - userStatsUpdate( - user.id, - u => { - return { - toa_raid_levels_bank: new Bank() - .add(u.toa_raid_levels_bank as ItemBank) - .add(raidLevel, quantity).bank, - total_toa_duration_minutes: { - increment: Math.floor(duration / Time.Minute) - }, - toa_loot: new Bank(u.toa_loot as ItemBank).add(totalLoot.get(userID)).bank - }; + const currentStats = await user.fetchStats({ toa_raid_levels_bank: true, toa_loot: true }); + await userStatsUpdate(user.id, { + toa_raid_levels_bank: new Bank() + .add(currentStats.toa_raid_levels_bank as ItemBank) + .add(raidLevel, quantity).bank, + total_toa_duration_minutes: { + increment: Math.floor(duration / Time.Minute) }, - {} - ); + toa_loot: new Bank(currentStats.toa_loot as ItemBank).add(totalLoot.get(userID)).bank + }); const items = itemsAdded.items(); diff --git a/src/tasks/minions/minigames/tobActivity.ts b/src/tasks/minions/minigames/tobActivity.ts index f5b997c8f2..a9755bb34a 100644 --- a/src/tasks/minions/minigames/tobActivity.ts +++ b/src/tasks/minions/minigames/tobActivity.ts @@ -115,7 +115,7 @@ export const tobTask: MinionTask = { // Track loot for T3+ patrons await Promise.all( allUsers.map(user => { - return userStatsBankUpdate(user.id, 'tob_loot', new Bank(result.loot[user.id])); + return userStatsBankUpdate(user, 'tob_loot', new Bank(result.loot[user.id])); }) ); diff --git a/src/tasks/minions/woodcuttingActivity.ts b/src/tasks/minions/woodcuttingActivity.ts index e31dbf1216..450923254f 100644 --- a/src/tasks/minions/woodcuttingActivity.ts +++ b/src/tasks/minions/woodcuttingActivity.ts @@ -127,7 +127,7 @@ async function handleForestry({ user, duration, loot }: { user: MUser; duration: for (const [event, count] of objectEntries(eventCounts)) { if (event && count && count > 0) { totalEvents += count; - await userStatsBankUpdate(user.id, 'forestry_event_completions_bank', new Bank().add(Number(event), count)); + await userStatsBankUpdate(user, 'forestry_event_completions_bank', new Bank().add(Number(event), count)); } } diff --git a/tests/integration/migrateUser.test.ts b/tests/integration/migrateUser.test.ts index 0df552d40f..350f0e8c98 100644 --- a/tests/integration/migrateUser.test.ts +++ b/tests/integration/migrateUser.test.ts @@ -935,17 +935,18 @@ const allTableCommands: TestCommand[] = [ user_id: user.id }); + const stats = await user.fetchStats({ items_sold_bank: true }); await Promise.all([ updateClientGPTrackSetting('gp_sell', totalPrice), updateBankSetting('sold_items_bank', bankToSell), userStatsUpdate( user.id, - userStats => ({ - items_sold_bank: new Bank(userStats.items_sold_bank as ItemBank).add(bankToSell).bank, + { + items_sold_bank: new Bank(stats.items_sold_bank as ItemBank).add(bankToSell).bank, sell_gp: { increment: totalPrice } - }), + }, {} ), global.prisma!.botItemSell.createMany({ data: botItemSellData }) diff --git a/tests/integration/rolesTask.test.ts b/tests/integration/rolesTask.test.ts index df57e5e208..252bee8faf 100644 --- a/tests/integration/rolesTask.test.ts +++ b/tests/integration/rolesTask.test.ts @@ -11,10 +11,10 @@ import { createTestUser, mockedId, unMockedCyptoRand } from './util'; describe.skip('Roles Task', async () => { test('Should not throw', async () => { const user = await createTestUser(); - await userStatsBankUpdate(user.id, 'sacrificed_bank', new Bank().add('Coal', 10_000)); + await userStatsBankUpdate(user, 'sacrificed_bank', new Bank().add('Coal', 10_000)); const ironUser = await createTestUser(); await ironUser.update({ minion_ironman: true, sacrificedValue: 1_000_000 }); - await userStatsBankUpdate(ironUser.id, 'sacrificed_bank', new Bank().add('Coal', 10_000)); + await userStatsBankUpdate(ironUser, 'sacrificed_bank', new Bank().add('Coal', 10_000)); // Create minigame scores: const minigames = Minigames.map(game => game.column).filter(i => i !== 'tithe_farm'); diff --git a/tests/integration/userStats.test.ts b/tests/integration/userStats.test.ts index f6815ebaba..f0a74eef4c 100644 --- a/tests/integration/userStats.test.ts +++ b/tests/integration/userStats.test.ts @@ -23,11 +23,11 @@ describe('User Stats', async () => { expect(result).toEqual({ user_id: BigInt(userID) }); const result2 = await userStatsUpdate( userID, - () => ({ + { ash_sanctifier_prayer_xp: { increment: 100 } - }), + }, {} ); expect(result2).toEqual({ user_id: BigInt(userID) }); From 4c624769a17dc1803117d54903665ef64321e218 Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Fri, 19 Jul 2024 16:14:30 +1000 Subject: [PATCH 091/145] Log time difference in interaction errors --- src/lib/util/logError.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/lib/util/logError.ts b/src/lib/util/logError.ts index 29a17924f2..e709616166 100644 --- a/src/lib/util/logError.ts +++ b/src/lib/util/logError.ts @@ -45,7 +45,10 @@ export function logErrorForInteraction( guild_id: interaction.guildId, interaction_id: interaction.id, interaction_type: interaction.type, - ...extraContext + ...extraContext, + interaction_created_at: interaction.createdTimestamp, + current_timestamp: Date.now(), + difference_ms: Date.now() - interaction.createdTimestamp }; if (interaction.isChatInputCommand()) { context.options = JSON.stringify( From abdfafb87a29781d4b2099453c0a08eebcf5be42 Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Fri, 19 Jul 2024 16:14:44 +1000 Subject: [PATCH 092/145] Use interactionReply for shutdown/reboot messages --- src/index.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/index.ts b/src/index.ts index e4a52bfb19..c3210e8979 100644 --- a/src/index.ts +++ b/src/index.ts @@ -26,7 +26,7 @@ import { OldSchoolBotClient } from './lib/structures/OldSchoolBotClient'; import { assert, runTimedLoggedFn } from './lib/util'; import { CACHED_ACTIVE_USER_IDS, syncActiveUserIDs } from './lib/util/cachedUserIDs'; import { interactionHook } from './lib/util/globalInteractions'; -import { handleInteractionError } from './lib/util/interactionReply'; +import { handleInteractionError, interactionReply } from './lib/util/interactionReply'; import { logError } from './lib/util/logError'; import { syncDisabledCommands } from './lib/util/syncDisabledCommands'; import { allCommands } from './mahoji/commands/allCommands'; @@ -146,7 +146,7 @@ client.on('interactionCreate', async interaction => { if (globalClient.isShuttingDown) { if (interaction.isRepliable()) { - await interaction.reply({ + await interactionReply(interaction,{ content: 'Old School Bot is currently shutting down for maintenance/updates, please try again in a couple minutes! Thank you <3', ephemeral: true @@ -157,7 +157,7 @@ client.on('interactionCreate', async interaction => { if (!client.isReady() || !client.uptime || client.uptime < Time.Second * 10) { if (interaction.isRepliable()) { - await interaction.reply({ + await interactionReply(interaction,{ content: 'Old School Bot is currently rebooting, please try again in a few seconds! Thank you <3', ephemeral: true }); From 09a7e506e13da9f8b09d5e803434f0a28b877344 Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Fri, 19 Jul 2024 16:34:40 +1000 Subject: [PATCH 093/145] Switch some usage of production var --- src/mahoji/lib/events.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/mahoji/lib/events.ts b/src/mahoji/lib/events.ts index 45d7db1cca..7d0a50cf85 100644 --- a/src/mahoji/lib/events.ts +++ b/src/mahoji/lib/events.ts @@ -1,10 +1,8 @@ import type { ItemBank } from 'oldschooljs/dist/meta/types'; import { bulkUpdateCommands } from '@oldschoolgg/toolkit'; -import { production } from '../../config'; import { Channel, META_CONSTANTS, globalConfig } from '../../lib/constants'; import { initCrons } from '../../lib/crons'; - import { initTickers } from '../../lib/tickers'; import { mahojiClientSettingsFetch } from '../../lib/util/clientSettings'; import { sendToChannelID } from '../../lib/util/webhook'; @@ -18,8 +16,10 @@ export async function syncCustomPrices() { } export async function onStartup() { - globalClient.application.commands.fetch({ guildId: production ? undefined : globalConfig.testingServerID }); - if (!production) { + globalClient.application.commands.fetch({ + guildId: globalConfig.isProduction ? undefined : globalConfig.testingServerID + }); + if (!globalConfig.isProduction) { console.log('Syncing commands locally...'); await bulkUpdateCommands({ client: globalClient.mahojiClient, @@ -31,7 +31,7 @@ export async function onStartup() { initCrons(); initTickers(); - if (production) { + if (globalConfig.isProduction) { sendToChannelID(Channel.GeneralChannel, { content: `I have just turned on! From 26bc6f177ad7d4f974822af023d6ce2588171cbc Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Fri, 19 Jul 2024 16:34:51 +1000 Subject: [PATCH 094/145] Dont show too lnog message for favorite items --- src/mahoji/commands/config.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/mahoji/commands/config.ts b/src/mahoji/commands/config.ts index 46d145ebae..5be1c602b3 100644 --- a/src/mahoji/commands/config.ts +++ b/src/mahoji/commands/config.ts @@ -212,8 +212,9 @@ async function favItemConfig( const currentFavorites = user.user.favoriteItems; const item = getItem(itemToAdd ?? itemToRemove); const currentItems = `Your current favorite items are: ${ - currentFavorites.length === 0 ? 'None' : currentFavorites.map(itemNameFromID).join(', ') + currentFavorites.length === 0 ? 'None' : currentFavorites.map(itemNameFromID).join(', ').slice(0, 1500) }.`; + if (!item) return currentItems; if (itemToAdd) { const limit = (user.perkTier() + 1) * 100; From e1b95bfa6f3e527886e72ca1aeb034a2a66746ea Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Fri, 19 Jul 2024 16:35:15 +1000 Subject: [PATCH 095/145] Log client error events --- src/index.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/index.ts b/src/index.ts index c3210e8979..83193da10b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -140,13 +140,14 @@ global.globalClient = client; client.on('messageCreate', msg => { onMessage(msg); }); +client.on('error', console.error); client.on('interactionCreate', async interaction => { if (BLACKLISTED_USERS.has(interaction.user.id)) return; if (interaction.guildId && BLACKLISTED_GUILDS.has(interaction.guildId)) return; if (globalClient.isShuttingDown) { if (interaction.isRepliable()) { - await interactionReply(interaction,{ + await interactionReply(interaction, { content: 'Old School Bot is currently shutting down for maintenance/updates, please try again in a couple minutes! Thank you <3', ephemeral: true @@ -157,7 +158,7 @@ client.on('interactionCreate', async interaction => { if (!client.isReady() || !client.uptime || client.uptime < Time.Second * 10) { if (interaction.isRepliable()) { - await interactionReply(interaction,{ + await interactionReply(interaction, { content: 'Old School Bot is currently rebooting, please try again in a few seconds! Thank you <3', ephemeral: true }); From 19cd2358c1f0e43c04a8c879ba778fb6dbca88c9 Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Fri, 19 Jul 2024 21:04:04 +1000 Subject: [PATCH 096/145] Some changes --- src/index.ts | 2 +- src/lib/util.ts | 4 ++++ src/lib/util/cachedUserIDs.ts | 22 ++++++++++---------- src/mahoji/lib/events.ts | 39 ++++++++++++++++++----------------- 4 files changed, 36 insertions(+), 31 deletions(-) diff --git a/src/index.ts b/src/index.ts index 83193da10b..9fbbf9ae2b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -201,7 +201,7 @@ client.on('guildCreate', guild => { }); client.on('shardError', err => debugLog('Shard Error', { error: err.message })); -client.once('ready', () => runTimedLoggedFn('OnStartup', async () => onStartup())); +client.once('ready', () => onStartup()); async function main() { if (process.env.TEST) return; diff --git a/src/lib/util.ts b/src/lib/util.ts index b55f5ff655..668daa25c9 100644 --- a/src/lib/util.ts +++ b/src/lib/util.ts @@ -395,6 +395,10 @@ export async function runTimedLoggedFn(name: string, fn: () => Promise) } } +export function logWrapFn(name: string, fn: () => Promise) { + return () => runTimedLoggedFn(name, fn); +} + export function isModOrAdmin(user: MUser) { return [...OWNER_IDS, ...ADMIN_IDS].includes(user.id) || user.bitfield.includes(BitField.isModerator); } diff --git a/src/lib/util/cachedUserIDs.ts b/src/lib/util/cachedUserIDs.ts index 492c0450e1..ee7e92a5b9 100644 --- a/src/lib/util/cachedUserIDs.ts +++ b/src/lib/util/cachedUserIDs.ts @@ -4,28 +4,28 @@ import { objectEntries } from 'e'; import { OWNER_IDS, SupportServer } from '../../config'; import { globalConfig } from '../constants'; -import { runTimedLoggedFn } from '../util'; +import { logWrapFn, runTimedLoggedFn } from '../util'; export const CACHED_ACTIVE_USER_IDS = new Set(); CACHED_ACTIVE_USER_IDS.add(globalConfig.clientID); for (const id of OWNER_IDS) CACHED_ACTIVE_USER_IDS.add(id); -export async function syncActiveUserIDs() { - const users = await prisma.$queryRawUnsafe< - { user_id: string }[] - >(`SELECT DISTINCT(${Prisma.ActivityScalarFieldEnum.user_id}::text) +export const syncActiveUserIDs = logWrapFn('Sync Active User IDs', async () => { + const users = await prisma.$queryRawUnsafe< + { user_id: string }[] + >(`SELECT DISTINCT(${Prisma.ActivityScalarFieldEnum.user_id}::text) FROM activity WHERE finish_date > now() - INTERVAL '48 hours'`); - const perkTierUsers = await roboChimpClient.$queryRawUnsafe<{ id: string }[]>(`SELECT id::text + const perkTierUsers = await roboChimpClient.$queryRawUnsafe<{ id: string }[]>(`SELECT id::text FROM "user" WHERE perk_tier > 0;`); - for (const id of [...users.map(i => i.user_id), ...perkTierUsers.map(i => i.id)]) { - CACHED_ACTIVE_USER_IDS.add(id); - } - debugLog(`${CACHED_ACTIVE_USER_IDS.size} cached active user IDs`); -} + for (const id of [...users.map(i => i.user_id), ...perkTierUsers.map(i => i.id)]) { + CACHED_ACTIVE_USER_IDS.add(id); + } + debugLog(`${CACHED_ACTIVE_USER_IDS.size} cached active user IDs`); +}); export function memoryAnalysis() { const guilds = globalClient.guilds.cache.size; diff --git a/src/mahoji/lib/events.ts b/src/mahoji/lib/events.ts index 7d0a50cf85..2b22286933 100644 --- a/src/mahoji/lib/events.ts +++ b/src/mahoji/lib/events.ts @@ -7,6 +7,7 @@ import { initTickers } from '../../lib/tickers'; import { mahojiClientSettingsFetch } from '../../lib/util/clientSettings'; import { sendToChannelID } from '../../lib/util/webhook'; import { CUSTOM_PRICE_CACHE } from '../commands/sell'; +import { logWrapFn } from '../../lib/util'; export async function syncCustomPrices() { const clientData = await mahojiClientSettingsFetch({ custom_prices: true }); @@ -15,27 +16,27 @@ export async function syncCustomPrices() { } } -export async function onStartup() { - globalClient.application.commands.fetch({ - guildId: globalConfig.isProduction ? undefined : globalConfig.testingServerID - }); - if (!globalConfig.isProduction) { - console.log('Syncing commands locally...'); - await bulkUpdateCommands({ - client: globalClient.mahojiClient, - commands: Array.from(globalClient.mahojiClient.commands.values()), - guildID: globalConfig.testingServerID +export const onStartup = logWrapFn("onStartup", async () => { + globalClient.application.commands.fetch({ + guildId: globalConfig.isProduction ? undefined : globalConfig.testingServerID }); - } + if (!globalConfig.isProduction) { + console.log('Syncing commands locally...'); + await bulkUpdateCommands({ + client: globalClient.mahojiClient, + commands: Array.from(globalClient.mahojiClient.commands.values()), + guildID: globalConfig.testingServerID + }); + } - initCrons(); - initTickers(); + initCrons(); + initTickers(); - if (globalConfig.isProduction) { - sendToChannelID(Channel.GeneralChannel, { - content: `I have just turned on! + if (globalConfig.isProduction) { + sendToChannelID(Channel.GeneralChannel, { + content: `I have just turned on! ${META_CONSTANTS.RENDERED_STR}` - }).catch(console.error); - } -} + }).catch(console.error); + } + }); From 1509bed6aa97bf674d6e827d52c545dde9ccb498 Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Fri, 19 Jul 2024 21:05:23 +1000 Subject: [PATCH 097/145] Lint --- src/lib/Task.ts | 10 ++++----- src/lib/util/cachedUserIDs.ts | 16 +++++++------- src/mahoji/lib/events.ts | 40 +++++++++++++++++------------------ 3 files changed, 33 insertions(+), 33 deletions(-) diff --git a/src/lib/Task.ts b/src/lib/Task.ts index efc54e5357..c243efe2ee 100644 --- a/src/lib/Task.ts +++ b/src/lib/Task.ts @@ -96,6 +96,7 @@ import { modifyBusyCounter } from './busyCounterCache'; import { minionActivityCache } from './constants'; import { convertStoredActivityToFlatActivity } from './settings/prisma'; import { activitySync, minionActivityCacheDelete } from './settings/settings'; +import { logWrapFn } from './util'; import { logError } from './util/logError'; const tasks: MinionTask[] = [ @@ -214,18 +215,17 @@ export async function processPendingActivities() { completed: true } }); + await Promise.all(activities.map(completeActivity)); } - - await Promise.all(activities.map(completeActivity)); - return activities; } -export async function syncActivityCache() { + +export const syncActivityCache = logWrapFn('syncActivityCache', async () => { const tasks = await prisma.activity.findMany({ where: { completed: false } }); minionActivityCache.clear(); for (const task of tasks) { activitySync(task); } -} +}); const ActivityTaskOptionsSchema = z.object({ userID: z.string(), diff --git a/src/lib/util/cachedUserIDs.ts b/src/lib/util/cachedUserIDs.ts index ee7e92a5b9..fa757b1e33 100644 --- a/src/lib/util/cachedUserIDs.ts +++ b/src/lib/util/cachedUserIDs.ts @@ -11,20 +11,20 @@ CACHED_ACTIVE_USER_IDS.add(globalConfig.clientID); for (const id of OWNER_IDS) CACHED_ACTIVE_USER_IDS.add(id); export const syncActiveUserIDs = logWrapFn('Sync Active User IDs', async () => { - const users = await prisma.$queryRawUnsafe< - { user_id: string }[] - >(`SELECT DISTINCT(${Prisma.ActivityScalarFieldEnum.user_id}::text) + const users = await prisma.$queryRawUnsafe< + { user_id: string }[] + >(`SELECT DISTINCT(${Prisma.ActivityScalarFieldEnum.user_id}::text) FROM activity WHERE finish_date > now() - INTERVAL '48 hours'`); - const perkTierUsers = await roboChimpClient.$queryRawUnsafe<{ id: string }[]>(`SELECT id::text + const perkTierUsers = await roboChimpClient.$queryRawUnsafe<{ id: string }[]>(`SELECT id::text FROM "user" WHERE perk_tier > 0;`); - for (const id of [...users.map(i => i.user_id), ...perkTierUsers.map(i => i.id)]) { - CACHED_ACTIVE_USER_IDS.add(id); - } - debugLog(`${CACHED_ACTIVE_USER_IDS.size} cached active user IDs`); + for (const id of [...users.map(i => i.user_id), ...perkTierUsers.map(i => i.id)]) { + CACHED_ACTIVE_USER_IDS.add(id); + } + debugLog(`${CACHED_ACTIVE_USER_IDS.size} cached active user IDs`); }); export function memoryAnalysis() { diff --git a/src/mahoji/lib/events.ts b/src/mahoji/lib/events.ts index 2b22286933..05fd050229 100644 --- a/src/mahoji/lib/events.ts +++ b/src/mahoji/lib/events.ts @@ -4,10 +4,10 @@ import { bulkUpdateCommands } from '@oldschoolgg/toolkit'; import { Channel, META_CONSTANTS, globalConfig } from '../../lib/constants'; import { initCrons } from '../../lib/crons'; import { initTickers } from '../../lib/tickers'; +import { logWrapFn } from '../../lib/util'; import { mahojiClientSettingsFetch } from '../../lib/util/clientSettings'; import { sendToChannelID } from '../../lib/util/webhook'; import { CUSTOM_PRICE_CACHE } from '../commands/sell'; -import { logWrapFn } from '../../lib/util'; export async function syncCustomPrices() { const clientData = await mahojiClientSettingsFetch({ custom_prices: true }); @@ -16,27 +16,27 @@ export async function syncCustomPrices() { } } -export const onStartup = logWrapFn("onStartup", async () => { - globalClient.application.commands.fetch({ - guildId: globalConfig.isProduction ? undefined : globalConfig.testingServerID +export const onStartup = logWrapFn('onStartup', async () => { + globalClient.application.commands.fetch({ + guildId: globalConfig.isProduction ? undefined : globalConfig.testingServerID + }); + if (!globalConfig.isProduction) { + console.log('Syncing commands locally...'); + await bulkUpdateCommands({ + client: globalClient.mahojiClient, + commands: Array.from(globalClient.mahojiClient.commands.values()), + guildID: globalConfig.testingServerID }); - if (!globalConfig.isProduction) { - console.log('Syncing commands locally...'); - await bulkUpdateCommands({ - client: globalClient.mahojiClient, - commands: Array.from(globalClient.mahojiClient.commands.values()), - guildID: globalConfig.testingServerID - }); - } + } - initCrons(); - initTickers(); + initCrons(); + initTickers(); - if (globalConfig.isProduction) { - sendToChannelID(Channel.GeneralChannel, { - content: `I have just turned on! + if (globalConfig.isProduction) { + sendToChannelID(Channel.GeneralChannel, { + content: `I have just turned on! ${META_CONSTANTS.RENDERED_STR}` - }).catch(console.error); - } - }); + }).catch(console.error); + } +}); From ae37b531db3a7bc65f347b618dbe50ea8237c46d Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Fri, 19 Jul 2024 21:22:52 +1000 Subject: [PATCH 098/145] Move fetchUserStats to its own function --- src/lib/MUser.ts | 16 ++-------------- src/mahoji/mahojiSettings.ts | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+), 14 deletions(-) diff --git a/src/lib/MUser.ts b/src/lib/MUser.ts index 7a725883da..3c4bd10f0e 100644 --- a/src/lib/MUser.ts +++ b/src/lib/MUser.ts @@ -9,7 +9,7 @@ import { EquipmentSlot } from 'oldschooljs/dist/meta/types'; import { resolveItems } from 'oldschooljs/dist/util/util'; import { timePerAlch } from '../mahoji/lib/abstracted_commands/alchCommand'; -import { userStatsUpdate } from '../mahoji/mahojiSettings'; +import { fetchUserStats, userStatsUpdate } from '../mahoji/mahojiSettings'; import { addXP } from './addXP'; import { userIsBusy } from './busyCounterCache'; import { ClueTiers } from './clues/clueTiers'; @@ -704,19 +704,7 @@ Charge your items using ${mentionCommand(globalClient, 'minion', 'charge')}.` } async fetchStats(selectKeys: T): Promise> { - const keysToSelect = Object.keys(selectKeys).length === 0 ? { user_id: true } : selectKeys; - const result = await prisma.userStats.upsert({ - where: { - user_id: BigInt(this.id) - }, - create: { - user_id: BigInt(this.id) - }, - update: {}, - select: keysToSelect - }); - - return result as unknown as SelectedUserStats; + return fetchUserStats(this.id, selectKeys); } get logName() { diff --git a/src/mahoji/mahojiSettings.ts b/src/mahoji/mahojiSettings.ts index 75d08466b9..d168ec6472 100644 --- a/src/mahoji/mahojiSettings.ts +++ b/src/mahoji/mahojiSettings.ts @@ -70,6 +70,25 @@ export function getMahojiBank(user: { bank: Prisma.JsonValue }) { return new Bank(user.bank as ItemBank); } +export async function fetchUserStats( + userID: string, + selectKeys: T +): Promise> { + const keysToSelect = Object.keys(selectKeys).length === 0 ? { user_id: true } : selectKeys; + const result = await prisma.userStats.upsert({ + where: { + user_id: BigInt(userID) + }, + create: { + user_id: BigInt(userID) + }, + update: {}, + select: keysToSelect + }); + + return result as unknown as SelectedUserStats; +} + export async function userStatsUpdate( userID: string, data: Omit, From dc5da885f29ecdaea03fb62930b5bc4287e119e7 Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Fri, 19 Jul 2024 21:28:50 +1000 Subject: [PATCH 099/145] Fixes --- src/index.ts | 2 +- src/mahoji/mahojiSettings.ts | 10 +++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/index.ts b/src/index.ts index 9fbbf9ae2b..a7e6507ec1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -206,7 +206,7 @@ client.once('ready', () => onStartup()); async function main() { if (process.env.TEST) return; await Promise.all([ - runTimedLoggedFn('Sync Active User IDs', syncActiveUserIDs), + syncActiveUserIDs(), runTimedLoggedFn('Sync Activity Cache', syncActivityCache), runTimedLoggedFn('Startup Scripts', runStartupScripts), runTimedLoggedFn('Sync Disabled Commands', syncDisabledCommands), diff --git a/src/mahoji/mahojiSettings.ts b/src/mahoji/mahojiSettings.ts index d168ec6472..6eb0c25293 100644 --- a/src/mahoji/mahojiSettings.ts +++ b/src/mahoji/mahojiSettings.ts @@ -119,15 +119,19 @@ export async function userStatsUpdate; } -export async function userStatsBankUpdate(user: MUser, key: JsonKeys, bank: Bank) { +export async function userStatsBankUpdate(user: MUser | string, key: JsonKeys, bank: Bank) { if (!key) throw new Error('No key provided to userStatsBankUpdate'); - const stats = await user.fetchStats({ [key]: true }); + const userID = typeof user === 'string' ? user : user.id; + const stats = + typeof user === 'string' + ? await fetchUserStats(userID, { [key]: true }) + : await user.fetchStats({ [key]: true }); const currentItemBank = stats[key] as ItemBank; if (!isObject(currentItemBank)) { throw new Error(`Key ${key} is not an object.`); } await userStatsUpdate( - user.id, + userID, { [key]: bank.clone().add(currentItemBank).bank }, From 9d59544a86e2baa2d250e57f9fbf133507ed1eb1 Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Fri, 19 Jul 2024 21:42:08 +1000 Subject: [PATCH 100/145] Stopwatch trip effects that take >200ms --- src/lib/util/handleTripFinish.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/lib/util/handleTripFinish.ts b/src/lib/util/handleTripFinish.ts index 91f371e5c7..39d9b937ff 100644 --- a/src/lib/util/handleTripFinish.ts +++ b/src/lib/util/handleTripFinish.ts @@ -2,7 +2,7 @@ import type { activity_type_enum } from '@prisma/client'; import type { AttachmentBuilder, ButtonBuilder, MessageCollector, MessageCreateOptions } from 'discord.js'; import type { Bank } from 'oldschooljs'; -import { channelIsSendable, makeComponents } from '@oldschoolgg/toolkit'; +import { Stopwatch, channelIsSendable, makeComponents } from '@oldschoolgg/toolkit'; import { calculateBirdhouseDetails } from '../../mahoji/lib/abstracted_commands/birdhousesCommand'; import { canRunAutoContract } from '../../mahoji/lib/abstracted_commands/farmingContractCommand'; import { handleTriggerShootingStar } from '../../mahoji/lib/abstracted_commands/shootingStarsCommand'; @@ -111,7 +111,15 @@ export async function handleTripFinish( } const perkTier = user.perkTier(); const messages: string[] = []; - for (const effect of tripFinishEffects) await effect.fn({ data, user, loot, messages }); + + for (const effect of tripFinishEffects) { + const stopwatch = new Stopwatch().start(); + await effect.fn({ data, user, loot, messages }); + stopwatch.stop(); + if (stopwatch.duration > 200) { + console.log(`Finished ${effect.name} trip effect in ${stopwatch}`); + } + } const clueReceived = loot ? ClueTiers.filter(tier => loot.amount(tier.scrollID) > 0) : []; From 09d0d4c4aa20afb5b5300ba2990bc702f4b87eaa Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Sat, 20 Jul 2024 15:45:36 +1000 Subject: [PATCH 101/145] Update command_name schema type --- prisma/schema.prisma | 10 ++--- src/lib/events.ts | 17 +++++---- src/lib/util/commandUsage.ts | 4 +- src/lib/util/handleTripFinish.ts | 2 +- tests/integration/migrateUser.test.ts | 53 +++++++++++++++------------ 5 files changed, 47 insertions(+), 39 deletions(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 0eb8e34fd9..e25b832224 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -578,12 +578,12 @@ model Minigame { } model CommandUsage { - id Int @id @default(autoincrement()) - date DateTime @default(now()) @db.Timestamp(6) + id Int @id @default(autoincrement()) + date DateTime @default(now()) @db.Timestamp(6) user_id BigInt - command_name String @db.VarChar(32) - is_continue Boolean @default(false) - inhibited Boolean? @default(false) + command_name command_name_enum + is_continue Boolean @default(false) + inhibited Boolean? @default(false) is_mention_command Boolean @default(false) diff --git a/src/lib/events.ts b/src/lib/events.ts index eb54efbf12..8b0ba8c14b 100644 --- a/src/lib/events.ts +++ b/src/lib/events.ts @@ -6,6 +6,7 @@ import { Time, isFunction, roll } from 'e'; import { LRUCache } from 'lru-cache'; import { Items } from 'oldschooljs'; +import { command_name_enum } from '@prisma/client'; import { SupportServer, production } from '../config'; import { untrustedGuildSettingsCache } from '../mahoji/guildSettings'; import { minionStatusCommand } from '../mahoji/lib/abstracted_commands/minionStatusCommand'; @@ -140,7 +141,7 @@ interface MentionCommandOptions { content: string; } interface MentionCommand { - name: string; + name: command_name_enum; aliases: string[]; description: string; run: (options: MentionCommandOptions) => Promise; @@ -148,7 +149,7 @@ interface MentionCommand { const mentionCommands: MentionCommand[] = [ { - name: 'bs', + name: command_name_enum.bs, aliases: ['bs'], description: 'Searches your bank.', run: async ({ msg, user, components, content }: MentionCommandOptions) => { @@ -167,7 +168,7 @@ const mentionCommands: MentionCommand[] = [ } }, { - name: 'bal', + name: command_name_enum.bal, aliases: ['bal', 'gp'], description: 'Shows how much GP you have.', run: async ({ msg, user, components }: MentionCommandOptions) => { @@ -178,7 +179,7 @@ const mentionCommands: MentionCommand[] = [ } }, { - name: 'is', + name: command_name_enum.is, aliases: ['is'], description: 'Searches for items.', run: async ({ msg, components, user, content }: MentionCommandOptions) => { @@ -220,7 +221,7 @@ const mentionCommands: MentionCommand[] = [ } }, { - name: 'bank', + name: command_name_enum.bank, aliases: ['b', 'bank'], description: 'Shows your bank.', run: async ({ msg, user, components }: MentionCommandOptions) => { @@ -242,7 +243,7 @@ const mentionCommands: MentionCommand[] = [ } }, { - name: 'cd', + name: command_name_enum.cd, aliases: ['cd'], description: 'Shows your cooldowns.', run: async ({ msg, user, components }: MentionCommandOptions) => { @@ -272,7 +273,7 @@ const mentionCommands: MentionCommand[] = [ } }, { - name: 'sendtoabutton', + name: command_name_enum.sendtoabutton, aliases: ['sendtoabutton'], description: 'Shows your stats.', run: async ({ msg, user }: MentionCommandOptions) => { @@ -297,7 +298,7 @@ const mentionCommands: MentionCommand[] = [ } }, { - name: 's', + name: command_name_enum.stats, aliases: ['s', 'stats'], description: 'Shows your stats.', run: async ({ msg, user, components }: MentionCommandOptions) => { diff --git a/src/lib/util/commandUsage.ts b/src/lib/util/commandUsage.ts index 57036cf431..153064fcdb 100644 --- a/src/lib/util/commandUsage.ts +++ b/src/lib/util/commandUsage.ts @@ -1,5 +1,5 @@ import type { CommandOptions } from '@oldschoolgg/toolkit'; -import type { Prisma } from '@prisma/client'; +import type { Prisma, command_name_enum } from '@prisma/client'; import { getCommandArgs } from '../../mahoji/lib/util'; @@ -24,7 +24,7 @@ export function makeCommandUsage({ }): Prisma.CommandUsageCreateInput { return { user_id: BigInt(userID), - command_name: commandName, + command_name: commandName as command_name_enum, args: getCommandArgs(commandName, args), channel_id: BigInt(channelID), guild_id: guildID ? BigInt(guildID) : null, diff --git a/src/lib/util/handleTripFinish.ts b/src/lib/util/handleTripFinish.ts index 39d9b937ff..0fc1cd8f9f 100644 --- a/src/lib/util/handleTripFinish.ts +++ b/src/lib/util/handleTripFinish.ts @@ -116,7 +116,7 @@ export async function handleTripFinish( const stopwatch = new Stopwatch().start(); await effect.fn({ data, user, loot, messages }); stopwatch.stop(); - if (stopwatch.duration > 200) { + if (stopwatch.duration > 500) { console.log(`Finished ${effect.name} trip effect in ${stopwatch}`); } } diff --git a/tests/integration/migrateUser.test.ts b/tests/integration/migrateUser.test.ts index 350f0e8c98..62abb33ac8 100644 --- a/tests/integration/migrateUser.test.ts +++ b/tests/integration/migrateUser.test.ts @@ -1,25 +1,26 @@ -import type { - Activity, - Bingo, - BingoParticipant, - BuyCommandTransaction, - CommandUsage, - EconomyTransaction, - FarmedCrop, - GearPreset, - Giveaway, - HistoricalData, - LastManStandingGame, - LootTrack, - Minigame, - PinnedTrip, - PlayerOwnedHouse, - Prisma, - ReclaimableItem, - SlayerTask, - UserStats, - XPGain, - activity_type_enum +import { + type Activity, + type Bingo, + type BingoParticipant, + type BuyCommandTransaction, + type CommandUsage, + type EconomyTransaction, + type FarmedCrop, + type GearPreset, + type Giveaway, + type HistoricalData, + type LastManStandingGame, + type LootTrack, + type Minigame, + type PinnedTrip, + type PlayerOwnedHouse, + type Prisma, + type ReclaimableItem, + type SlayerTask, + type UserStats, + type XPGain, + type activity_type_enum, + command_name_enum } from '@prisma/client'; import { Time, deepClone, randArrItem, randInt, shuffleArr, sumArr } from 'e'; import { Bank } from 'oldschooljs'; @@ -1050,7 +1051,13 @@ const allTableCommands: TestCommand[] = [ { name: 'Command usage', cmd: async user => { - const randCommands = ['minion', 'runecraft', 'chop', 'mine', 'buy']; + const randCommands = [ + command_name_enum.minion, + command_name_enum.runecraft, + command_name_enum.chop, + command_name_enum.mine, + command_name_enum.buy + ]; await global.prisma!.commandUsage.create({ data: { user_id: BigInt(user.id), From 50bbdeb8ae9c210969dda935cee133341c568c01 Mon Sep 17 00:00:00 2001 From: GC <30398469+gc@users.noreply.github.com> Date: Sat, 20 Jul 2024 17:02:14 +1000 Subject: [PATCH 102/145] Various things (#5963) --- .yarnrc.yml | 1 + package.json | 12 +- src/index.ts | 47 +- src/lib/badges.ts | 2 +- .../combat_achievements/combatAchievements.ts | 6 +- src/lib/constants.ts | 12 +- src/lib/preStartup.ts | 26 + src/lib/randomEvents.ts | 12 +- src/lib/util.ts | 14 +- src/lib/util/globalInteractions.ts | 2 +- src/lib/util/handleTripFinish.ts | 38 +- src/lib/util/logError.ts | 3 +- src/mahoji/lib/preCommand.ts | 25 +- tests/integration/preStartup.test.ts | 9 + tests/integration/tripEffects.test.ts | 30 + yarn.lock | 737 ++++++------------ 16 files changed, 406 insertions(+), 570 deletions(-) create mode 100644 src/lib/preStartup.ts create mode 100644 tests/integration/preStartup.test.ts create mode 100644 tests/integration/tripEffects.test.ts diff --git a/.yarnrc.yml b/.yarnrc.yml index 55d6c720ef..9e33970694 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -2,3 +2,4 @@ nodeLinker: node-modules telemetryInterval: 999999999999 enableTelemetry: false checksumBehavior: "update" +enableHardenedMode: false diff --git a/package.json b/package.json index 7f8bf63a30..2cd90d2506 100644 --- a/package.json +++ b/package.json @@ -10,11 +10,11 @@ "build:tsc": "tsc -p src", "watch:tsc": "tsc -w -p src", "wipedist": "node -e \"try { require('fs').rmSync('dist', { recursive: true }) } catch(_){}\"", - "cleanup": "yarn && yarn wipedist && yarn lint && yarn build && yarn test && npm i -g dpdm && dpdm --exit-code circular:1 --progress=false --warning=false --tree=false ./dist/index.js", + "dev": "concurrently --raw --kill-others-on-fail \"yarn\" \"yarn wipedist\" \"yarn lint\" && yarn build && yarn test", "test": "concurrently --raw --kill-others-on-fail \"tsc -p src && yarn test:circular\" \"yarn test:lint\" \"yarn test:unit\" \"tsc -p tests/integration --noEmit\" \"tsc -p tests/unit --noEmit\"", "test:lint": "biome check --diagnostic-level=error", "test:unit": "vitest run --coverage --config vitest.unit.config.mts", - "test:docker": "docker-compose up --build --abort-on-container-exit", + "test:docker": "docker-compose up --build --abort-on-container-exit --remove-orphans && docker-compose down --volumes --remove-orphans", "test:watch": "vitest --config vitest.unit.config.mts --coverage", "buildandrun": "yarn build:esbuild && node --enable-source-maps dist", "build:esbuild": "concurrently --raw \"yarn build:main\" \"yarn build:workers\"", @@ -26,7 +26,7 @@ "dependencies": { "@napi-rs/canvas": "^0.1.53", "@oldschoolgg/toolkit": "git+https://github.com/oldschoolgg/toolkit.git#3550582efbdf04929f0a2c5114ed069a61551807", - "@prisma/client": "^5.16.1", + "@prisma/client": "^5.17.0", "@sapphire/ratelimits": "^2.4.9", "@sapphire/snowflake": "^3.5.3", "@sapphire/time-utilities": "^1.6.0", @@ -57,17 +57,17 @@ "@types/node": "^20.14.9", "@types/node-cron": "^3.0.7", "@types/node-fetch": "^2.6.1", - "@vitest/coverage-v8": "^1.6.0", + "@vitest/coverage-v8": "^2.0.3", "concurrently": "^8.2.2", "dpdm": "^3.14.0", "esbuild": "0.21.5", "fast-glob": "^3.3.2", "nodemon": "^3.1.4", "prettier": "^3.3.2", - "prisma": "^5.16.1", + "prisma": "^5.17.0", "tsx": "^4.16.2", "typescript": "^5.5.3", - "vitest": "^1.6.0" + "vitest": "^2.0.3" }, "engines": { "node": "20.15.0" diff --git a/src/index.ts b/src/index.ts index a7e6507ec1..a2d90c39d4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,35 +8,27 @@ import { MahojiClient } from '@oldschoolgg/toolkit'; import { init } from '@sentry/node'; import type { TextChannel } from 'discord.js'; import { GatewayIntentBits, Options, Partials } from 'discord.js'; -import { Time, isObject } from 'e'; +import { isObject } from 'e'; import { SENTRY_DSN, SupportServer } from './config'; -import { syncActivityCache } from './lib/Task'; -import { cacheBadges } from './lib/badges'; -import { BLACKLISTED_GUILDS, BLACKLISTED_USERS, syncBlacklists } from './lib/blacklists'; -import { Channel, Events, META_CONSTANTS, gitHash, globalConfig } from './lib/constants'; +import { BLACKLISTED_GUILDS, BLACKLISTED_USERS } from './lib/blacklists'; +import { Channel, Events, gitHash, globalConfig } from './lib/constants'; import { economyLog } from './lib/economyLogs'; import { onMessage } from './lib/events'; -import { GrandExchange } from './lib/grandExchange'; -import { cacheGEPrices } from './lib/marketPrices'; import { modalInteractionHook } from './lib/modals'; -import { populateRoboChimpCache } from './lib/perkTier'; -import { runStartupScripts } from './lib/startupScripts'; +import { preStartup } from './lib/preStartup'; import { OldSchoolBotClient } from './lib/structures/OldSchoolBotClient'; -import { assert, runTimedLoggedFn } from './lib/util'; -import { CACHED_ACTIVE_USER_IDS, syncActiveUserIDs } from './lib/util/cachedUserIDs'; +import { runTimedLoggedFn } from './lib/util'; +import { CACHED_ACTIVE_USER_IDS } from './lib/util/cachedUserIDs'; import { interactionHook } from './lib/util/globalInteractions'; import { handleInteractionError, interactionReply } from './lib/util/interactionReply'; import { logError } from './lib/util/logError'; -import { syncDisabledCommands } from './lib/util/syncDisabledCommands'; import { allCommands } from './mahoji/commands/allCommands'; -import { onStartup, syncCustomPrices } from './mahoji/lib/events'; +import { onStartup } from './mahoji/lib/events'; import { postCommand } from './mahoji/lib/postCommand'; import { preCommand } from './mahoji/lib/preCommand'; import { convertMahojiCommandToAbstractCommand } from './mahoji/lib/util'; -debugLog(`Starting... Git Hash ${META_CONSTANTS.GIT_HASH}`); - if (SENTRY_DSN) { init({ dsn: SENTRY_DSN, @@ -47,8 +39,6 @@ if (SENTRY_DSN) { }); } -assert(process.env.TZ === 'UTC'); - const client = new OldSchoolBotClient({ shards: 'auto', intents: [ @@ -142,9 +132,6 @@ client.on('messageCreate', msg => { }); client.on('error', console.error); client.on('interactionCreate', async interaction => { - if (BLACKLISTED_USERS.has(interaction.user.id)) return; - if (interaction.guildId && BLACKLISTED_GUILDS.has(interaction.guildId)) return; - if (globalClient.isShuttingDown) { if (interaction.isRepliable()) { await interactionReply(interaction, { @@ -156,10 +143,13 @@ client.on('interactionCreate', async interaction => { return; } - if (!client.isReady() || !client.uptime || client.uptime < Time.Second * 10) { + if ( + BLACKLISTED_USERS.has(interaction.user.id) || + (interaction.guildId && BLACKLISTED_GUILDS.has(interaction.guildId)) + ) { if (interaction.isRepliable()) { await interactionReply(interaction, { - content: 'Old School Bot is currently rebooting, please try again in a few seconds! Thank you <3', + content: 'You are blacklisted.', ephemeral: true }); } @@ -205,18 +195,7 @@ client.once('ready', () => onStartup()); async function main() { if (process.env.TEST) return; - await Promise.all([ - syncActiveUserIDs(), - runTimedLoggedFn('Sync Activity Cache', syncActivityCache), - runTimedLoggedFn('Startup Scripts', runStartupScripts), - runTimedLoggedFn('Sync Disabled Commands', syncDisabledCommands), - runTimedLoggedFn('Sync Blacklist', syncBlacklists), - runTimedLoggedFn('Syncing prices', syncCustomPrices), - runTimedLoggedFn('Caching badges', cacheBadges), - runTimedLoggedFn('Init Grand Exchange', () => GrandExchange.init()), - runTimedLoggedFn('populateRoboChimpCache', populateRoboChimpCache), - runTimedLoggedFn('Cache G.E Prices', cacheGEPrices) - ]); + await preStartup(); await runTimedLoggedFn('Log In', () => client.login(globalConfig.botToken)); console.log(`Logged in as ${globalClient.user.username}`); } diff --git a/src/lib/badges.ts b/src/lib/badges.ts index 7a08a72786..bcf839069a 100644 --- a/src/lib/badges.ts +++ b/src/lib/badges.ts @@ -24,6 +24,6 @@ export async function cacheBadges() { newCache.set(user.RSN.toLowerCase(), userBadges.join(' ')); } - globalClient._badgeCache.clear(); + globalClient._badgeCache?.clear(); globalClient._badgeCache = newCache; } diff --git a/src/lib/combat_achievements/combatAchievements.ts b/src/lib/combat_achievements/combatAchievements.ts index cb34e45c40..5545b07c0d 100644 --- a/src/lib/combat_achievements/combatAchievements.ts +++ b/src/lib/combat_achievements/combatAchievements.ts @@ -159,7 +159,7 @@ assert(allCATaskIDs.length === new Set(allCATaskIDs).size); assert(sumArr(Object.values(CombatAchievements).map(i => i.length)) === allCATaskIDs.length); const indexesWithRng = entries.flatMap(i => i[1].tasks.filter(t => 'rng' in t)); -export const combatAchievementTripEffect: TripFinishEffect['fn'] = async ({ data, messages }) => { +export const combatAchievementTripEffect = async ({ data, messages, user }: Parameters[0]) => { const dataCopy = deepClone(data); if (dataCopy.type === 'Inferno' && !dataCopy.diedPreZuk && !dataCopy.diedZuk) { (dataCopy as any).quantity = 1; @@ -183,8 +183,8 @@ export const combatAchievementTripEffect: TripFinishEffect['fn'] = async ({ data } } - const users = await Promise.all( - ('users' in data ? (data.users as string[]) : [data.userID]).map(id => mUserFetch(id)) + const users: MUser[] = await Promise.all( + 'users' in data ? (data.users as string[]).map(id => mUserFetch(id)) : [user] ); for (const user of users) { diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 235f257d6f..df22a874b6 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -534,7 +534,8 @@ const globalConfigSchema = z.object({ botToken: z.string().min(1), isCI: z.coerce.boolean().default(false), isProduction: z.coerce.boolean().default(production), - testingServerID: z.string() + testingServerID: z.string(), + timeZone: z.literal('UTC') }); dotenv.config({ path: path.resolve(process.cwd(), process.env.TEST ? '.env.test' : '.env') }); @@ -554,7 +555,8 @@ export const globalConfig = globalConfigSchema.parse({ botToken: process.env.BOT_TOKEN, isCI: process.env.CI, isProduction, - testingServerID: process.env.TESTING_SERVER_ID ?? OLDSCHOOLGG_TESTING_SERVER_ID + testingServerID: process.env.TESTING_SERVER_ID ?? OLDSCHOOLGG_TESTING_SERVER_ID, + timeZone: process.env.TZ }); if ((process.env.NODE_ENV === 'production') !== globalConfig.isProduction || production !== globalConfig.isProduction) { @@ -624,3 +626,9 @@ export const winterTodtPointsTable = new SimpleTable() .add(750) .add(780) .add(850); + +if (!process.env.TEST) { + console.log( + `Starting... Git[${gitHash}] ClientID[${globalConfig.clientID}] Production[${globalConfig.isProduction}]` + ); +} diff --git a/src/lib/preStartup.ts b/src/lib/preStartup.ts new file mode 100644 index 0000000000..79b604bafb --- /dev/null +++ b/src/lib/preStartup.ts @@ -0,0 +1,26 @@ +import { syncCustomPrices } from '../mahoji/lib/events'; +import { syncActivityCache } from './Task'; +import { cacheBadges } from './badges'; +import { syncBlacklists } from './blacklists'; +import { GrandExchange } from './grandExchange'; +import { cacheGEPrices } from './marketPrices'; +import { populateRoboChimpCache } from './perkTier'; +import { runStartupScripts } from './startupScripts'; +import { runTimedLoggedFn } from './util'; +import { syncActiveUserIDs } from './util/cachedUserIDs'; +import { syncDisabledCommands } from './util/syncDisabledCommands'; + +export async function preStartup() { + await Promise.all([ + syncActiveUserIDs(), + runTimedLoggedFn('Sync Activity Cache', syncActivityCache), + runTimedLoggedFn('Startup Scripts', runStartupScripts), + runTimedLoggedFn('Sync Disabled Commands', syncDisabledCommands), + runTimedLoggedFn('Sync Blacklist', syncBlacklists), + runTimedLoggedFn('Syncing prices', syncCustomPrices), + runTimedLoggedFn('Caching badges', cacheBadges), + runTimedLoggedFn('Init Grand Exchange', () => GrandExchange.init()), + runTimedLoggedFn('populateRoboChimpCache', populateRoboChimpCache), + runTimedLoggedFn('Cache G.E Prices', cacheGEPrices) + ]); +} diff --git a/src/lib/randomEvents.ts b/src/lib/randomEvents.ts index a600bbc11e..5370f5442e 100644 --- a/src/lib/randomEvents.ts +++ b/src/lib/randomEvents.ts @@ -187,19 +187,19 @@ const cache = new LRUCache({ max: 500 }); const doesntGetRandomEvent: activity_type_enum[] = [activity_type_enum.TombsOfAmascut]; export async function triggerRandomEvent(user: MUser, type: activity_type_enum, duration: number, messages: string[]) { - if (doesntGetRandomEvent.includes(type)) return; + if (doesntGetRandomEvent.includes(type)) return {}; const minutes = Math.min(30, duration / Time.Minute); const randomEventChance = 60 - minutes; - if (!roll(randomEventChance)) return; + if (!roll(randomEventChance)) return {}; if (user.bitfield.includes(BitField.DisabledRandomEvents)) { - return; + return {}; } const prev = cache.get(user.id); // Max 1 event per 3h mins per user if (prev && Date.now() - prev < Time.Hour * 3) { - return; + return {}; } cache.set(user.id, Date.now()); @@ -214,7 +214,9 @@ export async function triggerRandomEvent(user: MUser, type: activity_type_enum, } } loot.add(event.loot.roll()); - await transactItems({ userID: user.id, itemsToAdd: loot, collectionLog: true }); await userStatsBankUpdate(user, 'random_event_completions_bank', new Bank().add(event.id)); messages.push(`Did ${event.name} random event and got ${loot}`); + return { + itemsToAddWithCL: loot + }; } diff --git a/src/lib/util.ts b/src/lib/util.ts index 668daa25c9..e118fcc0c2 100644 --- a/src/lib/util.ts +++ b/src/lib/util.ts @@ -384,19 +384,23 @@ export function awaitMessageComponentInteraction({ }); } -export async function runTimedLoggedFn(name: string, fn: () => Promise) { +export async function runTimedLoggedFn(name: string, fn: () => Promise, threshholdToLog = 100): Promise { const logger = globalConfig.isProduction ? debugLog : console.log; const stopwatch = new Stopwatch(); stopwatch.start(); - await fn(); + const result = await fn(); stopwatch.stop(); - if (!globalConfig.isProduction || stopwatch.duration > 50) { + if (!globalConfig.isProduction || stopwatch.duration > threshholdToLog) { logger(`Took ${stopwatch} to do ${name}`); } + return result; } -export function logWrapFn(name: string, fn: () => Promise) { - return () => runTimedLoggedFn(name, fn); +export function logWrapFn Promise>( + name: string, + fn: T +): (...args: Parameters) => ReturnType { + return (...args: Parameters): ReturnType => runTimedLoggedFn(name, () => fn(...args)) as ReturnType; } export function isModOrAdmin(user: MUser) { diff --git a/src/lib/util/globalInteractions.ts b/src/lib/util/globalInteractions.ts index 17f3f47f9b..86977500d6 100644 --- a/src/lib/util/globalInteractions.ts +++ b/src/lib/util/globalInteractions.ts @@ -378,7 +378,7 @@ export async function interactionHook(interaction: Interaction) { const timeSinceMessage = Date.now() - new Date(interaction.message.createdTimestamp).getTime(); const timeLimit = reactionTimeLimit(user.perkTier()); if (timeSinceMessage > Time.Day) { - console.log( + debugLog( `${user.id} clicked Diff[${formatDuration(timeSinceMessage)}] Button[${id}] Message[${ interaction.message.id }]` diff --git a/src/lib/util/handleTripFinish.ts b/src/lib/util/handleTripFinish.ts index 0fc1cd8f9f..fcea8224f4 100644 --- a/src/lib/util/handleTripFinish.ts +++ b/src/lib/util/handleTripFinish.ts @@ -1,8 +1,8 @@ +import { Stopwatch, channelIsSendable, makeComponents } from '@oldschoolgg/toolkit'; import type { activity_type_enum } from '@prisma/client'; import type { AttachmentBuilder, ButtonBuilder, MessageCollector, MessageCreateOptions } from 'discord.js'; -import type { Bank } from 'oldschooljs'; +import { Bank } from 'oldschooljs'; -import { Stopwatch, channelIsSendable, makeComponents } from '@oldschoolgg/toolkit'; import { calculateBirdhouseDetails } from '../../mahoji/lib/abstracted_commands/birdhousesCommand'; import { canRunAutoContract } from '../../mahoji/lib/abstracted_commands/farmingContractCommand'; import { handleTriggerShootingStar } from '../../mahoji/lib/abstracted_commands/shootingStarsCommand'; @@ -42,21 +42,27 @@ interface TripFinishEffectOptions { loot: Bank | null; messages: string[]; } + +type TripEffectReturn = { + itemsToAddWithCL?: Bank; +}; + export interface TripFinishEffect { name: string; - fn: (options: TripFinishEffectOptions) => unknown; + fn: (options: TripFinishEffectOptions) => Promise | Promise | Promise; } const tripFinishEffects: TripFinishEffect[] = [ { name: 'Track GP Analytics', - fn: ({ data, loot }) => { + fn: async ({ data, loot }) => { if (loot && activitiesToTrackAsPVMGPSource.includes(data.type)) { const GP = loot.amount(COINS_ID); if (typeof GP === 'number') { - updateClientGPTrackSetting('gp_pvm', GP); + await updateClientGPTrackSetting('gp_pvm', GP); } } + return {}; } }, { @@ -66,9 +72,12 @@ const tripFinishEffects: TripFinishEffect[] = [ if (imp && imp.bank.length > 0) { const many = imp.bank.length > 1; messages.push(`Caught ${many ? 'some' : 'an'} impling${many ? 's' : ''}, you received: ${imp.bank}`); - userStatsBankUpdate(user, 'passive_implings_bank', imp.bank); - await transactItems({ userID: user.id, itemsToAdd: imp.bank, collectionLog: true }); + await userStatsBankUpdate(user, 'passive_implings_bank', imp.bank); + return { + itemsToAddWithCL: imp.bank + }; } + return {}; } }, { @@ -80,12 +89,14 @@ const tripFinishEffects: TripFinishEffect[] = [ { name: 'Random Events', fn: async ({ data, messages, user }) => { - await triggerRandomEvent(user, data.type, data.duration, messages); + return triggerRandomEvent(user, data.type, data.duration, messages); } }, { name: 'Combat Achievements', - fn: combatAchievementTripEffect + fn: async options => { + return combatAchievementTripEffect(options); + } } ]; @@ -112,14 +123,19 @@ export async function handleTripFinish( const perkTier = user.perkTier(); const messages: string[] = []; + const itemsToAddWithCL = new Bank(); for (const effect of tripFinishEffects) { const stopwatch = new Stopwatch().start(); - await effect.fn({ data, user, loot, messages }); + const res = await effect.fn({ data, user, loot, messages }); + if (res?.itemsToAddWithCL) itemsToAddWithCL.add(res.itemsToAddWithCL); stopwatch.stop(); if (stopwatch.duration > 500) { - console.log(`Finished ${effect.name} trip effect in ${stopwatch}`); + debugLog(`Finished ${effect.name} trip effect for ${user.id} in ${stopwatch}`); } } + if (itemsToAddWithCL.length > 0) { + await user.addItemsToBank({ items: itemsToAddWithCL, collectionLog: true }); + } const clueReceived = loot ? ClueTiers.filter(tier => loot.amount(tier.scrollID) > 0) : []; diff --git a/src/lib/util/logError.ts b/src/lib/util/logError.ts index e709616166..5917b1c75e 100644 --- a/src/lib/util/logError.ts +++ b/src/lib/util/logError.ts @@ -48,7 +48,8 @@ export function logErrorForInteraction( ...extraContext, interaction_created_at: interaction.createdTimestamp, current_timestamp: Date.now(), - difference_ms: Date.now() - interaction.createdTimestamp + difference_ms: Date.now() - interaction.createdTimestamp, + was_deferred: interaction.isRepliable() ? interaction.deferred : 'N/A' }; if (interaction.isChatInputCommand()) { context.options = JSON.stringify( diff --git a/src/mahoji/lib/preCommand.ts b/src/mahoji/lib/preCommand.ts index c27af0c7b8..1232d995fb 100644 --- a/src/mahoji/lib/preCommand.ts +++ b/src/mahoji/lib/preCommand.ts @@ -4,17 +4,11 @@ import type { InteractionReplyOptions, TextChannel, User } from 'discord.js'; import { modifyBusyCounter, userIsBusy } from '../../lib/busyCounterCache'; import { busyImmuneCommands } from '../../lib/constants'; +import { logWrapFn } from '../../lib/util'; import type { AbstractCommand } from './inhibitors'; import { runInhibitors } from './inhibitors'; -export async function preCommand({ - abstractCommand, - userID, - guildID, - channelID, - bypassInhibitors, - apiUser -}: { +interface PreCommandOptions { apiUser: User | null; abstractCommand: AbstractCommand; userID: string; @@ -22,13 +16,24 @@ export async function preCommand({ channelID: string | bigint; bypassInhibitors: boolean; options: CommandOptions; -}): Promise< +} + +type PrecommandReturn = Promise< | undefined | { reason: InteractionReplyOptions; dontRunPostCommand?: boolean; } -> { +>; +export const preCommand: (opts: PreCommandOptions) => PrecommandReturn = logWrapFn('PreCommand', rawPreCommand); +async function rawPreCommand({ + abstractCommand, + userID, + guildID, + channelID, + bypassInhibitors, + apiUser +}: PreCommandOptions): PrecommandReturn { if (globalClient.isShuttingDown) { return { reason: { content: 'The bot is currently restarting, please try again later.' }, diff --git a/tests/integration/preStartup.test.ts b/tests/integration/preStartup.test.ts new file mode 100644 index 0000000000..d865c68ea9 --- /dev/null +++ b/tests/integration/preStartup.test.ts @@ -0,0 +1,9 @@ +import { test } from 'vitest'; + +import { preStartup } from '../../src/lib/preStartup'; +import { mockClient } from './util'; + +test('PreStartup', async () => { + await mockClient(); + await preStartup(); +}); diff --git a/tests/integration/tripEffects.test.ts b/tests/integration/tripEffects.test.ts new file mode 100644 index 0000000000..ddf5c2c878 --- /dev/null +++ b/tests/integration/tripEffects.test.ts @@ -0,0 +1,30 @@ +import { activity_type_enum } from '@prisma/client'; +import { Time } from 'e'; +import { Monsters } from 'oldschooljs'; +import { expect, test } from 'vitest'; + +import { minionKCommand } from '../../src/mahoji/commands/k'; +import { createTestUser, mockClient, mockMathRandom } from './util'; + +test('Random Events', async () => { + const unmock = mockMathRandom(0.03); + const client = await mockClient(); + const user = await createTestUser(); + await user.runCommand(minionKCommand, { name: 'man' }); + await prisma.activity.updateMany({ + where: { + user_id: BigInt(user.id), + type: activity_type_enum.MonsterKilling + }, + data: { + duration: Time.Hour + } + }); + await client.processActivities(); + expect(await user.getKC(Monsters.Man.id)).toBeGreaterThan(1); + const userStats = await user.fetchStats({ random_event_completions_bank: true }); + await user.sync(); + expect(userStats.random_event_completions_bank).toEqual({ 1: 1 }); + expect(user.bank.amount("Beekeeper's hat")).toEqual(1); + unmock(); +}); diff --git a/yarn.lock b/yarn.lock index ff1a4da40a..7e6345566a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5,20 +5,13 @@ __metadata: version: 8 cacheKey: 10c0 -"@ampproject/remapping@npm:^2.2.1": - version: 2.2.1 - resolution: "@ampproject/remapping@npm:2.2.1" +"@ampproject/remapping@npm:^2.3.0": + version: 2.3.0 + resolution: "@ampproject/remapping@npm:2.3.0" dependencies: - "@jridgewell/gen-mapping": "npm:^0.3.0" - "@jridgewell/trace-mapping": "npm:^0.3.9" - checksum: 10c0/92ce5915f8901d8c7cd4f4e6e2fe7b9fd335a29955b400caa52e0e5b12ca3796ada7c2f10e78c9c5b0f9c2539dff0ffea7b19850a56e1487aa083531e1e46d43 - languageName: node - linkType: hard - -"@babel/helper-string-parser@npm:^7.23.4": - version: 7.23.4 - resolution: "@babel/helper-string-parser@npm:7.23.4" - checksum: 10c0/f348d5637ad70b6b54b026d6544bd9040f78d24e7ec245a0fc42293968181f6ae9879c22d89744730d246ce8ec53588f716f102addd4df8bbc79b73ea10004ac + "@jridgewell/gen-mapping": "npm:^0.3.5" + "@jridgewell/trace-mapping": "npm:^0.3.24" + checksum: 10c0/81d63cca5443e0f0c72ae18b544cc28c7c0ec2cea46e7cb888bb0e0f411a1191d0d6b7af798d54e30777d8d1488b2ec0732aac2be342d3d7d3ffd271c6f489ed languageName: node linkType: hard @@ -29,10 +22,10 @@ __metadata: languageName: node linkType: hard -"@babel/helper-validator-identifier@npm:^7.22.20": - version: 7.22.20 - resolution: "@babel/helper-validator-identifier@npm:7.22.20" - checksum: 10c0/dcad63db345fb110e032de46c3688384b0008a42a4845180ce7cd62b1a9c0507a1bed727c4d1060ed1a03ae57b4d918570259f81724aaac1a5b776056f37504e +"@babel/helper-string-parser@npm:^7.24.8": + version: 7.24.8 + resolution: "@babel/helper-string-parser@npm:7.24.8" + checksum: 10c0/6361f72076c17fabf305e252bf6d580106429014b3ab3c1f5c4eb3e6d465536ea6b670cc0e9a637a77a9ad40454d3e41361a2909e70e305116a23d68ce094c08 languageName: node linkType: hard @@ -43,12 +36,12 @@ __metadata: languageName: node linkType: hard -"@babel/parser@npm:^7.23.6": - version: 7.23.9 - resolution: "@babel/parser@npm:7.23.9" +"@babel/parser@npm:^7.24.4": + version: 7.24.8 + resolution: "@babel/parser@npm:7.24.8" bin: parser: ./bin/babel-parser.js - checksum: 10c0/7df97386431366d4810538db4b9ec538f4377096f720c0591c7587a16f6810e62747e9fbbfa1ff99257fd4330035e4fb1b5b77c7bd3b97ce0d2e3780a6618975 + checksum: 10c0/ce69671de8fa6f649abf849be262707ac700b573b8b1ce1893c66cc6cd76aeb1294a19e8c290b0eadeb2f47d3f413a2e57a281804ffbe76bfb9fa50194cf3c52 languageName: node linkType: hard @@ -61,14 +54,14 @@ __metadata: languageName: node linkType: hard -"@babel/types@npm:^7.23.6": - version: 7.23.9 - resolution: "@babel/types@npm:7.23.9" +"@babel/types@npm:^7.24.0": + version: 7.24.9 + resolution: "@babel/types@npm:7.24.9" dependencies: - "@babel/helper-string-parser": "npm:^7.23.4" - "@babel/helper-validator-identifier": "npm:^7.22.20" + "@babel/helper-string-parser": "npm:^7.24.8" + "@babel/helper-validator-identifier": "npm:^7.24.7" to-fast-properties: "npm:^2.0.0" - checksum: 10c0/edc7bb180ce7e4d2aea10c6972fb10474341ac39ba8fdc4a27ffb328368dfdfbf40fca18e441bbe7c483774500d5c05e222cec276c242e952853dcaf4eb884f7 + checksum: 10c0/4970b3481cab39c5c3fdb7c28c834df5c7049f3c7f43baeafe121bb05270ebf0da7c65b097abf314877f213baa591109c82204f30d66cdd46c22ece4a2f32415 languageName: node linkType: hard @@ -456,30 +449,14 @@ __metadata: languageName: node linkType: hard -"@jest/schemas@npm:^29.6.3": - version: 29.6.3 - resolution: "@jest/schemas@npm:29.6.3" - dependencies: - "@sinclair/typebox": "npm:^0.27.8" - checksum: 10c0/b329e89cd5f20b9278ae1233df74016ebf7b385e0d14b9f4c1ad18d096c4c19d1e687aa113a9c976b16ec07f021ae53dea811fb8c1248a50ac34fbe009fdf6be - languageName: node - linkType: hard - -"@jridgewell/gen-mapping@npm:^0.3.0": - version: 0.3.3 - resolution: "@jridgewell/gen-mapping@npm:0.3.3" +"@jridgewell/gen-mapping@npm:^0.3.5": + version: 0.3.5 + resolution: "@jridgewell/gen-mapping@npm:0.3.5" dependencies: - "@jridgewell/set-array": "npm:^1.0.1" + "@jridgewell/set-array": "npm:^1.2.1" "@jridgewell/sourcemap-codec": "npm:^1.4.10" - "@jridgewell/trace-mapping": "npm:^0.3.9" - checksum: 10c0/376fc11cf5a967318ba3ddd9d8e91be528eab6af66810a713c49b0c3f8dc67e9949452c51c38ab1b19aa618fb5e8594da5a249977e26b1e7fea1ee5a1fcacc74 - languageName: node - linkType: hard - -"@jridgewell/resolve-uri@npm:3.1.0": - version: 3.1.0 - resolution: "@jridgewell/resolve-uri@npm:3.1.0" - checksum: 10c0/78055e2526108331126366572045355051a930f017d1904a4f753d3f4acee8d92a14854948095626f6163cffc24ea4e3efa30637417bb866b84743dec7ef6fd9 + "@jridgewell/trace-mapping": "npm:^0.3.24" + checksum: 10c0/1be4fd4a6b0f41337c4f5fdf4afc3bd19e39c3691924817108b82ffcb9c9e609c273f936932b9fba4b3a298ce2eb06d9bff4eb1cc3bd81c4f4ee1b4917e25feb languageName: node linkType: hard @@ -490,14 +467,14 @@ __metadata: languageName: node linkType: hard -"@jridgewell/set-array@npm:^1.0.1": - version: 1.1.2 - resolution: "@jridgewell/set-array@npm:1.1.2" - checksum: 10c0/bc7ab4c4c00470de4e7562ecac3c0c84f53e7ee8a711e546d67c47da7febe7c45cd67d4d84ee3c9b2c05ae8e872656cdded8a707a283d30bd54fbc65aef821ab +"@jridgewell/set-array@npm:^1.2.1": + version: 1.2.1 + resolution: "@jridgewell/set-array@npm:1.2.1" + checksum: 10c0/2a5aa7b4b5c3464c895c802d8ae3f3d2b92fcbe84ad12f8d0bfbb1f5ad006717e7577ee1fd2eac00c088abe486c7adb27976f45d2941ff6b0b92b2c3302c60f4 languageName: node linkType: hard -"@jridgewell/sourcemap-codec@npm:1.4.14, @jridgewell/sourcemap-codec@npm:^1.4.10": +"@jridgewell/sourcemap-codec@npm:^1.4.10": version: 1.4.14 resolution: "@jridgewell/sourcemap-codec@npm:1.4.14" checksum: 10c0/3fbaff1387c1338b097eeb6ff92890d7838f7de0dde259e4983763b44540bfd5ca6a1f7644dc8ad003a57f7e80670d5b96a8402f1386ba9aee074743ae9bad51 @@ -511,7 +488,7 @@ __metadata: languageName: node linkType: hard -"@jridgewell/trace-mapping@npm:^0.3.23": +"@jridgewell/trace-mapping@npm:^0.3.23, @jridgewell/trace-mapping@npm:^0.3.24": version: 0.3.25 resolution: "@jridgewell/trace-mapping@npm:0.3.25" dependencies: @@ -521,16 +498,6 @@ __metadata: languageName: node linkType: hard -"@jridgewell/trace-mapping@npm:^0.3.9": - version: 0.3.18 - resolution: "@jridgewell/trace-mapping@npm:0.3.18" - dependencies: - "@jridgewell/resolve-uri": "npm:3.1.0" - "@jridgewell/sourcemap-codec": "npm:1.4.14" - checksum: 10c0/e5045775f076022b6c7cc64a7b55742faa5442301cb3389fd0e6712fafc46a2bb13c68fa1ffaf7b8bb665a91196f050b4115885fc802094ebc06a1cf665935ac - languageName: node - linkType: hard - "@napi-rs/canvas-android-arm64@npm:0.1.53": version: 0.1.53 resolution: "@napi-rs/canvas-android-arm64@npm:0.1.53" @@ -1036,61 +1003,61 @@ __metadata: languageName: node linkType: hard -"@prisma/client@npm:^5.16.1": - version: 5.16.1 - resolution: "@prisma/client@npm:5.16.1" +"@prisma/client@npm:^5.17.0": + version: 5.17.0 + resolution: "@prisma/client@npm:5.17.0" peerDependencies: prisma: "*" peerDependenciesMeta: prisma: optional: true - checksum: 10c0/4d1e140db1e0654564c9864d7b9e9f3dafe0794c7f86d6d7d9e25c51158f855f22d6e4c5e2ff13a15e3171b3bf9c9a8d3306bdd96f26dc8c87b3501e42a9d7ab + checksum: 10c0/cc6c5e9bfbc2f9a01fdf73e009c42298b8a9fea8c9b19db0089cad84a9ee94c3bb6f66f53f1e2f4b32b3506706bf16d23a8e3bcb4619a8bc76d0812a8382ae63 languageName: node linkType: hard -"@prisma/debug@npm:5.16.1": - version: 5.16.1 - resolution: "@prisma/debug@npm:5.16.1" - checksum: 10c0/f2e536b4b3479feee00adcacb5988d37027b771e682cf8f8a29c30b9216706aa39942fe14bf7fd8bb41c71f1b01074e3dca0632e359253def0bc4a86a5cfb3fd +"@prisma/debug@npm:5.17.0": + version: 5.17.0 + resolution: "@prisma/debug@npm:5.17.0" + checksum: 10c0/10aca89c8cd3a96c7f1153792110f33d96d1875e4af807002b9ca061eda255b1aa21e757b9e7a1690ac0676fb2312c441191cdb357acf45617dd658678984053 languageName: node linkType: hard -"@prisma/engines-version@npm:5.16.0-24.34ace0eb2704183d2c05b60b52fba5c43c13f303": - version: 5.16.0-24.34ace0eb2704183d2c05b60b52fba5c43c13f303 - resolution: "@prisma/engines-version@npm:5.16.0-24.34ace0eb2704183d2c05b60b52fba5c43c13f303" - checksum: 10c0/de02aed86429fbae0c98dbcb35baa9e1583a37aa222692a832a24153ed173a5559257c31296ecbd8ae5a8a99887f3aaf0797d819ca45e562905ce0bea411fe75 +"@prisma/engines-version@npm:5.17.0-31.393aa359c9ad4a4bb28630fb5613f9c281cde053": + version: 5.17.0-31.393aa359c9ad4a4bb28630fb5613f9c281cde053 + resolution: "@prisma/engines-version@npm:5.17.0-31.393aa359c9ad4a4bb28630fb5613f9c281cde053" + checksum: 10c0/164b4cd6965da770bcd085fa0596466b092060d19eb8a4ba3402e66bd9b2e813cae417eeca99422b66a3a05a65cfe6d0e0339083b53644acf553ac138693232d languageName: node linkType: hard -"@prisma/engines@npm:5.16.1": - version: 5.16.1 - resolution: "@prisma/engines@npm:5.16.1" +"@prisma/engines@npm:5.17.0": + version: 5.17.0 + resolution: "@prisma/engines@npm:5.17.0" dependencies: - "@prisma/debug": "npm:5.16.1" - "@prisma/engines-version": "npm:5.16.0-24.34ace0eb2704183d2c05b60b52fba5c43c13f303" - "@prisma/fetch-engine": "npm:5.16.1" - "@prisma/get-platform": "npm:5.16.1" - checksum: 10c0/062a97aee17734c937362672d5da730968e396f1e746ae1f146532324345093fdb01d243afba43843d3ca63b22f6beef83f49ec2649c0280597b875ad02bfe52 + "@prisma/debug": "npm:5.17.0" + "@prisma/engines-version": "npm:5.17.0-31.393aa359c9ad4a4bb28630fb5613f9c281cde053" + "@prisma/fetch-engine": "npm:5.17.0" + "@prisma/get-platform": "npm:5.17.0" + checksum: 10c0/b1d48c39fbe16680947685960be615894ccc1a2ca40263fc6d1ac4599e3100f2f31e71b02bd000c0f3269cd045f38817dfbddd37fefcb8a4dec6155a6df48e2f languageName: node linkType: hard -"@prisma/fetch-engine@npm:5.16.1": - version: 5.16.1 - resolution: "@prisma/fetch-engine@npm:5.16.1" +"@prisma/fetch-engine@npm:5.17.0": + version: 5.17.0 + resolution: "@prisma/fetch-engine@npm:5.17.0" dependencies: - "@prisma/debug": "npm:5.16.1" - "@prisma/engines-version": "npm:5.16.0-24.34ace0eb2704183d2c05b60b52fba5c43c13f303" - "@prisma/get-platform": "npm:5.16.1" - checksum: 10c0/d6fd9674948de65d2fbf23f4319d2ec584c97cbe34637109ae9e47b394a1dce33232f6a6739f7ca546c8ffd8217ba46b6a09ba3680a45a881f1cbfda34c9be3c + "@prisma/debug": "npm:5.17.0" + "@prisma/engines-version": "npm:5.17.0-31.393aa359c9ad4a4bb28630fb5613f9c281cde053" + "@prisma/get-platform": "npm:5.17.0" + checksum: 10c0/b5c554e8a637871fd6497e656d67e649d9eea3a06be325b68a686b707c78d200ba9ba20bd76b0a3408e5cb78f6e34bab535ce161174273db377353a01368806e languageName: node linkType: hard -"@prisma/get-platform@npm:5.16.1": - version: 5.16.1 - resolution: "@prisma/get-platform@npm:5.16.1" +"@prisma/get-platform@npm:5.17.0": + version: 5.17.0 + resolution: "@prisma/get-platform@npm:5.17.0" dependencies: - "@prisma/debug": "npm:5.16.1" - checksum: 10c0/3fc626175411fbc379189567c6a3cde39e98237918b82c30804086bf8969ecd9909633e567380c8b04146a231a9692f8947dde7086585e74272b1e5eeb3d016f + "@prisma/debug": "npm:5.17.0" + checksum: 10c0/8687736c6e18737e29544bc1f98653b75b4dcb85c1ffe02686da100e843bb30041dd9d00146a2178517d34b783a650c8b76bdde5029d1675bd28c2be6ee6565a languageName: node linkType: hard @@ -1339,13 +1306,6 @@ __metadata: languageName: node linkType: hard -"@sinclair/typebox@npm:^0.27.8": - version: 0.27.8 - resolution: "@sinclair/typebox@npm:0.27.8" - checksum: 10c0/ef6351ae073c45c2ac89494dbb3e1f87cc60a93ce4cde797b782812b6f97da0d620ae81973f104b43c9b7eaa789ad20ba4f6a1359f1cc62f63729a55a7d22d4e - languageName: node - linkType: hard - "@types/accepts@npm:*": version: 1.3.7 resolution: "@types/accepts@npm:1.3.7" @@ -1642,80 +1602,89 @@ __metadata: languageName: node linkType: hard -"@vitest/coverage-v8@npm:^1.6.0": - version: 1.6.0 - resolution: "@vitest/coverage-v8@npm:1.6.0" +"@vitest/coverage-v8@npm:^2.0.3": + version: 2.0.3 + resolution: "@vitest/coverage-v8@npm:2.0.3" dependencies: - "@ampproject/remapping": "npm:^2.2.1" + "@ampproject/remapping": "npm:^2.3.0" "@bcoe/v8-coverage": "npm:^0.2.3" - debug: "npm:^4.3.4" + debug: "npm:^4.3.5" istanbul-lib-coverage: "npm:^3.2.2" istanbul-lib-report: "npm:^3.0.1" - istanbul-lib-source-maps: "npm:^5.0.4" - istanbul-reports: "npm:^3.1.6" - magic-string: "npm:^0.30.5" - magicast: "npm:^0.3.3" - picocolors: "npm:^1.0.0" - std-env: "npm:^3.5.0" - strip-literal: "npm:^2.0.0" - test-exclude: "npm:^6.0.0" + istanbul-lib-source-maps: "npm:^5.0.6" + istanbul-reports: "npm:^3.1.7" + magic-string: "npm:^0.30.10" + magicast: "npm:^0.3.4" + std-env: "npm:^3.7.0" + strip-literal: "npm:^2.1.0" + test-exclude: "npm:^7.0.1" + tinyrainbow: "npm:^1.2.0" peerDependencies: - vitest: 1.6.0 - checksum: 10c0/a7beaf2a88b628a9dc16ddca7589f2b2e4681598e6788d68423dffbb06c608edc52b2dd421ada069eb3cfd83f8f592ddd6e8b8db2d037bf13965a56c5e5835ac + vitest: 2.0.3 + checksum: 10c0/ac3bbe2ff7cb41a71d22d7174347bba81b789b8293da54a1cf266eef12b9b0f92a04ef3cdaddff176b11fa8db9ec3cbdc423bf51a5e847714a643a38b3da370f languageName: node linkType: hard -"@vitest/expect@npm:1.6.0": - version: 1.6.0 - resolution: "@vitest/expect@npm:1.6.0" +"@vitest/expect@npm:2.0.3": + version: 2.0.3 + resolution: "@vitest/expect@npm:2.0.3" dependencies: - "@vitest/spy": "npm:1.6.0" - "@vitest/utils": "npm:1.6.0" - chai: "npm:^4.3.10" - checksum: 10c0/a4351f912a70543e04960f5694f1f1ac95f71a856a46e87bba27d3eb72a08c5d11d35021cbdc6077452a152e7d93723fc804bba76c2cc53c8896b7789caadae3 + "@vitest/spy": "npm:2.0.3" + "@vitest/utils": "npm:2.0.3" + chai: "npm:^5.1.1" + tinyrainbow: "npm:^1.2.0" + checksum: 10c0/bc8dead850a8aeb84a0d5d8620e1437752cbfe10908c2d5ec9f80fc6d9c387d70c964abfd2d6caf76da2882022c0dd05b0fa09b7c2a44d65abdde2b6c73517fe languageName: node linkType: hard -"@vitest/runner@npm:1.6.0": - version: 1.6.0 - resolution: "@vitest/runner@npm:1.6.0" +"@vitest/pretty-format@npm:2.0.3, @vitest/pretty-format@npm:^2.0.3": + version: 2.0.3 + resolution: "@vitest/pretty-format@npm:2.0.3" dependencies: - "@vitest/utils": "npm:1.6.0" - p-limit: "npm:^5.0.0" - pathe: "npm:^1.1.1" - checksum: 10c0/27d67fa51f40effe0e41ee5f26563c12c0ef9a96161f806036f02ea5eb9980c5cdf305a70673942e7a1e3d472d4d7feb40093ae93024ef1ccc40637fc65b1d2f + tinyrainbow: "npm:^1.2.0" + checksum: 10c0/217fd176fa4d1e64e04bc6a187d146381e99921f46007f98f7132d0e31e2c14b9c6d050a150331b3368ee8004bbeab5b1b7d477522a4e4d71ad822d046debc16 languageName: node linkType: hard -"@vitest/snapshot@npm:1.6.0": - version: 1.6.0 - resolution: "@vitest/snapshot@npm:1.6.0" +"@vitest/runner@npm:2.0.3": + version: 2.0.3 + resolution: "@vitest/runner@npm:2.0.3" dependencies: - magic-string: "npm:^0.30.5" - pathe: "npm:^1.1.1" - pretty-format: "npm:^29.7.0" - checksum: 10c0/be027fd268d524589ff50c5fad7b4faa1ac5742b59ac6c1dc6f5a3930aad553560e6d8775e90ac4dfae4be746fc732a6f134ba95606a1519707ce70db3a772a5 + "@vitest/utils": "npm:2.0.3" + pathe: "npm:^1.1.2" + checksum: 10c0/efbf646457c29268f0d370985d8cbfcfc7d181693dfc2e061dd05ce911f43592957f2c866cde1b5b2e3078ae5d74b94dc28453e1c70b80e8467440223431e863 languageName: node linkType: hard -"@vitest/spy@npm:1.6.0": - version: 1.6.0 - resolution: "@vitest/spy@npm:1.6.0" +"@vitest/snapshot@npm:2.0.3": + version: 2.0.3 + resolution: "@vitest/snapshot@npm:2.0.3" dependencies: - tinyspy: "npm:^2.2.0" - checksum: 10c0/df66ea6632b44fb76ef6a65c1abbace13d883703aff37cd6d062add6dcd1b883f19ce733af8e0f7feb185b61600c6eb4042a518e4fb66323d0690ec357f9401c + "@vitest/pretty-format": "npm:2.0.3" + magic-string: "npm:^0.30.10" + pathe: "npm:^1.1.2" + checksum: 10c0/dc7e2e8f60d40c308c487effe2cd94c42bffa795c2d8c740c30b880b451637763891609a052afe29f0c9872e71141d439cb03118595e4a461fe6b4877ae99878 languageName: node linkType: hard -"@vitest/utils@npm:1.6.0": - version: 1.6.0 - resolution: "@vitest/utils@npm:1.6.0" +"@vitest/spy@npm:2.0.3": + version: 2.0.3 + resolution: "@vitest/spy@npm:2.0.3" + dependencies: + tinyspy: "npm:^3.0.0" + checksum: 10c0/4780aeed692c52756d70735b633ad58f201b2b8729b9e46c4cf968b8e4174e2c2cddd099de669019771bcd8e1ca32d0b9fa42d962e431fdf473b62393b9d2a0a + languageName: node + linkType: hard + +"@vitest/utils@npm:2.0.3": + version: 2.0.3 + resolution: "@vitest/utils@npm:2.0.3" dependencies: - diff-sequences: "npm:^29.6.3" + "@vitest/pretty-format": "npm:2.0.3" estree-walker: "npm:^3.0.3" - loupe: "npm:^2.3.7" - pretty-format: "npm:^29.7.0" - checksum: 10c0/8b0d19835866455eb0b02b31c5ca3d8ad45f41a24e4c7e1f064b480f6b2804dc895a70af332f14c11ed89581011b92b179718523f55f5b14787285a0321b1301 + loupe: "npm:^3.1.1" + tinyrainbow: "npm:^1.2.0" + checksum: 10c0/41b64c07814e7d576ebe7d11d277eb104a2aafb986497855a59f641b45fa53a30a2bfea525cd913e91b695f444a7a48b1f1e5909c27d5a989b0aea68f2242bd9 languageName: node linkType: hard @@ -1751,22 +1720,6 @@ __metadata: languageName: node linkType: hard -"acorn-walk@npm:^8.3.2": - version: 8.3.2 - resolution: "acorn-walk@npm:8.3.2" - checksum: 10c0/7e2a8dad5480df7f872569b9dccff2f3da7e65f5353686b1d6032ab9f4ddf6e3a2cb83a9b52cf50b1497fd522154dda92f0abf7153290cc79cd14721ff121e52 - languageName: node - linkType: hard - -"acorn@npm:^8.11.3": - version: 8.11.3 - resolution: "acorn@npm:8.11.3" - bin: - acorn: bin/acorn - checksum: 10c0/3ff155f8812e4a746fee8ecff1f227d527c4c45655bb1fad6347c3cb58e46190598217551b1500f18542d2bbe5c87120cb6927f5a074a59166fbdd9468f0a299 - languageName: node - linkType: hard - "acorn@npm:^8.8.2": version: 8.8.2 resolution: "acorn@npm:8.8.2" @@ -1818,13 +1771,6 @@ __metadata: languageName: node linkType: hard -"ansi-styles@npm:^5.0.0": - version: 5.2.0 - resolution: "ansi-styles@npm:5.2.0" - checksum: 10c0/9c4ca80eb3c2fb7b33841c210d2f20807f40865d27008d7c3f707b7f95cab7d67462a565e2388ac3285b71cb3d9bb2173de8da37c57692a362885ec34d6e27df - languageName: node - linkType: hard - "ansi-styles@npm:^6.1.0": version: 6.2.1 resolution: "ansi-styles@npm:6.2.1" @@ -1851,10 +1797,10 @@ __metadata: languageName: node linkType: hard -"assertion-error@npm:^1.1.0": - version: 1.1.0 - resolution: "assertion-error@npm:1.1.0" - checksum: 10c0/25456b2aa333250f01143968e02e4884a34588a8538fbbf65c91a637f1dbfb8069249133cd2f4e530f10f624d206a664e7df30207830b659e9f5298b00a4099b +"assertion-error@npm:^2.0.1": + version: 2.0.1 + resolution: "assertion-error@npm:2.0.1" + checksum: 10c0/bbbcb117ac6480138f8c93cf7f535614282dea9dc828f540cdece85e3c665e8f78958b96afac52f29ff883c72638e6a87d469ecc9fe5bc902df03ed24a55dba8 languageName: node linkType: hard @@ -1979,18 +1925,16 @@ __metadata: languageName: node linkType: hard -"chai@npm:^4.3.10": - version: 4.4.1 - resolution: "chai@npm:4.4.1" +"chai@npm:^5.1.1": + version: 5.1.1 + resolution: "chai@npm:5.1.1" dependencies: - assertion-error: "npm:^1.1.0" - check-error: "npm:^1.0.3" - deep-eql: "npm:^4.1.3" - get-func-name: "npm:^2.0.2" - loupe: "npm:^2.3.6" - pathval: "npm:^1.1.1" - type-detect: "npm:^4.0.8" - checksum: 10c0/91590a8fe18bd6235dece04ccb2d5b4ecec49984b50924499bdcd7a95c02cb1fd2a689407c19bb854497bde534ef57525cfad6c7fdd2507100fd802fbc2aefbd + assertion-error: "npm:^2.0.1" + check-error: "npm:^2.1.1" + deep-eql: "npm:^5.0.1" + loupe: "npm:^3.1.0" + pathval: "npm:^2.0.0" + checksum: 10c0/e7f00e5881e3d5224f08fe63966ed6566bd9fdde175863c7c16dd5240416de9b34c4a0dd925f4fd64ad56256ca6507d32cf6131c49e1db65c62578eb31d4566c languageName: node linkType: hard @@ -2004,12 +1948,10 @@ __metadata: languageName: node linkType: hard -"check-error@npm:^1.0.3": - version: 1.0.3 - resolution: "check-error@npm:1.0.3" - dependencies: - get-func-name: "npm:^2.0.2" - checksum: 10c0/94aa37a7315c0e8a83d0112b5bfb5a8624f7f0f81057c73e4707729cdd8077166c6aefb3d8e2b92c63ee130d4a2ff94bad46d547e12f3238cc1d78342a973841 +"check-error@npm:^2.1.1": + version: 2.1.1 + resolution: "check-error@npm:2.1.1" + checksum: 10c0/979f13eccab306cf1785fa10941a590b4e7ea9916ea2a4f8c87f0316fc3eab07eabefb6e587424ef0f88cbcd3805791f172ea739863ca3d7ce2afc54641c7f0e languageName: node linkType: hard @@ -2166,7 +2108,7 @@ __metadata: languageName: node linkType: hard -"debug@npm:4, debug@npm:^4": +"debug@npm:4, debug@npm:^4, debug@npm:^4.3.5": version: 4.3.5 resolution: "debug@npm:4.3.5" dependencies: @@ -2197,12 +2139,10 @@ __metadata: languageName: node linkType: hard -"deep-eql@npm:^4.1.3": - version: 4.1.3 - resolution: "deep-eql@npm:4.1.3" - dependencies: - type-detect: "npm:^4.0.0" - checksum: 10c0/ff34e8605d8253e1bf9fe48056e02c6f347b81d9b5df1c6650a1b0f6f847b4a86453b16dc226b34f853ef14b626e85d04e081b022e20b00cd7d54f079ce9bbdd +"deep-eql@npm:^5.0.1": + version: 5.0.2 + resolution: "deep-eql@npm:5.0.2" + checksum: 10c0/7102cf3b7bb719c6b9c0db2e19bf0aa9318d141581befe8c7ce8ccd39af9eaa4346e5e05adef7f9bd7015da0f13a3a25dcfe306ef79dc8668aedbecb658dd247 languageName: node linkType: hard @@ -2243,13 +2183,6 @@ __metadata: languageName: node linkType: hard -"diff-sequences@npm:^29.6.3": - version: 29.6.3 - resolution: "diff-sequences@npm:29.6.3" - checksum: 10c0/32e27ac7dbffdf2fb0eb5a84efd98a9ad084fbabd5ac9abb8757c6770d5320d2acd172830b28c4add29bb873d59420601dfc805ac4064330ce59b1adfd0593b2 - languageName: node - linkType: hard - "discord-api-types@npm:0.37.83": version: 0.37.83 resolution: "discord-api-types@npm:0.37.83" @@ -2594,13 +2527,6 @@ __metadata: languageName: node linkType: hard -"fs.realpath@npm:^1.0.0": - version: 1.0.0 - resolution: "fs.realpath@npm:1.0.0" - checksum: 10c0/444cf1291d997165dfd4c0d58b69f0e4782bfd9149fd72faa4fe299e68e0e93d6db941660b37dd29153bf7186672ececa3b50b7e7249477b03fdf850f287c948 - languageName: node - linkType: hard - "fsevents@npm:~2.3.2": version: 2.3.2 resolution: "fsevents@npm:2.3.2" @@ -2653,7 +2579,7 @@ __metadata: languageName: node linkType: hard -"get-func-name@npm:^2.0.0, get-func-name@npm:^2.0.1, get-func-name@npm:^2.0.2": +"get-func-name@npm:^2.0.1": version: 2.0.2 resolution: "get-func-name@npm:2.0.2" checksum: 10c0/89830fd07623fa73429a711b9daecdb304386d237c71268007f788f113505ef1d4cc2d0b9680e072c5082490aec9df5d7758bf5ac6f1c37062855e8e3dc0b9df @@ -2701,7 +2627,7 @@ __metadata: languageName: node linkType: hard -"glob@npm:^10.3.4": +"glob@npm:^10.3.4, glob@npm:^10.4.1": version: 10.4.5 resolution: "glob@npm:10.4.5" dependencies: @@ -2717,20 +2643,6 @@ __metadata: languageName: node linkType: hard -"glob@npm:^7.1.4": - version: 7.2.3 - resolution: "glob@npm:7.2.3" - dependencies: - fs.realpath: "npm:^1.0.0" - inflight: "npm:^1.0.4" - inherits: "npm:2" - minimatch: "npm:^3.1.1" - once: "npm:^1.3.0" - path-is-absolute: "npm:^1.0.0" - checksum: 10c0/65676153e2b0c9095100fe7f25a778bf45608eeb32c6048cf307f579649bcc30353277b3b898a3792602c65764e5baa4f643714dfbdfd64ea271d210c7a425fe - languageName: node - linkType: hard - "graceful-fs@npm:^4.1.6, graceful-fs@npm:^4.2.0, graceful-fs@npm:^4.2.6": version: 4.2.11 resolution: "graceful-fs@npm:4.2.11" @@ -2863,17 +2775,7 @@ __metadata: languageName: node linkType: hard -"inflight@npm:^1.0.4": - version: 1.0.6 - resolution: "inflight@npm:1.0.6" - dependencies: - once: "npm:^1.3.0" - wrappy: "npm:1" - checksum: 10c0/7faca22584600a9dc5b9fca2cd5feb7135ac8c935449837b315676b4c90aa4f391ec4f42240178244b5a34e8bede1948627fda392ca3191522fc46b34e985ab2 - languageName: node - linkType: hard - -"inherits@npm:2, inherits@npm:^2.0.3, inherits@npm:^2.0.4": +"inherits@npm:^2.0.3, inherits@npm:^2.0.4": version: 2.0.4 resolution: "inherits@npm:2.0.4" checksum: 10c0/4e531f648b29039fb7426fb94075e6545faa1eb9fe83c29f0b6d9e7263aceb4289d2d4557db0d428188eeb449cc7c5e77b0a0b2c4e248ff2a65933a0dee49ef2 @@ -3049,18 +2951,18 @@ __metadata: languageName: node linkType: hard -"istanbul-lib-source-maps@npm:^5.0.4": - version: 5.0.5 - resolution: "istanbul-lib-source-maps@npm:5.0.5" +"istanbul-lib-source-maps@npm:^5.0.6": + version: 5.0.6 + resolution: "istanbul-lib-source-maps@npm:5.0.6" dependencies: "@jridgewell/trace-mapping": "npm:^0.3.23" debug: "npm:^4.1.1" istanbul-lib-coverage: "npm:^3.0.0" - checksum: 10c0/6b7ee06e82cbd5cf7bb9359e59d5e654b50e84260394de2e345d545823c474ac1df356b53a0453629ede8c20c9eb145bb4ea2afa8d55117f7e7413133bcdfe6f + checksum: 10c0/ffe75d70b303a3621ee4671554f306e0831b16f39ab7f4ab52e54d356a5d33e534d97563e318f1333a6aae1d42f91ec49c76b6cd3f3fb378addcb5c81da0255f languageName: node linkType: hard -"istanbul-reports@npm:^3.1.6": +"istanbul-reports@npm:^3.1.7": version: 3.1.7 resolution: "istanbul-reports@npm:3.1.7" dependencies: @@ -3083,10 +2985,10 @@ __metadata: languageName: node linkType: hard -"js-tokens@npm:^8.0.2": - version: 8.0.3 - resolution: "js-tokens@npm:8.0.3" - checksum: 10c0/b50ba7d926b087ad31949d8155c7bc84374e0785019b17bdddeb2c4f98f5dea04ba464651fe23a8be4f7d15f50d06ce8bb536087b24ce3ebfbaea4a1dc5869f0 +"js-tokens@npm:^9.0.0": + version: 9.0.0 + resolution: "js-tokens@npm:9.0.0" + checksum: 10c0/4ad1c12f47b8c8b2a3a99e29ef338c1385c7b7442198a425f3463f3537384dab6032012791bfc2f056ea5ecdb06b1ed4f70e11a3ab3f388d3dcebfe16a52b27d languageName: node linkType: hard @@ -3097,13 +2999,6 @@ __metadata: languageName: node linkType: hard -"jsonc-parser@npm:^3.2.0": - version: 3.2.0 - resolution: "jsonc-parser@npm:3.2.0" - checksum: 10c0/5a12d4d04dad381852476872a29dcee03a57439574e4181d91dca71904fcdcc5e8e4706c0a68a2c61ad9810e1e1c5806b5100d52d3e727b78f5cdc595401045b - languageName: node - linkType: hard - "jsonfile@npm:^6.0.1": version: 6.1.0 resolution: "jsonfile@npm:6.1.0" @@ -3117,16 +3012,6 @@ __metadata: languageName: node linkType: hard -"local-pkg@npm:^0.5.0": - version: 0.5.0 - resolution: "local-pkg@npm:0.5.0" - dependencies: - mlly: "npm:^1.4.2" - pkg-types: "npm:^1.0.3" - checksum: 10c0/f61cbd00d7689f275558b1a45c7ff2a3ddf8472654123ed880215677b9adfa729f1081e50c27ffb415cdb9fa706fb755fec5e23cdd965be375c8059e87ff1cc9 - languageName: node - linkType: hard - "lodash.defaults@npm:^4.2.0": version: 4.2.0 resolution: "lodash.defaults@npm:4.2.0" @@ -3172,21 +3057,12 @@ __metadata: languageName: node linkType: hard -"loupe@npm:^2.3.6": - version: 2.3.6 - resolution: "loupe@npm:2.3.6" - dependencies: - get-func-name: "npm:^2.0.0" - checksum: 10c0/a974841ce94ef2a35aac7144e7f9e789e3887f82286cd9ffe7ff00f2ac9d117481989948657465e2b0b102f23136d89ae0a18fd4a32d9015012cd64464453289 - languageName: node - linkType: hard - -"loupe@npm:^2.3.7": - version: 2.3.7 - resolution: "loupe@npm:2.3.7" +"loupe@npm:^3.1.0, loupe@npm:^3.1.1": + version: 3.1.1 + resolution: "loupe@npm:3.1.1" dependencies: get-func-name: "npm:^2.0.1" - checksum: 10c0/71a781c8fc21527b99ed1062043f1f2bb30bdaf54fa4cf92463427e1718bc6567af2988300bc243c1f276e4f0876f29e3cbf7b58106fdc186915687456ce5bf4 + checksum: 10c0/99f88badc47e894016df0c403de846fedfea61154aadabbf776c8428dd59e8d8378007135d385d737de32ae47980af07d22ba7bec5ef7beebd721de9baa0a0af languageName: node linkType: hard @@ -3220,23 +3096,23 @@ __metadata: languageName: node linkType: hard -"magic-string@npm:^0.30.5": - version: 0.30.7 - resolution: "magic-string@npm:0.30.7" +"magic-string@npm:^0.30.10": + version: 0.30.10 + resolution: "magic-string@npm:0.30.10" dependencies: "@jridgewell/sourcemap-codec": "npm:^1.4.15" - checksum: 10c0/d1d949f7a53c37c6e685f4ea7b2b151c2fe0cc5af8f1f979ecba916f7d60d58f35309aaf4c8b09ce1aef7c160b957be39a38b52b478a91650750931e4ddd5daf + checksum: 10c0/aa9ca17eae571a19bce92c8221193b6f93ee8511abb10f085e55ffd398db8e4c089a208d9eac559deee96a08b7b24d636ea4ab92f09c6cf42a7d1af51f7fd62b languageName: node linkType: hard -"magicast@npm:^0.3.3": - version: 0.3.3 - resolution: "magicast@npm:0.3.3" +"magicast@npm:^0.3.4": + version: 0.3.4 + resolution: "magicast@npm:0.3.4" dependencies: - "@babel/parser": "npm:^7.23.6" - "@babel/types": "npm:^7.23.6" - source-map-js: "npm:^1.0.2" - checksum: 10c0/2eeba19545ac4328433be817bd81fcfa8a517ec67599260541e13ce5ce18b27ff8830f1b87d54a1392d408d1b96e44938bf026920f0110edbdfecc96980919b3 + "@babel/parser": "npm:^7.24.4" + "@babel/types": "npm:^7.24.0" + source-map-js: "npm:^1.2.0" + checksum: 10c0/7ebaaac397b13c31ca05e6d9649296751d76749b945d10a0800107872119fbdf267acdb604571d25e38ec6fd7ab3568a951b6e76eaef1caba9eaa11778fd9783 languageName: node linkType: hard @@ -3339,7 +3215,7 @@ __metadata: languageName: node linkType: hard -"minimatch@npm:^3.0.4, minimatch@npm:^3.1.1, minimatch@npm:^3.1.2": +"minimatch@npm:^3.1.2": version: 3.1.2 resolution: "minimatch@npm:3.1.2" dependencies: @@ -3457,30 +3333,6 @@ __metadata: languageName: node linkType: hard -"mlly@npm:^1.2.0": - version: 1.2.1 - resolution: "mlly@npm:1.2.1" - dependencies: - acorn: "npm:^8.8.2" - pathe: "npm:^1.1.0" - pkg-types: "npm:^1.0.3" - ufo: "npm:^1.1.2" - checksum: 10c0/4d2972801ad002876d8acd9c7b9cbf27969c4a6ee977217fe3c402dcc6b135f7259918baf0e3a3835b56f5b8e02b608d8fea49a2299ea6d70e97c7deaa440420 - languageName: node - linkType: hard - -"mlly@npm:^1.4.2": - version: 1.6.1 - resolution: "mlly@npm:1.6.1" - dependencies: - acorn: "npm:^8.11.3" - pathe: "npm:^1.1.2" - pkg-types: "npm:^1.0.3" - ufo: "npm:^1.3.2" - checksum: 10c0/a7bf26b3d4f83b0f5a5232caa3af44be08b464f562f31c11d885d1bc2d43b7d717137d47b0c06fdc69e1b33ffc09f902b6d2b18de02c577849d40914e8785092 - languageName: node - linkType: hard - "module-details-from-path@npm:^1.0.3": version: 1.0.3 resolution: "module-details-from-path@npm:1.0.3" @@ -3677,15 +3529,6 @@ __metadata: languageName: node linkType: hard -"once@npm:^1.3.0": - version: 1.4.0 - resolution: "once@npm:1.4.0" - dependencies: - wrappy: "npm:1" - checksum: 10c0/5d48aca287dfefabd756621c5dfce5c91a549a93e9fdb7b8246bc4c4790aa2ec17b34a260530474635147aeb631a2dcc8b32c613df0675f96041cbb8244517d0 - languageName: node - linkType: hard - "onetime@npm:^5.1.0": version: 5.1.2 resolution: "onetime@npm:5.1.2" @@ -3746,15 +3589,6 @@ __metadata: languageName: node linkType: hard -"p-limit@npm:^5.0.0": - version: 5.0.0 - resolution: "p-limit@npm:5.0.0" - dependencies: - yocto-queue: "npm:^1.0.0" - checksum: 10c0/574e93b8895a26e8485eb1df7c4b58a1a6e8d8ae41b1750cc2cc440922b3d306044fc6e9a7f74578a883d46802d9db72b30f2e612690fcef838c173261b1ed83 - languageName: node - linkType: hard - "p-map@npm:^4.0.0": version: 4.0.0 resolution: "p-map@npm:4.0.0" @@ -3790,13 +3624,6 @@ __metadata: languageName: node linkType: hard -"path-is-absolute@npm:^1.0.0": - version: 1.0.1 - resolution: "path-is-absolute@npm:1.0.1" - checksum: 10c0/127da03c82172a2a50099cddbf02510c1791fc2cc5f7713ddb613a56838db1e8168b121a920079d052e0936c23005562059756d653b7c544c53185efe53be078 - languageName: node - linkType: hard - "path-key@npm:^3.1.0": version: 3.1.1 resolution: "path-key@npm:3.1.1" @@ -3828,24 +3655,17 @@ __metadata: languageName: node linkType: hard -"pathe@npm:^1.1.0": - version: 1.1.0 - resolution: "pathe@npm:1.1.0" - checksum: 10c0/1c5d07378475bcdf4f435684566190d35d06be2db8b8e61cf9e866ae649941fdb093d732fa01b0f51d86e3f94140543c2571b0bf65a87ca7b5d1f52152aabe03 - languageName: node - linkType: hard - -"pathe@npm:^1.1.1, pathe@npm:^1.1.2": +"pathe@npm:^1.1.2": version: 1.1.2 resolution: "pathe@npm:1.1.2" checksum: 10c0/64ee0a4e587fb0f208d9777a6c56e4f9050039268faaaaecd50e959ef01bf847b7872785c36483fa5cdcdbdfdb31fef2ff222684d4fc21c330ab60395c681897 languageName: node linkType: hard -"pathval@npm:^1.1.1": - version: 1.1.1 - resolution: "pathval@npm:1.1.1" - checksum: 10c0/f63e1bc1b33593cdf094ed6ff5c49c1c0dc5dc20a646ca9725cc7fe7cd9995002d51d5685b9b2ec6814342935748b711bafa840f84c0bb04e38ff40a335c94dc +"pathval@npm:^2.0.0": + version: 2.0.0 + resolution: "pathval@npm:2.0.0" + checksum: 10c0/602e4ee347fba8a599115af2ccd8179836a63c925c23e04bd056d0674a64b39e3a081b643cc7bc0b84390517df2d800a46fcc5598d42c155fe4977095c2f77c5 languageName: node linkType: hard @@ -3931,17 +3751,6 @@ __metadata: languageName: node linkType: hard -"pkg-types@npm:^1.0.3": - version: 1.0.3 - resolution: "pkg-types@npm:1.0.3" - dependencies: - jsonc-parser: "npm:^3.2.0" - mlly: "npm:^1.2.0" - pathe: "npm:^1.1.0" - checksum: 10c0/7f692ff2005f51b8721381caf9bdbc7f5461506ba19c34f8631660a215c8de5e6dca268f23a319dd180b8f7c47a0dc6efea14b376c485ff99e98d810b8f786c4 - languageName: node - linkType: hard - "postcss@npm:^8.4.38": version: 8.4.38 resolution: "postcss@npm:8.4.38" @@ -4029,17 +3838,6 @@ __metadata: languageName: node linkType: hard -"pretty-format@npm:^29.7.0": - version: 29.7.0 - resolution: "pretty-format@npm:29.7.0" - dependencies: - "@jest/schemas": "npm:^29.6.3" - ansi-styles: "npm:^5.0.0" - react-is: "npm:^18.0.0" - checksum: 10c0/edc5ff89f51916f036c62ed433506b55446ff739358de77207e63e88a28ca2894caac6e73dcb68166a606e51c8087d32d400473e6a9fdd2dbe743f46c9c0276f - languageName: node - linkType: hard - "printable-characters@npm:^1.0.42": version: 1.0.42 resolution: "printable-characters@npm:1.0.42" @@ -4047,14 +3845,14 @@ __metadata: languageName: node linkType: hard -"prisma@npm:^5.16.1": - version: 5.16.1 - resolution: "prisma@npm:5.16.1" +"prisma@npm:^5.17.0": + version: 5.17.0 + resolution: "prisma@npm:5.17.0" dependencies: - "@prisma/engines": "npm:5.16.1" + "@prisma/engines": "npm:5.17.0" bin: prisma: build/index.js - checksum: 10c0/f30b3f4f5c1c0bb918bd6bc69a8e0af01d21a1b9ad4529f8abe2dc4efff0a24bf7ac34eb2e6fb7bd9159d789a16dbe58a0f1b973478194feb5ecd1048262bf47 + checksum: 10c0/30546a8576ffadf66d6f34cd833e25e21eec99847db92c4d88f6c9dbbc401abbd3f699f9e0f0dbcd9d5229ccba47c6aadb42ba6cd6e29afb7335689c7257c964 languageName: node linkType: hard @@ -4110,13 +3908,6 @@ __metadata: languageName: node linkType: hard -"react-is@npm:^18.0.0": - version: 18.2.0 - resolution: "react-is@npm:18.2.0" - checksum: 10c0/6eb5e4b28028c23e2bfcf73371e72cd4162e4ac7ab445ddae2afe24e347a37d6dc22fae6e1748632cd43c6d4f9b8f86dcf26bf9275e1874f436d129952528ae0 - languageName: node - linkType: hard - "readable-stream@npm:^3.4.0": version: 3.6.2 resolution: "readable-stream@npm:3.6.2" @@ -4309,7 +4100,7 @@ __metadata: "@biomejs/biome": "npm:^1.8.3" "@napi-rs/canvas": "npm:^0.1.53" "@oldschoolgg/toolkit": "git+https://github.com/oldschoolgg/toolkit.git#3550582efbdf04929f0a2c5114ed069a61551807" - "@prisma/client": "npm:^5.16.1" + "@prisma/client": "npm:^5.17.0" "@sapphire/ratelimits": "npm:^2.4.9" "@sapphire/snowflake": "npm:^3.5.3" "@sapphire/time-utilities": "npm:^1.6.0" @@ -4318,7 +4109,7 @@ __metadata: "@types/node": "npm:^20.14.9" "@types/node-cron": "npm:^3.0.7" "@types/node-fetch": "npm:^2.6.1" - "@vitest/coverage-v8": "npm:^1.6.0" + "@vitest/coverage-v8": "npm:^2.0.3" ascii-table3: "npm:^0.9.0" bufferutil: "npm:^4.0.8" concurrently: "npm:^8.2.2" @@ -4339,13 +4130,13 @@ __metadata: p-queue: "npm:^6.6.2" piscina: "npm:^4.6.1" prettier: "npm:^3.3.2" - prisma: "npm:^5.16.1" + prisma: "npm:^5.17.0" random-js: "npm:^2.1.0" simple-statistics: "npm:^7.8.3" sonic-boom: "npm:^4.0.1" tsx: "npm:^4.16.2" typescript: "npm:^5.5.3" - vitest: "npm:^1.6.0" + vitest: "npm:^2.0.3" zlib-sync: "npm:^0.1.9" zod: "npm:^3.23.8" languageName: unknown @@ -4516,13 +4307,6 @@ __metadata: languageName: node linkType: hard -"source-map-js@npm:^1.0.2": - version: 1.0.2 - resolution: "source-map-js@npm:1.0.2" - checksum: 10c0/32f2dfd1e9b7168f9a9715eb1b4e21905850f3b50cf02cf476e47e4eebe8e6b762b63a64357896aa29b37e24922b4282df0f492e0d2ace572b43d15525976ff8 - languageName: node - linkType: hard - "source-map-js@npm:^1.2.0": version: 1.2.0 resolution: "source-map-js@npm:1.2.0" @@ -4567,7 +4351,7 @@ __metadata: languageName: node linkType: hard -"std-env@npm:^3.5.0": +"std-env@npm:^3.7.0": version: 3.7.0 resolution: "std-env@npm:3.7.0" checksum: 10c0/60edf2d130a4feb7002974af3d5a5f3343558d1ccf8d9b9934d225c638606884db4a20d2fe6440a09605bca282af6b042ae8070a10490c0800d69e82e478f41e @@ -4630,12 +4414,12 @@ __metadata: languageName: node linkType: hard -"strip-literal@npm:^2.0.0": - version: 2.0.0 - resolution: "strip-literal@npm:2.0.0" +"strip-literal@npm:^2.1.0": + version: 2.1.0 + resolution: "strip-literal@npm:2.1.0" dependencies: - js-tokens: "npm:^8.0.2" - checksum: 10c0/63a6e4224ac7088ff93fd19fc0f6882705020da2f0767dbbecb929cbf9d49022e72350420f47be635866823608da9b9a5caf34f518004721895b6031199fc3c8 + js-tokens: "npm:^9.0.0" + checksum: 10c0/bc8b8c8346125ae3c20fcdaf12e10a498ff85baf6f69597b4ab2b5fbf2e58cfd2827f1a44f83606b852da99a5f6c8279770046ddea974c510c17c98934c9cc24 languageName: node linkType: hard @@ -4687,35 +4471,42 @@ __metadata: languageName: node linkType: hard -"test-exclude@npm:^6.0.0": - version: 6.0.0 - resolution: "test-exclude@npm:6.0.0" +"test-exclude@npm:^7.0.1": + version: 7.0.1 + resolution: "test-exclude@npm:7.0.1" dependencies: "@istanbuljs/schema": "npm:^0.1.2" - glob: "npm:^7.1.4" - minimatch: "npm:^3.0.4" - checksum: 10c0/019d33d81adff3f9f1bfcff18125fb2d3c65564f437d9be539270ee74b994986abb8260c7c2ce90e8f30162178b09dbbce33c6389273afac4f36069c48521f57 + glob: "npm:^10.4.1" + minimatch: "npm:^9.0.4" + checksum: 10c0/6d67b9af4336a2e12b26a68c83308c7863534c65f27ed4ff7068a56f5a58f7ac703e8fc80f698a19bb154fd8f705cdf7ec347d9512b2c522c737269507e7b263 languageName: node linkType: hard -"tinybench@npm:^2.5.1": - version: 2.6.0 - resolution: "tinybench@npm:2.6.0" - checksum: 10c0/60ea35699bf8bac9bc8cf279fa5877ab5b335b4673dcd07bf0fbbab9d7953a02c0ccded374677213eaa13aa147f54eb75d3230139ddbeec3875829ebe73db310 +"tinybench@npm:^2.8.0": + version: 2.8.0 + resolution: "tinybench@npm:2.8.0" + checksum: 10c0/5a9a642351fa3e4955e0cbf38f5674be5f3ba6730fd872fd23a5c953ad6c914234d5aba6ea41ef88820180a81829ceece5bd8d3967c490c5171bca1141c2f24d languageName: node linkType: hard -"tinypool@npm:^0.8.3": - version: 0.8.4 - resolution: "tinypool@npm:0.8.4" - checksum: 10c0/779c790adcb0316a45359652f4b025958c1dff5a82460fe49f553c864309b12ad732c8288be52f852973bc76317f5e7b3598878aee0beb8a33322c0e72c4a66c +"tinypool@npm:^1.0.0": + version: 1.0.0 + resolution: "tinypool@npm:1.0.0" + checksum: 10c0/71b20b9c54366393831c286a0772380c20f8cad9546d724c484edb47aea3228f274c58e98cf51d28c40869b39f5273209ef3ea94a9d2a23f8b292f4731cd3e4e languageName: node linkType: hard -"tinyspy@npm:^2.2.0": - version: 2.2.1 - resolution: "tinyspy@npm:2.2.1" - checksum: 10c0/0b4cfd07c09871e12c592dfa7b91528124dc49a4766a0b23350638c62e6a483d5a2a667de7e6282246c0d4f09996482ddaacbd01f0c05b7ed7e0f79d32409bdc +"tinyrainbow@npm:^1.2.0": + version: 1.2.0 + resolution: "tinyrainbow@npm:1.2.0" + checksum: 10c0/7f78a4b997e5ba0f5ecb75e7ed786f30bab9063716e7dff24dd84013fb338802e43d176cb21ed12480561f5649a82184cf31efb296601a29d38145b1cdb4c192 + languageName: node + linkType: hard + +"tinyspy@npm:^3.0.0": + version: 3.0.0 + resolution: "tinyspy@npm:3.0.0" + checksum: 10c0/eb0dec264aa5370efd3d29743825eb115ed7f1ef8a72a431e9a75d5c9e7d67e99d04b0d61d86b8cd70c79ec27863f241ad0317bc453f78762e0cbd76d2c332d0 languageName: node linkType: hard @@ -4806,13 +4597,6 @@ __metadata: languageName: node linkType: hard -"type-detect@npm:^4.0.0, type-detect@npm:^4.0.8": - version: 4.0.8 - resolution: "type-detect@npm:4.0.8" - checksum: 10c0/8fb9a51d3f365a7de84ab7f73b653534b61b622aa6800aecdb0f1095a4a646d3f5eb295322127b6573db7982afcd40ab492d038cf825a42093a58b1e1353e0bd - languageName: node - linkType: hard - "typescript@npm:^5.2.2, typescript@npm:^5.5.3": version: 5.5.3 resolution: "typescript@npm:5.5.3" @@ -4833,20 +4617,6 @@ __metadata: languageName: node linkType: hard -"ufo@npm:^1.1.2": - version: 1.1.2 - resolution: "ufo@npm:1.1.2" - checksum: 10c0/f19c5e0093447dbebb33cb84bfc2073e4a8a5d3535f44ca3aaa1470a93f394f14896b3a11cd4258fb986ba86b566543ea079244205a06ce35a480d4e613aca6e - languageName: node - linkType: hard - -"ufo@npm:^1.3.2": - version: 1.4.0 - resolution: "ufo@npm:1.4.0" - checksum: 10c0/d9a3cb8c5fd13356e0af661362244fd0a901edcdd08996f42553271007cae01e85dcec29a3303a87ddab6aa705cbd630332aaa8c268d037483536b198fa67a7c - languageName: node - linkType: hard - "undefsafe@npm:^2.0.5": version: 2.0.5 resolution: "undefsafe@npm:2.0.5" @@ -4909,18 +4679,18 @@ __metadata: languageName: node linkType: hard -"vite-node@npm:1.6.0": - version: 1.6.0 - resolution: "vite-node@npm:1.6.0" +"vite-node@npm:2.0.3": + version: 2.0.3 + resolution: "vite-node@npm:2.0.3" dependencies: cac: "npm:^6.7.14" - debug: "npm:^4.3.4" - pathe: "npm:^1.1.1" - picocolors: "npm:^1.0.0" + debug: "npm:^4.3.5" + pathe: "npm:^1.1.2" + tinyrainbow: "npm:^1.2.0" vite: "npm:^5.0.0" bin: vite-node: vite-node.mjs - checksum: 10c0/0807e6501ac7763e0efa2b4bd484ce99fb207e92c98624c9f8999d1f6727ac026e457994260fa7fdb7060d87546d197081e46a705d05b0136a38b6f03715cbc2 + checksum: 10c0/a1bcc110aeb49e79a50ae0df41ca692d39e0d992702f7c5b095c969f622eb72636543bed79efb7131fdedaa4c44a6c9c19daf6fca909240acc1f27f79b978c11 languageName: node linkType: hard @@ -4964,35 +4734,34 @@ __metadata: languageName: node linkType: hard -"vitest@npm:^1.6.0": - version: 1.6.0 - resolution: "vitest@npm:1.6.0" - dependencies: - "@vitest/expect": "npm:1.6.0" - "@vitest/runner": "npm:1.6.0" - "@vitest/snapshot": "npm:1.6.0" - "@vitest/spy": "npm:1.6.0" - "@vitest/utils": "npm:1.6.0" - acorn-walk: "npm:^8.3.2" - chai: "npm:^4.3.10" - debug: "npm:^4.3.4" +"vitest@npm:^2.0.3": + version: 2.0.3 + resolution: "vitest@npm:2.0.3" + dependencies: + "@ampproject/remapping": "npm:^2.3.0" + "@vitest/expect": "npm:2.0.3" + "@vitest/pretty-format": "npm:^2.0.3" + "@vitest/runner": "npm:2.0.3" + "@vitest/snapshot": "npm:2.0.3" + "@vitest/spy": "npm:2.0.3" + "@vitest/utils": "npm:2.0.3" + chai: "npm:^5.1.1" + debug: "npm:^4.3.5" execa: "npm:^8.0.1" - local-pkg: "npm:^0.5.0" - magic-string: "npm:^0.30.5" - pathe: "npm:^1.1.1" - picocolors: "npm:^1.0.0" - std-env: "npm:^3.5.0" - strip-literal: "npm:^2.0.0" - tinybench: "npm:^2.5.1" - tinypool: "npm:^0.8.3" + magic-string: "npm:^0.30.10" + pathe: "npm:^1.1.2" + std-env: "npm:^3.7.0" + tinybench: "npm:^2.8.0" + tinypool: "npm:^1.0.0" + tinyrainbow: "npm:^1.2.0" vite: "npm:^5.0.0" - vite-node: "npm:1.6.0" + vite-node: "npm:2.0.3" why-is-node-running: "npm:^2.2.2" peerDependencies: "@edge-runtime/vm": "*" "@types/node": ^18.0.0 || >=20.0.0 - "@vitest/browser": 1.6.0 - "@vitest/ui": 1.6.0 + "@vitest/browser": 2.0.3 + "@vitest/ui": 2.0.3 happy-dom: "*" jsdom: "*" peerDependenciesMeta: @@ -5010,7 +4779,7 @@ __metadata: optional: true bin: vitest: vitest.mjs - checksum: 10c0/065da5b8ead51eb174d93dac0cd50042ca9539856dc25e340ea905d668c41961f7e00df3e388e6c76125b2c22091db2e8465f993d0f6944daf9598d549e562e7 + checksum: 10c0/1801ec31eb144063d14a03d054ff573869732dcaf69abd4fefdabe011d183599a7493e49d8e180b29808675309814421c4a12271fb140c708e7c9f68c4a37a3c languageName: node linkType: hard @@ -5096,13 +4865,6 @@ __metadata: languageName: node linkType: hard -"wrappy@npm:1": - version: 1.0.2 - resolution: "wrappy@npm:1.0.2" - checksum: 10c0/56fece1a4018c6a6c8e28fbc88c87e0fbf4ea8fd64fc6c63b18f4acc4bd13e0ad2515189786dd2c30d3eec9663d70f4ecf699330002f8ccb547e4a18231fc9f0 - languageName: node - linkType: hard - "ws@npm:^8.16.0": version: 8.18.0 resolution: "ws@npm:8.18.0" @@ -5161,13 +4923,6 @@ __metadata: languageName: node linkType: hard -"yocto-queue@npm:^1.0.0": - version: 1.0.0 - resolution: "yocto-queue@npm:1.0.0" - checksum: 10c0/856117aa15cf5103d2a2fb173f0ab4acb12b4b4d0ed3ab249fdbbf612e55d1cadfd27a6110940e24746fb0a78cf640b522cc8bca76f30a3b00b66e90cf82abe0 - languageName: node - linkType: hard - "zlib-sync@npm:^0.1.9": version: 0.1.9 resolution: "zlib-sync@npm:0.1.9" From 80a7b80372988a2f48a5f41bea322249459456ee Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Sat, 20 Jul 2024 17:47:34 +1000 Subject: [PATCH 103/145] Add itemsToRemove to trip effects --- src/lib/util/handleTripFinish.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/lib/util/handleTripFinish.ts b/src/lib/util/handleTripFinish.ts index fcea8224f4..e589cf0100 100644 --- a/src/lib/util/handleTripFinish.ts +++ b/src/lib/util/handleTripFinish.ts @@ -45,6 +45,7 @@ interface TripFinishEffectOptions { type TripEffectReturn = { itemsToAddWithCL?: Bank; + itemsToRemove?: Bank; }; export interface TripFinishEffect { @@ -124,17 +125,19 @@ export async function handleTripFinish( const messages: string[] = []; const itemsToAddWithCL = new Bank(); + const itemsToRemove = new Bank(); for (const effect of tripFinishEffects) { const stopwatch = new Stopwatch().start(); const res = await effect.fn({ data, user, loot, messages }); if (res?.itemsToAddWithCL) itemsToAddWithCL.add(res.itemsToAddWithCL); + if (res?.itemsToRemove) itemsToRemove.add(res.itemsToRemove); stopwatch.stop(); if (stopwatch.duration > 500) { debugLog(`Finished ${effect.name} trip effect for ${user.id} in ${stopwatch}`); } } - if (itemsToAddWithCL.length > 0) { - await user.addItemsToBank({ items: itemsToAddWithCL, collectionLog: true }); + if (itemsToAddWithCL.length > 0 || itemsToRemove.length > 0) { + await user.transactItems({ itemsToAdd: itemsToAddWithCL, collectionLog: true, itemsToRemove }); } const clueReceived = loot ? ClueTiers.filter(tier => loot.amount(tier.scrollID) > 0) : []; From 3c3d986b8b945d050f1d3dcd7a016ae505bf0772 Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Sat, 20 Jul 2024 17:47:40 +1000 Subject: [PATCH 104/145] Change coverage output to text --- vitest.unit.config.mts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vitest.unit.config.mts b/vitest.unit.config.mts index a5315297c7..76d7b34b42 100644 --- a/vitest.unit.config.mts +++ b/vitest.unit.config.mts @@ -8,7 +8,7 @@ export default defineConfig({ include: ['tests/unit/**/*.test.ts'], coverage: { provider: 'v8', - reporter: 'html', + reporter: 'text', include: ['src/lib/structures/Gear.ts', 'src/lib/util/parseStringBank.ts', 'src/lib/util/equipMulti.ts'] }, setupFiles: 'tests/unit/setup.ts', From 3be70a2f8cd728d8583b042e4b41c74a5d5f87ce Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Sat, 20 Jul 2024 18:08:19 +1000 Subject: [PATCH 105/145] Fix trip effect types --- src/lib/util/handleTripFinish.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/lib/util/handleTripFinish.ts b/src/lib/util/handleTripFinish.ts index e589cf0100..090bfc1ab5 100644 --- a/src/lib/util/handleTripFinish.ts +++ b/src/lib/util/handleTripFinish.ts @@ -50,7 +50,8 @@ type TripEffectReturn = { export interface TripFinishEffect { name: string; - fn: (options: TripFinishEffectOptions) => Promise | Promise | Promise; + // biome-ignore lint/suspicious/noConfusingVoidType: + fn: (options: TripFinishEffectOptions) => Promise; } const tripFinishEffects: TripFinishEffect[] = [ From 68d67e0d6a6227a56c7eec24e2fdf98e26539387 Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Sat, 20 Jul 2024 18:10:30 +1000 Subject: [PATCH 106/145] Skip prestartup test for now --- tests/integration/preStartup.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/preStartup.test.ts b/tests/integration/preStartup.test.ts index d865c68ea9..66911fbd99 100644 --- a/tests/integration/preStartup.test.ts +++ b/tests/integration/preStartup.test.ts @@ -3,7 +3,7 @@ import { test } from 'vitest'; import { preStartup } from '../../src/lib/preStartup'; import { mockClient } from './util'; -test('PreStartup', async () => { +test.skip('PreStartup', async () => { await mockClient(); await preStartup(); }); From b1009ab9afed3ffae36c1ad67316dfd1d56df4bc Mon Sep 17 00:00:00 2001 From: TastyPumPum <79149170+TastyPumPum@users.noreply.github.com> Date: Sat, 20 Jul 2024 10:57:55 +0100 Subject: [PATCH 107/145] Slayer Tasks Fixes (#5958) --- src/lib/slayer/slayerUtil.ts | 3 ++ src/lib/slayer/tasks/bossTasks.ts | 34 ++++--------------- src/lib/slayer/tasks/krystiliaTasks.ts | 4 +++ .../abstracted_commands/autoSlayCommand.ts | 6 ++-- 4 files changed, 17 insertions(+), 30 deletions(-) diff --git a/src/lib/slayer/slayerUtil.ts b/src/lib/slayer/slayerUtil.ts index c8cb21238f..8ed9e4cdbb 100644 --- a/src/lib/slayer/slayerUtil.ts +++ b/src/lib/slayer/slayerUtil.ts @@ -289,6 +289,9 @@ export function getCommonTaskName(task: Monster) { case Monsters.RevenantImp.id: commonName = 'Revenant'; break; + case Monsters.DagannothPrime.id: + commonName = 'Dagannoth Kings'; + break; default: } if (commonName !== 'TzHaar' && !commonName.endsWith('s')) commonName += 's'; diff --git a/src/lib/slayer/tasks/bossTasks.ts b/src/lib/slayer/tasks/bossTasks.ts index cbab364148..6ffcfb8338 100644 --- a/src/lib/slayer/tasks/bossTasks.ts +++ b/src/lib/slayer/tasks/bossTasks.ts @@ -33,7 +33,7 @@ export const bossTasks: AssignableSlayerTask[] = [ monster: Monsters.Callisto, amount: [3, 35], weight: 1, - monsters: [Monsters.Callisto.id], + monsters: [Monsters.Callisto.id, Monsters.Artio.id], isBoss: true, wilderness: true }, @@ -90,27 +90,7 @@ export const bossTasks: AssignableSlayerTask[] = [ levelRequirements: { prayer: 43 }, - monsters: [Monsters.DagannothPrime.id], - isBoss: true - }, - { - monster: Monsters.DagannothSupreme, - amount: [3, 35], - weight: 1, - levelRequirements: { - prayer: 43 - }, - monsters: [Monsters.DagannothSupreme.id], - isBoss: true - }, - { - monster: Monsters.DagannothRex, - amount: [3, 35], - weight: 1, - levelRequirements: { - prayer: 43 - }, - monsters: [Monsters.DagannothRex.id], + monsters: [Monsters.DagannothPrime.id, Monsters.DagannothSupreme.id, Monsters.DagannothRex.id], isBoss: true }, { @@ -219,7 +199,7 @@ export const bossTasks: AssignableSlayerTask[] = [ monster: Monsters.Venenatis, amount: [3, 35], weight: 1, - monsters: [Monsters.Venenatis.id], + monsters: [Monsters.Venenatis.id, Monsters.Spindel.id], isBoss: true, wilderness: true }, @@ -227,7 +207,7 @@ export const bossTasks: AssignableSlayerTask[] = [ monster: Monsters.Vetion, amount: [3, 35], weight: 1, - monsters: [Monsters.Vetion.id], + monsters: [Monsters.Vetion.id, Monsters.Calvarion.id], isBoss: true, wilderness: true }, @@ -260,7 +240,7 @@ export const wildernessBossTasks: AssignableSlayerTask[] = [ monster: Monsters.Callisto, amount: [3, 35], weight: 1, - monsters: [Monsters.Callisto.id], + monsters: [Monsters.Callisto.id, Monsters.Artio.id], isBoss: true, wilderness: true }, @@ -300,7 +280,7 @@ export const wildernessBossTasks: AssignableSlayerTask[] = [ monster: Monsters.Venenatis, amount: [3, 35], weight: 1, - monsters: [Monsters.Venenatis.id], + monsters: [Monsters.Venenatis.id, Monsters.Spindel.id], isBoss: true, wilderness: true }, @@ -308,7 +288,7 @@ export const wildernessBossTasks: AssignableSlayerTask[] = [ monster: Monsters.Vetion, amount: [3, 35], weight: 1, - monsters: [Monsters.Vetion.id], + monsters: [Monsters.Vetion.id, Monsters.Calvarion.id], isBoss: true, wilderness: true } diff --git a/src/lib/slayer/tasks/krystiliaTasks.ts b/src/lib/slayer/tasks/krystiliaTasks.ts index 40b5d56071..f32758963e 100644 --- a/src/lib/slayer/tasks/krystiliaTasks.ts +++ b/src/lib/slayer/tasks/krystiliaTasks.ts @@ -67,6 +67,8 @@ export const krystiliaTasks: AssignableSlayerTask[] = [ amount: [8, 16], weight: 4, monsters: [Monsters.BlackDragon.id], + extendedAmount: [40, 60], + extendedUnlockId: SlayerTaskUnlocksEnum.FireAndDarkness, unlocked: true, wilderness: true }, @@ -244,6 +246,8 @@ export const krystiliaTasks: AssignableSlayerTask[] = [ amount: [75, 125], weight: 5, monsters: [Monsters.GreaterNechryael.id], + extendedAmount: [200, 250], + extendedUnlockId: SlayerTaskUnlocksEnum.NechsPlease, slayerLevel: 80, unlocked: true, wilderness: true diff --git a/src/mahoji/lib/abstracted_commands/autoSlayCommand.ts b/src/mahoji/lib/abstracted_commands/autoSlayCommand.ts index f8722f6091..6d2d7183dc 100644 --- a/src/mahoji/lib/abstracted_commands/autoSlayCommand.ts +++ b/src/mahoji/lib/abstracted_commands/autoSlayCommand.ts @@ -345,9 +345,9 @@ const WildyAutoSlayMaxEfficiencyTable: AutoslayLink[] = [ efficientMethod: 'cannon' }, { - monsterID: Monsters.Nechryael.id, - efficientName: Monsters.Nechryael.name, - efficientMonster: Monsters.Nechryael.id, + monsterID: Monsters.GreaterNechryael.id, + efficientName: Monsters.GreaterNechryael.name, + efficientMonster: Monsters.GreaterNechryael.id, efficientMethod: 'barrage' }, { From 0c8f72dc89a7d0741a9c0a7fc9e46cce7a0a4d88 Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Tue, 23 Jul 2024 18:43:37 +1000 Subject: [PATCH 108/145] Truncate sacrifice strings --- src/mahoji/commands/sacrifice.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/mahoji/commands/sacrifice.ts b/src/mahoji/commands/sacrifice.ts index 31916e619a..1b5019e57e 100644 --- a/src/mahoji/commands/sacrifice.ts +++ b/src/mahoji/commands/sacrifice.ts @@ -8,7 +8,7 @@ import { Emoji, Events } from '../../lib/constants'; import { cats } from '../../lib/growablePets'; import minionIcons from '../../lib/minions/data/minionIcons'; import type { ItemBank } from '../../lib/types'; -import { toKMB } from '../../lib/util'; +import { toKMB, truncateString } from '../../lib/util'; import { handleMahojiConfirmation } from '../../lib/util/handleMahojiConfirmation'; import { deferInteraction } from '../../lib/util/interactionReply'; import { parseBank } from '../../lib/util/parseStringBank'; @@ -156,7 +156,7 @@ export const sacrificeCommand: OSBMahojiCommand = { await handleMahojiConfirmation( interaction, - `${user}, are you sure you want to sacrifice ${bankToSac}? This will add ${totalPrice.toLocaleString()} (${toKMB( + `${user}, are you sure you want to sacrifice ${truncateString(bankToSac.toString(), 15000)}? This will add ${totalPrice.toLocaleString()} (${toKMB( totalPrice )}) to your sacrificed amount.` ); From e24369bf489009bccf82d6b5853560aef74975fe Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Tue, 23 Jul 2024 20:35:41 +1000 Subject: [PATCH 109/145] Fix rebooting --- src/mahoji/commands/admin.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mahoji/commands/admin.ts b/src/mahoji/commands/admin.ts index e60ff52cb1..8626813376 100644 --- a/src/mahoji/commands/admin.ts +++ b/src/mahoji/commands/admin.ts @@ -883,7 +883,7 @@ export const adminCommand: OSBMahojiCommand = { ${META_CONSTANTS.RENDERED_STR}` }).catch(noOp); - process.exit(); + process.exit(1); } if (options.shut_down) { debugLog('SHUTTING DOWN'); From 156f0db932c502ad38def29aab6d98108b2dd0b8 Mon Sep 17 00:00:00 2001 From: TastyPumPum <79149170+TastyPumPum@users.noreply.github.com> Date: Wed, 24 Jul 2024 10:42:37 +0100 Subject: [PATCH 110/145] Add Refund of charges to Colosseum (#5956) --- src/lib/colosseum.ts | 37 ++++++++++-- src/lib/data/Collections.ts | 3 +- src/lib/degradeableItems.ts | 42 ++++++++++++++ src/lib/types/minions.ts | 3 + src/mahoji/commands/simulate.ts | 5 +- src/tasks/minions/colosseumActivity.ts | 56 +++++++++++++++---- tests/unit/snapshots/clsnapshots.test.ts.snap | 2 +- 7 files changed, 127 insertions(+), 21 deletions(-) diff --git a/src/lib/colosseum.ts b/src/lib/colosseum.ts index 1ddd0229ca..08f53f2ee8 100644 --- a/src/lib/colosseum.ts +++ b/src/lib/colosseum.ts @@ -366,6 +366,9 @@ interface ColosseumResult { realDuration: number; totalDeathChance: number; deathChances: number[]; + scytheCharges: number; + venatorBowCharges: number; + bloodFuryCharges: number; } export const startColosseumRun = (options: { @@ -377,6 +380,9 @@ export const startColosseumRun = (options: { hasClaws: boolean; hasSGS: boolean; hasTorture: boolean; + scytheCharges: number; + venatorBowCharges: number; + bloodFuryCharges: number; }): ColosseumResult => { const waveTwelveKC = options.kcBank.amount(12); @@ -406,6 +412,10 @@ export const startColosseumRun = (options: { let realDuration = 0; let maxGlory = 0; + // Calculate charges used + const scytheCharges = 300; + const calculateVenCharges = () => 50; + for (const wave of colosseumWaves) { realDuration += waveDuration; const kcForThisWave = options.kcBank.amount(wave.waveNumber); @@ -422,7 +432,10 @@ export const startColosseumRun = (options: { fakeDuration, realDuration, totalDeathChance: combinedChance(deathChances), - deathChances + deathChances, + scytheCharges: options.hasScythe ? scytheCharges : 0, + venatorBowCharges: options.hasVenBow ? calculateVenCharges() : 0, + bloodFuryCharges: options.hasBF ? scytheCharges * 3 : 0 }; } addedWaveKCBank.add(wave.waveNumber); @@ -436,7 +449,11 @@ export const startColosseumRun = (options: { fakeDuration, realDuration, totalDeathChance: combinedChance(deathChances), - deathChances + deathChances, + + scytheCharges: options.hasScythe ? scytheCharges : 0, + venatorBowCharges: options.hasVenBow ? calculateVenCharges() : 0, + bloodFuryCharges: options.hasBF ? scytheCharges * 3 : 0 }; } } @@ -532,6 +549,9 @@ export async function colosseumCommand(user: MUser, channelID: string) { const hasClaws = user.hasEquippedOrInBank('Dragon claws'); const hasSGS = user.hasEquippedOrInBank('Saradomin godsword'); const hasTorture = !hasBF && user.gear.melee.hasEquipped('Amulet of torture'); + const scytheCharges = 300; + const bloodFuryCharges = scytheCharges * 3; + const venatorBowCharges = calculateVenCharges(); const res = startColosseumRun({ kcBank: new ColosseumWaveBank((await user.fetchStats({ colo_kc_bank: true })).colo_kc_bank as ItemBank), @@ -541,7 +561,10 @@ export async function colosseumCommand(user: MUser, channelID: string) { hasBF, hasClaws, hasSGS, - hasTorture + hasTorture, + scytheCharges, + venatorBowCharges, + bloodFuryCharges }); const minutes = res.realDuration / Time.Minute; @@ -556,7 +579,6 @@ export async function colosseumCommand(user: MUser, channelID: string) { return 'You need to have a Ranging potion(4) or Bastion potion(4) in your bank.'; } - const scytheCharges = 300; if (hasScythe) { messages.push('10% boost for Scythe'); chargeBank.add('scythe_of_vitur_charges', scytheCharges); @@ -595,7 +617,7 @@ export async function colosseumCommand(user: MUser, channelID: string) { } if (user.gear.melee.hasEquipped('Amulet of blood fury')) { - chargeBank.add('blood_fury_charges', scytheCharges * 3); + chargeBank.add('blood_fury_charges', bloodFuryCharges); messages.push('-5% death chance for blood fury'); } else { messages.push('Missed -5% death chance for blood fury. If you have one, add charges and equip it to melee.'); @@ -652,7 +674,10 @@ export async function colosseumCommand(user: MUser, channelID: string) { fakeDuration: res.fakeDuration, maxGlory: res.maxGlory, diedAt: res.diedAt ?? undefined, - loot: res.loot?.bank + loot: res.loot?.bank, + scytheCharges: res.scytheCharges, + venatorBowCharges: res.venatorBowCharges, + bloodFuryCharges: res.bloodFuryCharges }); return `${user.minionName} is now attempting the Colosseum. They will finish in around ${formatDuration( diff --git a/src/lib/data/Collections.ts b/src/lib/data/Collections.ts index 84726fe90d..7e3a4c1f29 100644 --- a/src/lib/data/Collections.ts +++ b/src/lib/data/Collections.ts @@ -328,7 +328,8 @@ export const allCollectionLogs: ICollection = { 'Sunfire fanatic helm', 'Echo crystal', 'Tonalztics of ralos (uncharged)', - 'Sunfire splinters' + 'Sunfire splinters', + 'Uncut onyx' ]), fmtProg: ({ minigames }) => `${minigames.colosseum} KC` }, diff --git a/src/lib/degradeableItems.ts b/src/lib/degradeableItems.ts index 47807b39d8..dc3010152a 100644 --- a/src/lib/degradeableItems.ts +++ b/src/lib/degradeableItems.ts @@ -61,6 +61,13 @@ interface DegradeableItemPVMBoost { boost: number; } +interface RefundResult { + item: Item; + refundedCharges: number; + totalCharges: number; + userMessage: string; +} + export const degradeableItems: DegradeableItem[] = [ { item: getOSItem('Abyssal tentacle'), @@ -426,3 +433,38 @@ export async function degradeChargeBank(user: MUser, chargeBank: ChargeBank) { return results; } + +export async function refundChargeBank(user: MUser, chargeBank: ChargeBank): Promise { + const results: RefundResult[] = []; + + for (const [key, chargesToRefund] of chargeBank.entries()) { + const degItem = degradeableItems.find(i => i.settingsKey === key); + if (!degItem) { + throw new Error(`Invalid degradeable item settings key: ${key}`); + } + + const currentCharges = user.user[degItem.settingsKey]; + const newCharges = currentCharges + chargesToRefund; + + // Prepare result message + const userMessage = `Refunded ${chargesToRefund} charges for ${degItem.item.name}.`; + + // Create result object + const result: RefundResult = { + item: degItem.item, + refundedCharges: chargesToRefund, + totalCharges: newCharges, + userMessage + }; + + // Push result to results array + results.push(result); + + // Update user + await user.update({ + [degItem.settingsKey]: newCharges + }); + } + + return results; +} diff --git a/src/lib/types/minions.ts b/src/lib/types/minions.ts index 13d9532c7c..26d33f24a2 100644 --- a/src/lib/types/minions.ts +++ b/src/lib/types/minions.ts @@ -454,6 +454,9 @@ export interface ColoTaskOptions extends ActivityTaskOptions { diedAt?: number; loot?: ItemBank; maxGlory: number; + scytheCharges: number; + venatorBowCharges: number; + bloodFuryCharges: number; } type UserID = string; diff --git a/src/mahoji/commands/simulate.ts b/src/mahoji/commands/simulate.ts index a55a6f43c3..7ab6de1696 100644 --- a/src/mahoji/commands/simulate.ts +++ b/src/mahoji/commands/simulate.ts @@ -57,7 +57,10 @@ function simulateColosseumRuns(samples = 100) { hasBF: false, hasClaws: true, hasSGS: true, - hasTorture: true + hasTorture: true, + scytheCharges: 300, + venatorBowCharges: 50, + bloodFuryCharges: 0 }); totalDuration += result.realDuration; kcBank.add(result.addedWaveKCBank); diff --git a/src/tasks/minions/colosseumActivity.ts b/src/tasks/minions/colosseumActivity.ts index 8b38486804..bc0da01e0b 100644 --- a/src/tasks/minions/colosseumActivity.ts +++ b/src/tasks/minions/colosseumActivity.ts @@ -4,8 +4,10 @@ import type { ItemBank } from 'oldschooljs/dist/meta/types'; import { resolveItems } from 'oldschooljs/dist/util/util'; import { ColosseumWaveBank, colosseumWaves } from '../../lib/colosseum'; +import { refundChargeBank } from '../../lib/degradeableItems'; import { trackLoot } from '../../lib/lootTrack'; import { incrementMinigameScore } from '../../lib/settings/minigames'; +import { ChargeBank } from '../../lib/structures/Bank'; import type { ColoTaskOptions } from '../../lib/types/minions'; import { handleTripFinish } from '../../lib/util/handleTripFinish'; import { makeBankImage } from '../../lib/util/makeBankImage'; @@ -17,7 +19,16 @@ const sunfireItems = resolveItems(['Sunfire fanatic helm', 'Sunfire fanatic cuir export const colosseumTask: MinionTask = { type: 'Colosseum', async run(data: ColoTaskOptions) { - const { channelID, userID, loot: possibleLoot, diedAt, maxGlory } = data; + const { + channelID, + userID, + loot: possibleLoot, + diedAt, + maxGlory, + scytheCharges, + venatorBowCharges, + bloodFuryCharges + } = data; const user = await mUserFetch(userID); const newKCs = new ColosseumWaveBank(); @@ -33,20 +44,41 @@ export const colosseumTask: MinionTask = { .map(([kc, amount]) => `Wave ${kc}: ${amount} KC`) .join(', ')}`; + let scytheRefund = 0; + let venatorBowRefund = 0; + let bloodFuryRefund = 0; + const newWaveKcStr = !diedAt || diedAt > 1 ? `New wave KCs: ${newKCsStr}.` : 'No new KCs.'; if (diedAt) { const wave = colosseumWaves.find(i => i.waveNumber === diedAt)!; - return handleTripFinish( - user, - channelID, - `${user}, you died on wave ${diedAt} to ${randArrItem([ - ...(wave?.reinforcements ?? []), - ...wave.enemies - ])}, and received no loot. ${newWaveKcStr}`, - undefined, - data, - null - ); + + let str = `${user}, you died on wave ${diedAt} to ${randArrItem([ + ...(wave?.reinforcements ?? []), + ...wave.enemies + ])}, and received no loot. ${newWaveKcStr}`; + + // Calculate refund for unused charges + const completionPercentage = (diedAt - 1) / 12; + if (scytheCharges > 0) scytheRefund = Math.ceil(scytheCharges * (1 - completionPercentage)); + if (venatorBowCharges > 0) venatorBowRefund = Math.ceil(venatorBowCharges * (1 - completionPercentage)); + if (bloodFuryCharges > 0) bloodFuryRefund = Math.ceil(bloodFuryCharges * (1 - completionPercentage)); + + const chargeBank = new ChargeBank(); + if (scytheRefund > 0) chargeBank.add('scythe_of_vitur_charges', scytheRefund); + if (venatorBowRefund > 0) chargeBank.add('venator_bow_charges', venatorBowRefund); + if (bloodFuryRefund > 0) chargeBank.add('blood_fury_charges', bloodFuryRefund); + + if (chargeBank.length() > 0) { + const refundResults = await refundChargeBank(user, chargeBank); + + const refundMessages = refundResults + .map(result => `${result.userMessage} Total charges: ${result.totalCharges}.`) + .join('\n'); + + str += `\n${refundMessages}`; + } + + return handleTripFinish(user, channelID, str, undefined, data, null); } await incrementMinigameScore(user.id, 'colosseum'); diff --git a/tests/unit/snapshots/clsnapshots.test.ts.snap b/tests/unit/snapshots/clsnapshots.test.ts.snap index 1a39a3fc00..8a486bc15e 100644 --- a/tests/unit/snapshots/clsnapshots.test.ts.snap +++ b/tests/unit/snapshots/clsnapshots.test.ts.snap @@ -37,7 +37,7 @@ Elite Treasure Trails (59) Farming (141) Fishing Trawler (4) Forestry (23) -Fortis Colosseum (8) +Fortis Colosseum (9) Fossil Island Notes (10) General Graardor (8) Giant Mole (3) From 80017e44d9a88c9fbdd257a4e5eb41923292c553 Mon Sep 17 00:00:00 2001 From: GC <30398469+gc@users.noreply.github.com> Date: Wed, 24 Jul 2024 19:56:37 +1000 Subject: [PATCH 111/145] Exit handling + test bot status (#5967) --- package.json | 6 ++- src/index.ts | 17 +++++-- src/lib/Task.ts | 5 +- src/lib/analytics.ts | 9 ++-- src/lib/blacklists.ts | 6 +-- src/lib/constants.ts | 3 +- src/lib/crons.ts | 1 - src/lib/events.ts | 2 + src/lib/party.ts | 12 ++--- src/lib/preStartup.ts | 24 ++++----- src/lib/startupScripts.ts | 46 ++---------------- src/lib/tickers.ts | 6 ++- src/lib/util/cachedUserIDs.ts | 6 +-- src/mahoji/commands/admin.ts | 4 +- src/mahoji/lib/events.ts | 92 ++++++++++++++++++++++++++++------- src/mahoji/lib/exitHandler.ts | 21 ++++++++ src/mahoji/lib/postCommand.ts | 3 +- yarn.lock | 24 +++++++-- 18 files changed, 178 insertions(+), 109 deletions(-) create mode 100644 src/mahoji/lib/exitHandler.ts diff --git a/package.json b/package.json index 2cd90d2506..fbd80a360a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "scripts": { - "watch": "nodemon -e ts -w src --exec 'yarn buildandrun'", + "watch": "nodemon --delay 1ms -e ts -w src --exec 'yarn buildandrun'", "build": "tsx ./src/scripts/build.ts", "fix": "tsx ./src/scripts/troubleshooter.ts", "start": "yarn build && node --enable-source-maps dist/", @@ -25,17 +25,19 @@ }, "dependencies": { "@napi-rs/canvas": "^0.1.53", - "@oldschoolgg/toolkit": "git+https://github.com/oldschoolgg/toolkit.git#3550582efbdf04929f0a2c5114ed069a61551807", + "@oldschoolgg/toolkit": "git+https://github.com/oldschoolgg/toolkit.git#2813f25327093fcf2cb12bee7d4c85ce629069a0", "@prisma/client": "^5.17.0", "@sapphire/ratelimits": "^2.4.9", "@sapphire/snowflake": "^3.5.3", "@sapphire/time-utilities": "^1.6.0", + "@sapphire/timer-manager": "^1.0.2", "@sentry/node": "^8.15.0", "ascii-table3": "^0.9.0", "bufferutil": "^4.0.8", "discord.js": "^14.15.3", "dotenv": "^16.4.5", "e": "0.2.33", + "exit-hook": "^4.0.0", "fast-deep-equal": "^3.1.3", "lodash": "^4.17.21", "lru-cache": "^10.3.0", diff --git a/src/index.ts b/src/index.ts index a2d90c39d4..27b6929ead 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,7 +8,7 @@ import { MahojiClient } from '@oldschoolgg/toolkit'; import { init } from '@sentry/node'; import type { TextChannel } from 'discord.js'; import { GatewayIntentBits, Options, Partials } from 'discord.js'; -import { isObject } from 'e'; +import { isObject, noOp } from 'e'; import { SENTRY_DSN, SupportServer } from './config'; import { BLACKLISTED_GUILDS, BLACKLISTED_USERS } from './lib/blacklists'; @@ -16,15 +16,14 @@ import { Channel, Events, gitHash, globalConfig } from './lib/constants'; import { economyLog } from './lib/economyLogs'; import { onMessage } from './lib/events'; import { modalInteractionHook } from './lib/modals'; -import { preStartup } from './lib/preStartup'; import { OldSchoolBotClient } from './lib/structures/OldSchoolBotClient'; -import { runTimedLoggedFn } from './lib/util'; import { CACHED_ACTIVE_USER_IDS } from './lib/util/cachedUserIDs'; import { interactionHook } from './lib/util/globalInteractions'; import { handleInteractionError, interactionReply } from './lib/util/interactionReply'; import { logError } from './lib/util/logError'; import { allCommands } from './mahoji/commands/allCommands'; import { onStartup } from './mahoji/lib/events'; +import { exitCleanup } from './mahoji/lib/exitHandler'; import { postCommand } from './mahoji/lib/postCommand'; import { preCommand } from './mahoji/lib/preCommand'; import { convertMahojiCommandToAbstractCommand } from './mahoji/lib/util'; @@ -194,9 +193,17 @@ client.on('shardError', err => debugLog('Shard Error', { error: err.message })); client.once('ready', () => onStartup()); async function main() { + await Promise.all([ + roboChimpClient.$connect().then(noOp), + prisma.$connect().then(noOp), + import('exit-hook').then(({ asyncExitHook }) => + asyncExitHook(exitCleanup, { + wait: 2000 + }) + ) + ]); if (process.env.TEST) return; - await preStartup(); - await runTimedLoggedFn('Log In', () => client.login(globalConfig.botToken)); + await client.login(globalConfig.botToken); console.log(`Logged in as ${globalClient.user.username}`); } diff --git a/src/lib/Task.ts b/src/lib/Task.ts index c243efe2ee..74492fa491 100644 --- a/src/lib/Task.ts +++ b/src/lib/Task.ts @@ -96,7 +96,6 @@ import { modifyBusyCounter } from './busyCounterCache'; import { minionActivityCache } from './constants'; import { convertStoredActivityToFlatActivity } from './settings/prisma'; import { activitySync, minionActivityCacheDelete } from './settings/settings'; -import { logWrapFn } from './util'; import { logError } from './util/logError'; const tasks: MinionTask[] = [ @@ -219,13 +218,13 @@ export async function processPendingActivities() { } } -export const syncActivityCache = logWrapFn('syncActivityCache', async () => { +export const syncActivityCache = async () => { const tasks = await prisma.activity.findMany({ where: { completed: false } }); minionActivityCache.clear(); for (const task of tasks) { activitySync(task); } -}); +}; const ActivityTaskOptionsSchema = z.object({ userID: z.string(), diff --git a/src/lib/analytics.ts b/src/lib/analytics.ts index 1d3229abf7..ee966f8cb9 100644 --- a/src/lib/analytics.ts +++ b/src/lib/analytics.ts @@ -45,7 +45,7 @@ export async function analyticsTick() { ).map((result: any) => Number.parseInt(result[0].count)) as number[]; const taskCounts = await calculateMinionTaskCounts(); - const currentClientSettings = await await prisma.clientStorage.findFirst({ + const currentClientSettings = await prisma.clientStorage.upsert({ where: { id: globalConfig.clientID }, @@ -64,9 +64,12 @@ export async function analyticsTick() { gp_slots: true, gp_tax_balance: true, economyStats_dailiesAmount: true - } + }, + create: { + id: globalConfig.clientID + }, + update: {} }); - if (!currentClientSettings) throw new Error('No client settings found'); await prisma.analytic.create({ data: { guildsCount: globalClient.guilds.cache.size, diff --git a/src/lib/blacklists.ts b/src/lib/blacklists.ts index 7ae966f68d..278e74d6af 100644 --- a/src/lib/blacklists.ts +++ b/src/lib/blacklists.ts @@ -1,6 +1,6 @@ import { Time } from 'e'; -import { production } from '../config'; +import { TimerManager } from '@sapphire/timer-manager'; export const BLACKLISTED_USERS = new Set(); export const BLACKLISTED_GUILDS = new Set(); @@ -15,6 +15,4 @@ export async function syncBlacklists() { } } -if (production) { - setInterval(syncBlacklists, Time.Minute * 10); -} +TimerManager.setInterval(syncBlacklists, Time.Minute * 10); diff --git a/src/lib/constants.ts b/src/lib/constants.ts index df22a874b6..f7ba4ea7bd 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -1,5 +1,6 @@ import { execSync } from 'node:child_process'; import path from 'node:path'; +import { isMainThread } from 'node:worker_threads'; import type { Image } from '@napi-rs/canvas'; import { PerkTier, SimpleTable, StoreBitfield, dateFm } from '@oldschoolgg/toolkit'; import type { CommandOptions } from '@oldschoolgg/toolkit'; @@ -627,7 +628,7 @@ export const winterTodtPointsTable = new SimpleTable() .add(780) .add(850); -if (!process.env.TEST) { +if (!process.env.TEST && isMainThread) { console.log( `Starting... Git[${gitHash}] ClientID[${globalConfig.clientID}] Production[${globalConfig.isProduction}]` ); diff --git a/src/lib/crons.ts b/src/lib/crons.ts index a5742fef2d..ae104c06c5 100644 --- a/src/lib/crons.ts +++ b/src/lib/crons.ts @@ -37,7 +37,6 @@ GROUP BY item_id;`); * Delete all voice channels */ schedule('0 0 */1 * *', async () => { - debugLog('Cache cleanup cronjob starting'); cacheCleanup(); }); diff --git a/src/lib/events.ts b/src/lib/events.ts index 8b0ba8c14b..7a383eeb4e 100644 --- a/src/lib/events.ts +++ b/src/lib/events.ts @@ -43,6 +43,8 @@ const rareRolesSrc: [string, number, string][] = [ const userCache = new LRUCache({ max: 1000 }); function rareRoles(msg: Message) { + if (!globalConfig.isProduction) return; + if (!msg.guild || msg.guild.id !== SupportServer) { return; } diff --git a/src/lib/party.ts b/src/lib/party.ts index 7e82e6c19f..f1736a5974 100644 --- a/src/lib/party.ts +++ b/src/lib/party.ts @@ -1,10 +1,10 @@ import { makeComponents } from '@oldschoolgg/toolkit'; import { UserError } from '@oldschoolgg/toolkit'; +import { TimerManager } from '@sapphire/timer-manager'; import type { TextChannel } from 'discord.js'; import { ButtonBuilder, ButtonStyle, ComponentType, InteractionCollector } from 'discord.js'; import { Time, debounce, noOp } from 'e'; -import { production } from '../config'; import { BLACKLISTED_USERS } from './blacklists'; import { SILENT_ERROR } from './constants'; import type { MakePartyOptions } from './types'; @@ -12,11 +12,9 @@ import { getUsername } from './util'; import { CACHED_ACTIVE_USER_IDS } from './util/cachedUserIDs'; const partyLockCache = new Set(); -if (production) { - setInterval(() => { - partyLockCache.clear(); - }, Time.Minute * 20); -} +TimerManager.setInterval(() => { + partyLockCache.clear(); +}, Time.Minute * 20); const buttons = [ { @@ -226,7 +224,7 @@ export async function setupParty(channel: TextChannel, leaderUser: MUser, option for (const user of usersWhoConfirmed) { partyLockCache.delete(user); } - setTimeout(() => startTrip(), 250); + TimerManager.setTimeout(() => startTrip(), 250); }); }); diff --git a/src/lib/preStartup.ts b/src/lib/preStartup.ts index 79b604bafb..0b98474cd1 100644 --- a/src/lib/preStartup.ts +++ b/src/lib/preStartup.ts @@ -6,21 +6,21 @@ import { GrandExchange } from './grandExchange'; import { cacheGEPrices } from './marketPrices'; import { populateRoboChimpCache } from './perkTier'; import { runStartupScripts } from './startupScripts'; -import { runTimedLoggedFn } from './util'; +import { logWrapFn } from './util'; import { syncActiveUserIDs } from './util/cachedUserIDs'; import { syncDisabledCommands } from './util/syncDisabledCommands'; -export async function preStartup() { +export const preStartup = logWrapFn('PreStartup', async () => { await Promise.all([ syncActiveUserIDs(), - runTimedLoggedFn('Sync Activity Cache', syncActivityCache), - runTimedLoggedFn('Startup Scripts', runStartupScripts), - runTimedLoggedFn('Sync Disabled Commands', syncDisabledCommands), - runTimedLoggedFn('Sync Blacklist', syncBlacklists), - runTimedLoggedFn('Syncing prices', syncCustomPrices), - runTimedLoggedFn('Caching badges', cacheBadges), - runTimedLoggedFn('Init Grand Exchange', () => GrandExchange.init()), - runTimedLoggedFn('populateRoboChimpCache', populateRoboChimpCache), - runTimedLoggedFn('Cache G.E Prices', cacheGEPrices) + syncActivityCache(), + runStartupScripts(), + syncDisabledCommands(), + syncBlacklists(), + syncCustomPrices(), + cacheBadges(), + GrandExchange.init(), + populateRoboChimpCache(), + cacheGEPrices() ]); -} +}); diff --git a/src/lib/startupScripts.ts b/src/lib/startupScripts.ts index 1c279f0b4a..8a4d1d57be 100644 --- a/src/lib/startupScripts.ts +++ b/src/lib/startupScripts.ts @@ -1,35 +1,8 @@ import { Items } from 'oldschooljs'; +import { globalConfig } from './constants'; const startupScripts: { sql: string; ignoreErrors?: true }[] = []; -const arrayColumns = [ - ['guilds', 'disabledCommands'], - ['guilds', 'staffOnlyChannels'], - ['users', 'badges'], - ['users', 'bitfield'], - ['users', 'favoriteItems'], - ['users', 'favorite_alchables'], - ['users', 'favorite_food'], - ['users', 'favorite_bh_seeds'], - ['users', 'attack_style'], - ['users', 'combat_options'], - ['users', 'slayer.unlocks'], - ['users', 'slayer.blocked_ids'], - ['users', 'slayer.autoslay_options'] -]; - -for (const [table, column] of arrayColumns) { - startupScripts.push({ - sql: `UPDATE "${table}" SET "${column}" = '{}' WHERE "${column}" IS NULL;` - }); - startupScripts.push({ - sql: ` -ALTER TABLE "${table}" - ALTER COLUMN "${column}" SET DEFAULT '{}', - ALTER COLUMN "${column}" SET NOT NULL;` - }); -} - interface CheckConstraint { table: string; column: string; @@ -37,24 +10,12 @@ interface CheckConstraint { body: string; } const checkConstraints: CheckConstraint[] = [ - { - table: 'users', - column: 'lms_points', - name: 'users_lms_points_min', - body: 'lms_points >= 0' - }, { table: 'users', column: '"GP"', name: 'users_gp', body: '"GP" >= 0' }, - { - table: 'users', - column: '"QP"', - name: 'users_qp', - body: '"QP" >= 0' - }, { table: 'ge_listing', column: 'asking_price_per_item', @@ -152,8 +113,9 @@ DO UPDATE SET name = EXCLUDED.name WHERE item_metadata.name IS DISTINCT FROM EXCLUDED.name; `; - -startupScripts.push({ sql: itemMetaDataQuery }); +if (globalConfig.isProduction) { + startupScripts.push({ sql: itemMetaDataQuery }); +} export async function runStartupScripts() { await prisma.$transaction(startupScripts.map(query => prisma.$queryRawUnsafe(query.sql))); diff --git a/src/lib/tickers.ts b/src/lib/tickers.ts index d5555cd497..4c4390c305 100644 --- a/src/lib/tickers.ts +++ b/src/lib/tickers.ts @@ -3,6 +3,7 @@ import type { TextChannel } from 'discord.js'; import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder } from 'discord.js'; import { Time, noOp, randInt, removeFromArr, shuffleArr } from 'e'; +import { TimerManager } from '@sapphire/timer-manager'; import { production } from '../config'; import { userStatsUpdate } from '../mahoji/mahojiSettings'; import { mahojiUserSettingsUpdate } from './MUser'; @@ -398,10 +399,11 @@ export function initTickers() { logError(err); debugLog(`${ticker.name} ticker errored`, { type: 'TICKER' }); } finally { - ticker.timer = setTimeout(fn, ticker.interval); + if (ticker.timer) TimerManager.clearTimeout(ticker.timer); + ticker.timer = TimerManager.setTimeout(fn, ticker.interval); } }; - setTimeout(() => { + ticker.timer = TimerManager.setTimeout(() => { fn(); }, ticker.startupWait ?? 1); } diff --git a/src/lib/util/cachedUserIDs.ts b/src/lib/util/cachedUserIDs.ts index fa757b1e33..4158efe1d5 100644 --- a/src/lib/util/cachedUserIDs.ts +++ b/src/lib/util/cachedUserIDs.ts @@ -4,13 +4,13 @@ import { objectEntries } from 'e'; import { OWNER_IDS, SupportServer } from '../../config'; import { globalConfig } from '../constants'; -import { logWrapFn, runTimedLoggedFn } from '../util'; +import { runTimedLoggedFn } from '../util'; export const CACHED_ACTIVE_USER_IDS = new Set(); CACHED_ACTIVE_USER_IDS.add(globalConfig.clientID); for (const id of OWNER_IDS) CACHED_ACTIVE_USER_IDS.add(id); -export const syncActiveUserIDs = logWrapFn('Sync Active User IDs', async () => { +export const syncActiveUserIDs = async () => { const users = await prisma.$queryRawUnsafe< { user_id: string }[] >(`SELECT DISTINCT(${Prisma.ActivityScalarFieldEnum.user_id}::text) @@ -25,7 +25,7 @@ WHERE perk_tier > 0;`); CACHED_ACTIVE_USER_IDS.add(id); } debugLog(`${CACHED_ACTIVE_USER_IDS.size} cached active user IDs`); -}); +}; export function memoryAnalysis() { const guilds = globalClient.guilds.cache.size; diff --git a/src/mahoji/commands/admin.ts b/src/mahoji/commands/admin.ts index 8626813376..64af8ef97a 100644 --- a/src/mahoji/commands/admin.ts +++ b/src/mahoji/commands/admin.ts @@ -883,7 +883,7 @@ export const adminCommand: OSBMahojiCommand = { ${META_CONSTANTS.RENDERED_STR}` }).catch(noOp); - process.exit(1); + import('exit-hook').then(({ gracefulExit }) => gracefulExit(1)); } if (options.shut_down) { debugLog('SHUTTING DOWN'); @@ -899,7 +899,7 @@ ${META_CONSTANTS.RENDERED_STR}` ${META_CONSTANTS.RENDERED_STR}` }).catch(noOp); - process.exit(0); + import('exit-hook').then(({ gracefulExit }) => gracefulExit(0)); } if (options.sync_blacklist) { diff --git a/src/mahoji/lib/events.ts b/src/mahoji/lib/events.ts index 05fd050229..556b4aeffc 100644 --- a/src/mahoji/lib/events.ts +++ b/src/mahoji/lib/events.ts @@ -1,6 +1,7 @@ import type { ItemBank } from 'oldschooljs/dist/meta/types'; import { bulkUpdateCommands } from '@oldschoolgg/toolkit'; +import { ActivityType, bold, time } from 'discord.js'; import { Channel, META_CONSTANTS, globalConfig } from '../../lib/constants'; import { initCrons } from '../../lib/crons'; import { initTickers } from '../../lib/tickers'; @@ -9,6 +10,60 @@ import { mahojiClientSettingsFetch } from '../../lib/util/clientSettings'; import { sendToChannelID } from '../../lib/util/webhook'; import { CUSTOM_PRICE_CACHE } from '../commands/sell'; +export async function updateTestBotStatus(online = true) { + try { + if (globalConfig.isProduction) return; + const idMap: Record = { + '829398443821891634': '1265571664142270464', + '577488230539067403': '1265582554644217977', + '353484579840983042': '1265582554644217977', + '897549995446779964': '1265582743970910259', + '1158785741028081696': '1265583194108067925' + }; + const catChannelID = idMap[globalConfig.clientID]; + if (!catChannelID) return; + const cat = await globalClient.channels.fetch(catChannelID); + if (!cat || !cat.isTextBased() || cat.isDMBased()) { + console.log('Could not find status channel'); + return; + } + + const emoji = online ? '🟢' : '🔴'; + let text = ''; + if (online) { + text = `${emoji} ${globalClient.user.username} is ONLINE ${emoji} + +Turned on ${time(new Date(), 'R')}`; + text = bold(text); + } else { + text = `${emoji} ${globalClient.user.username} is offline ${emoji} + +Turned off ${time(new Date(), 'R')}`; + } + const message = await cat.messages + .fetch({ limit: 5 }) + .then(messages => messages.filter(m => m.author.id === globalClient.user!.id)) + .then(msg => msg.first()); + if (!message) { + await cat.send(text); + } else { + await message.edit(text); + } + if (online) { + await globalClient.user.setPresence({ + status: 'online', + activities: [ + { + name: `${emoji} ONLINE`, + type: ActivityType.Custom + } + ] + }); + } + } catch (err) { + console.error(err); + } +} export async function syncCustomPrices() { const clientData = await mahojiClientSettingsFetch({ custom_prices: true }); for (const [key, value] of Object.entries(clientData.custom_prices as ItemBank)) { @@ -17,26 +72,29 @@ export async function syncCustomPrices() { } export const onStartup = logWrapFn('onStartup', async () => { - globalClient.application.commands.fetch({ - guildId: globalConfig.isProduction ? undefined : globalConfig.testingServerID - }); - if (!globalConfig.isProduction) { - console.log('Syncing commands locally...'); - await bulkUpdateCommands({ - client: globalClient.mahojiClient, - commands: Array.from(globalClient.mahojiClient.commands.values()), - guildID: globalConfig.testingServerID - }); - } + const syncTestBotCommands = globalConfig.isProduction + ? null + : bulkUpdateCommands({ + client: globalClient.mahojiClient, + commands: Array.from(globalClient.mahojiClient.commands.values()), + guildID: globalConfig.testingServerID + }); initCrons(); initTickers(); - if (globalConfig.isProduction) { - sendToChannelID(Channel.GeneralChannel, { - content: `I have just turned on! + const sendStartupMessage = globalConfig.isProduction + ? sendToChannelID(Channel.GeneralChannel, { + content: `I have just turned on!\n\n${META_CONSTANTS.RENDERED_STR}` + }).catch(console.error) + : null; -${META_CONSTANTS.RENDERED_STR}` - }).catch(console.error); - } + await Promise.all([ + globalClient.application.commands.fetch({ + guildId: globalConfig.isProduction ? undefined : globalConfig.testingServerID + }), + updateTestBotStatus(), + sendStartupMessage, + syncTestBotCommands + ]); }); diff --git a/src/mahoji/lib/exitHandler.ts b/src/mahoji/lib/exitHandler.ts new file mode 100644 index 0000000000..14324c629b --- /dev/null +++ b/src/mahoji/lib/exitHandler.ts @@ -0,0 +1,21 @@ +import { TimerManager } from '@sapphire/timer-manager'; + +import { updateTestBotStatus } from './events'; + +export async function exitCleanup() { + try { + globalClient.isShuttingDown = true; + console.log('Cleaning up and exiting...'); + TimerManager.destroy(); + await updateTestBotStatus(false); + await Promise.all([ + globalClient.destroy(), + prisma.$disconnect(), + redis.disconnect(), + roboChimpClient.$disconnect() + ]); + console.log('\nCleaned up and exited.'); + } catch (err) { + console.error(err); + } +} diff --git a/src/mahoji/lib/postCommand.ts b/src/mahoji/lib/postCommand.ts index cc1c53b674..eceb159855 100644 --- a/src/mahoji/lib/postCommand.ts +++ b/src/mahoji/lib/postCommand.ts @@ -3,6 +3,7 @@ import type { CommandOptions } from '@oldschoolgg/toolkit'; import { modifyBusyCounter } from '../../lib/busyCounterCache'; import { busyImmuneCommands, shouldTrackCommand } from '../../lib/constants'; +import { TimerManager } from '@sapphire/timer-manager'; import { makeCommandUsage } from '../../lib/util/commandUsage'; import { logError } from '../../lib/util/logError'; import type { AbstractCommand } from './inhibitors'; @@ -28,7 +29,7 @@ export async function postCommand({ continueDeltaMillis: number | null; }): Promise { if (!busyImmuneCommands.includes(abstractCommand.name)) { - setTimeout(() => modifyBusyCounter(userID, -1), 1000); + TimerManager.setTimeout(() => modifyBusyCounter(userID, -1), 1000); } if (shouldTrackCommand(abstractCommand, args)) { const commandUsage = makeCommandUsage({ diff --git a/yarn.lock b/yarn.lock index 7e6345566a..be7b9c09c5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -646,9 +646,9 @@ __metadata: languageName: node linkType: hard -"@oldschoolgg/toolkit@git+https://github.com/oldschoolgg/toolkit.git#3550582efbdf04929f0a2c5114ed069a61551807": +"@oldschoolgg/toolkit@git+https://github.com/oldschoolgg/toolkit.git#2813f25327093fcf2cb12bee7d4c85ce629069a0": version: 0.0.24 - resolution: "@oldschoolgg/toolkit@https://github.com/oldschoolgg/toolkit.git#commit=3550582efbdf04929f0a2c5114ed069a61551807" + resolution: "@oldschoolgg/toolkit@https://github.com/oldschoolgg/toolkit.git#commit=2813f25327093fcf2cb12bee7d4c85ce629069a0" dependencies: decimal.js: "npm:^10.4.3" deep-object-diff: "npm:^1.1.9" @@ -664,7 +664,7 @@ __metadata: peerDependencies: discord.js: ^14.15.3 oldschooljs: ^2.5.9 - checksum: 10c0/8a968e143bc34c38d15ab29f1ecde613340e58c93341d51368651c6af52228652449be13a5a1c299bc836e0ff9a5a9c7c6a104198096d73345dd3601e06655ae + checksum: 10c0/c83f2188e18ac1e7d79edd9ab06b7ab0f96f8774a404a26fd766d22606bd65a8def0c0a74d0f681c5bde0d7b5b5bbb16f829e751b4e75f6821a1baf5c62b2580 languageName: node linkType: hard @@ -1217,6 +1217,13 @@ __metadata: languageName: node linkType: hard +"@sapphire/timer-manager@npm:^1.0.2": + version: 1.0.2 + resolution: "@sapphire/timer-manager@npm:1.0.2" + checksum: 10c0/0c9fee9a94b5927020aa98dac0fbe7ab903fc8b028383ae28c625f262eec4b65973b5ba5a17d329f72e1abf2092e7655647517d8818378781d5b790cb8b2e62a + languageName: node + linkType: hard + "@sapphire/utilities@npm:^3.3.0": version: 3.3.0 resolution: "@sapphire/utilities@npm:3.3.0" @@ -2412,6 +2419,13 @@ __metadata: languageName: node linkType: hard +"exit-hook@npm:^4.0.0": + version: 4.0.0 + resolution: "exit-hook@npm:4.0.0" + checksum: 10c0/7fb33eaeb9050aee9479da9c93d42b796fb409c40e1d2b6ea2f40786ae7d7db6dc6a0f6ecc7bc24e479f957b7844bcb880044ded73320334743c64e3ecef48d7 + languageName: node + linkType: hard + "exponential-backoff@npm:^3.1.1": version: 3.1.1 resolution: "exponential-backoff@npm:3.1.1" @@ -4099,11 +4113,12 @@ __metadata: dependencies: "@biomejs/biome": "npm:^1.8.3" "@napi-rs/canvas": "npm:^0.1.53" - "@oldschoolgg/toolkit": "git+https://github.com/oldschoolgg/toolkit.git#3550582efbdf04929f0a2c5114ed069a61551807" + "@oldschoolgg/toolkit": "git+https://github.com/oldschoolgg/toolkit.git#2813f25327093fcf2cb12bee7d4c85ce629069a0" "@prisma/client": "npm:^5.17.0" "@sapphire/ratelimits": "npm:^2.4.9" "@sapphire/snowflake": "npm:^3.5.3" "@sapphire/time-utilities": "npm:^1.6.0" + "@sapphire/timer-manager": "npm:^1.0.2" "@sentry/node": "npm:^8.15.0" "@types/lodash": "npm:^4.14.195" "@types/node": "npm:^20.14.9" @@ -4118,6 +4133,7 @@ __metadata: dpdm: "npm:^3.14.0" e: "npm:0.2.33" esbuild: "npm:0.21.5" + exit-hook: "npm:^4.0.0" fast-deep-equal: "npm:^3.1.3" fast-glob: "npm:^3.3.2" lodash: "npm:^4.17.21" From d0e19ec01523e9e568fccf3bca3652f770df03e2 Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Wed, 24 Jul 2024 20:09:33 +1000 Subject: [PATCH 112/145] Redis test changes --- tests/integration/redis.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/integration/redis.test.ts b/tests/integration/redis.test.ts index ead9262f7e..7584d1bd67 100644 --- a/tests/integration/redis.test.ts +++ b/tests/integration/redis.test.ts @@ -11,7 +11,7 @@ function makeSender() { return new TSRedis({ mocked: !globalConfig.redisPort, port: globalConfig.redisPort }); } -test.concurrent('Should add patron badge', async () => { +test('Should add patron badge', async () => { const user = await createTestUser(); expect(user.user.badges).not.includes(BadgesEnum.Patron); const _redis = makeSender(); @@ -27,7 +27,7 @@ test.concurrent('Should add patron badge', async () => { expect(user.user.badges).includes(BadgesEnum.Patron); }); -test.concurrent('Should remove patron badge', async () => { +test('Should remove patron badge', async () => { const user = await createTestUser(undefined, { badges: [BadgesEnum.Patron] }); expect(user.user.badges).includes(BadgesEnum.Patron); const _redis = makeSender(); @@ -43,7 +43,7 @@ test.concurrent('Should remove patron badge', async () => { expect(user.user.badges).not.includes(BadgesEnum.Patron); }); -test.concurrent('Should add to cache', async () => { +test('Should add to cache', async () => { const users = [await createTestUser(), await createTestUser(), await createTestUser()]; await roboChimpClient.user.createMany({ data: users.map(u => ({ @@ -67,7 +67,7 @@ test.concurrent('Should add to cache', async () => { } }); -test.concurrent('Should remove from cache', async () => { +test('Should remove from cache', async () => { const users = [await createTestUser(), await createTestUser(), await createTestUser()]; await roboChimpClient.user.createMany({ data: users.map(u => ({ @@ -91,7 +91,7 @@ test.concurrent('Should remove from cache', async () => { } }); -test.concurrent('Should recognize special bitfields', async () => { +test('Should recognize special bitfields', async () => { const users = [ await createTestUser(undefined, { bitfield: [BitField.HasPermanentTierOne] }), await createTestUser(undefined, { bitfield: [BitField.BothBotsMaxedFreeTierOnePerks] }) @@ -101,7 +101,7 @@ test.concurrent('Should recognize special bitfields', async () => { } }); -test.concurrent('Should sdffsddfss', async () => { +test('Should sdffsddfss', async () => { const user = await createTestUser(); roboChimpCache.set(user.id, { perk_tier: 5 } as any); expect(getUsersPerkTier(user)).toEqual(5); From 13821d913b6d590fc465af39302e6a4aced6a1f1 Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Mon, 29 Jul 2024 13:52:59 +1000 Subject: [PATCH 113/145] Remove wiki/contributor bitfields --- src/lib/constants.ts | 4 ---- src/lib/perkTiers.ts | 6 +----- src/lib/tickers.ts | 1 - src/mahoji/commands/rp.ts | 7 ++----- src/mahoji/lib/abstracted_commands/ironmanCommand.ts | 2 -- src/mahoji/lib/inhibitors.ts | 3 +-- 6 files changed, 4 insertions(+), 19 deletions(-) diff --git a/src/lib/constants.ts b/src/lib/constants.ts index f7ba4ea7bd..c3732622d0 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -198,7 +198,6 @@ export enum BitField { IsPatronTier4 = 5, IsPatronTier5 = 6, isModerator = 7, - isContributor = 8, BypassAgeRestriction = 9, HasHosidiusWallkit = 10, HasPermanentEventBackgrounds = 11, @@ -209,7 +208,6 @@ export enum BitField { HasDexScroll = 16, HasArcaneScroll = 17, HasTornPrayerScroll = 18, - IsWikiContributor = 19, HasSlepeyTablet = 20, IsPatronTier6 = 21, DisableBirdhouseRunButton = 22, @@ -245,9 +243,7 @@ interface BitFieldData { } export const BitFieldData: Record = { - [BitField.IsWikiContributor]: { name: 'Wiki Contributor', protected: true, userConfigurable: false }, [BitField.isModerator]: { name: 'Moderator', protected: true, userConfigurable: false }, - [BitField.isContributor]: { name: 'Contributor', protected: true, userConfigurable: false }, [BitField.HasPermanentTierOne]: { name: 'Permanent Tier 1', protected: false, userConfigurable: false }, [BitField.IsPatronTier1]: { name: 'Tier 1 Patron', protected: false, userConfigurable: false }, diff --git a/src/lib/perkTiers.ts b/src/lib/perkTiers.ts index 025b1814af..e25e2db5bb 100644 --- a/src/lib/perkTiers.ts +++ b/src/lib/perkTiers.ts @@ -14,11 +14,7 @@ export const allPerkBitfields: BitField[] = [ ]; export function getUsersPerkTier(user: MUser): PerkTier | 0 { - if ( - [BitField.isContributor, BitField.isModerator, BitField.IsWikiContributor].some(bit => - user.bitfield.includes(bit) - ) - ) { + if ([BitField.isModerator].some(bit => user.bitfield.includes(bit))) { return PerkTier.Four; } diff --git a/src/lib/tickers.ts b/src/lib/tickers.ts index 4c4390c305..d94ad99023 100644 --- a/src/lib/tickers.ts +++ b/src/lib/tickers.ts @@ -222,7 +222,6 @@ WHERE bitfield && '{2,3,4,5,6,7,8,12,21,24}'::int[] AND user_stats."last_daily_t BitField.IsPatronTier4, BitField.IsPatronTier5, BitField.IsPatronTier6, - BitField.isContributor, BitField.isModerator ] } diff --git a/src/mahoji/commands/rp.ts b/src/mahoji/commands/rp.ts index b6499664b8..99f8533f3f 100644 --- a/src/mahoji/commands/rp.ts +++ b/src/mahoji/commands/rp.ts @@ -138,7 +138,7 @@ async function usernameSync() { function isProtectedAccount(user: MUser) { const botAccounts = ['303730326692429825', '729244028989603850', '969542224058654790']; if ([...ADMIN_IDS, ...OWNER_IDS, ...botAccounts].includes(user.id)) return true; - if ([BitField.isModerator, BitField.isContributor].some(bf => user.bitfield.includes(bf))) return true; + if ([BitField.isModerator].some(bf => user.bitfield.includes(bf))) return true; return false; } @@ -564,11 +564,8 @@ export const rpCommand: OSBMahojiCommand = { const isOwner = OWNER_IDS.includes(userID.toString()); const isAdmin = ADMIN_IDS.includes(userID); const isMod = isOwner || isAdmin || adminUser.bitfield.includes(BitField.isModerator); - const isTrusted = [BitField.IsWikiContributor, BitField.isContributor].some(bit => - adminUser.bitfield.includes(bit) - ); if (!guildID || (production && guildID.toString() !== SupportServer)) return randArrItem(gifs); - if (!isAdmin && !isMod && !isTrusted) return randArrItem(gifs); + if (!isAdmin && !isMod) return randArrItem(gifs); if (options.user_event) { const messageId = diff --git a/src/mahoji/lib/abstracted_commands/ironmanCommand.ts b/src/mahoji/lib/abstracted_commands/ironmanCommand.ts index a2d779c4c9..a1013f73b0 100644 --- a/src/mahoji/lib/abstracted_commands/ironmanCommand.ts +++ b/src/mahoji/lib/abstracted_commands/ironmanCommand.ts @@ -127,13 +127,11 @@ After becoming an ironman: BitField.IsPatronTier4, BitField.IsPatronTier5, BitField.isModerator, - BitField.isContributor, BitField.BypassAgeRestriction, BitField.HasPermanentEventBackgrounds, BitField.HasPermanentTierOne, BitField.DisabledRandomEvents, BitField.AlwaysSmallBank, - BitField.IsWikiContributor, BitField.IsPatronTier6 ]; diff --git a/src/mahoji/lib/inhibitors.ts b/src/mahoji/lib/inhibitors.ts index 19da2ee254..2f3685e47f 100644 --- a/src/mahoji/lib/inhibitors.ts +++ b/src/mahoji/lib/inhibitors.ts @@ -144,8 +144,7 @@ const inhibitors: Inhibitor[] = [ // Allow contributors + moderators to use disabled channels in SupportServer const userBitfield = user.bitfield; - const isStaff = - userBitfield.includes(BitField.isModerator) || userBitfield.includes(BitField.isContributor); + const isStaff = userBitfield.includes(BitField.isModerator); if (guild.id === SupportServer && isStaff) { return false; } From 262a53de1b528dcd3bcbe3890b783e0cdbdc964c Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Tue, 30 Jul 2024 14:12:59 +1000 Subject: [PATCH 114/145] Remove unneeded date cast on raw sql --- src/mahoji/commands/config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mahoji/commands/config.ts b/src/mahoji/commands/config.ts index 5be1c602b3..7573ec1892 100644 --- a/src/mahoji/commands/config.ts +++ b/src/mahoji/commands/config.ts @@ -1072,7 +1072,7 @@ export const configCommand: OSBMahojiCommand = { >(` SELECT DISTINCT ON (activity.type) activity.type, activity.data, activity.id, activity.finish_date FROM activity -WHERE finish_date::date > now() - INTERVAL '31 days' +WHERE finish_date > now() - INTERVAL '14 days' AND user_id = '${user.id}'::bigint ORDER BY activity.type, finish_date DESC LIMIT 20; From e435d7ee845518fca3fa6c55363a86b443f80e85 Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Tue, 30 Jul 2024 14:14:58 +1000 Subject: [PATCH 115/145] Prevent errors on pin trip --- src/mahoji/commands/config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/mahoji/commands/config.ts b/src/mahoji/commands/config.ts index 7573ec1892..9a79f21fd9 100644 --- a/src/mahoji/commands/config.ts +++ b/src/mahoji/commands/config.ts @@ -638,6 +638,7 @@ export async function pinTripCommand( ) { if (!tripId) return 'Invalid trip.'; const id = Number(tripId); + if (!id || Number.isNaN(id)) return 'Invalid trip.'; const trip = await prisma.activity.findFirst({ where: { id, user_id: BigInt(user.id) } }); if (!trip) return 'Invalid trip.'; From 6e9a36af330e92bfae2be8b01dad0071867e0080 Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Tue, 30 Jul 2024 14:18:09 +1000 Subject: [PATCH 116/145] Dont queue sendToChannelID anymore --- src/lib/util/webhook.ts | 62 ++++++++++++++++------------------- vitest.integration.config.mts | 3 +- 2 files changed, 29 insertions(+), 36 deletions(-) diff --git a/src/lib/util/webhook.ts b/src/lib/util/webhook.ts index 1582226f02..165d4d7488 100644 --- a/src/lib/util/webhook.ts +++ b/src/lib/util/webhook.ts @@ -1,7 +1,6 @@ import { channelIsSendable, splitMessage } from '@oldschoolgg/toolkit'; import type { AttachmentBuilder, BaseMessageOptions, EmbedBuilder, Message } from 'discord.js'; import { PartialGroupDMChannel, PermissionsBitField, WebhookClient } from 'discord.js'; -import PQueue from 'p-queue'; import { production } from '../../config'; import { logError } from './logError'; @@ -43,8 +42,6 @@ async function deleteWebhook(channelID: string) { await prisma.webhook.delete({ where: { channel_id: channelID } }); } -const queue = new PQueue({ concurrency: 10 }); - export async function sendToChannelID( channelID: string, data: { @@ -59,37 +56,14 @@ export async function sendToChannelID( const allowedMentions = data.allowedMentions ?? { parse: ['users'] }; - async function queuedFn() { - const channel = await resolveChannel(channelID); - if (!channel) return; + const channel = await resolveChannel(channelID); + if (!channel) return; - const files = data.image ? [data.image] : data.files; - const embeds = []; - if (data.embed) embeds.push(data.embed); - if (channel instanceof WebhookClient) { - try { - await sendToChannelOrWebhook(channel, { - content: data.content, - files, - embeds, - components: data.components, - allowedMentions - }); - } catch (err: any) { - const error = err as Error; - if (error.message === 'Unknown Webhook') { - await deleteWebhook(channelID); - await sendToChannelID(channelID, data); - } else { - logError(error, { - content: data.content ?? 'None', - channelID - }); - } - } finally { - channel.destroy(); - } - } else { + const files = data.image ? [data.image] : data.files; + const embeds = []; + if (data.embed) embeds.push(data.embed); + if (channel instanceof WebhookClient) { + try { await sendToChannelOrWebhook(channel, { content: data.content, files, @@ -97,9 +71,29 @@ export async function sendToChannelID( components: data.components, allowedMentions }); + } catch (err: any) { + const error = err as Error; + if (error.message === 'Unknown Webhook') { + await deleteWebhook(channelID); + await sendToChannelID(channelID, data); + } else { + logError(error, { + content: data.content ?? 'None', + channelID + }); + } + } finally { + channel.destroy(); } + } else { + await sendToChannelOrWebhook(channel, { + content: data.content, + files, + embeds, + components: data.components, + allowedMentions + }); } - return queue.add(queuedFn); } async function sendToChannelOrWebhook(channel: WebhookClient | Message['channel'], input: BaseMessageOptions) { diff --git a/vitest.integration.config.mts b/vitest.integration.config.mts index 46b6b535fe..91dc5c1bc9 100644 --- a/vitest.integration.config.mts +++ b/vitest.integration.config.mts @@ -7,8 +7,7 @@ export default defineConfig({ setupFiles: 'tests/integration/setup.ts', coverage: { provider: 'v8', - reporter: 'text', - include: ['src/lib/MUser.ts'] + reporter: 'text' }, testTimeout: 30_000, bail: 1, From 08d1dde2e5c465849b780b620e31f36bfc6e95fc Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Tue, 30 Jul 2024 15:00:20 +1000 Subject: [PATCH 117/145] Add add/remove item functions to startup scripts --- src/lib/startupScripts.ts | 52 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/src/lib/startupScripts.ts b/src/lib/startupScripts.ts index 8a4d1d57be..cc554d9601 100644 --- a/src/lib/startupScripts.ts +++ b/src/lib/startupScripts.ts @@ -3,6 +3,58 @@ import { globalConfig } from './constants'; const startupScripts: { sql: string; ignoreErrors?: true }[] = []; +startupScripts.push({ + sql: `CREATE OR REPLACE FUNCTION add_item_to_bank( + bank JSONB, + key TEXT, + quantity INT +) RETURNS JSONB LANGUAGE plpgsql AS $$ +BEGIN + RETURN ( + CASE + WHEN bank ? key THEN + jsonb_set( + bank, + ARRAY[key], + to_jsonb((bank->>key)::INT + quantity) + ) + ELSE + jsonb_set( + bank, + ARRAY[key], + to_jsonb(quantity) + ) + END + ); +END; +$$; + +CREATE OR REPLACE FUNCTION remove_item_from_bank( + bank JSONB, + key TEXT, + quantity INT +) RETURNS JSONB LANGUAGE plpgsql AS $$ +DECLARE + current_value INT; +BEGIN + IF bank ? key THEN + current_value := (bank->>key)::INT - quantity; + IF current_value > 0 THEN + RETURN jsonb_set( + bank, + ARRAY[key], + to_jsonb(current_value) + ); + ELSE + RETURN bank - key; + END IF; + ELSE + RETURN bank; + END IF; +END; +$$;` +}); + interface CheckConstraint { table: string; column: string; From 852950c944ebf46d2f767351a554c8bcdc1ec5bc Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Tue, 30 Jul 2024 15:31:21 +1000 Subject: [PATCH 118/145] Fix startup scripts --- src/lib/startupScripts.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/lib/startupScripts.ts b/src/lib/startupScripts.ts index cc554d9601..fab5ab93fe 100644 --- a/src/lib/startupScripts.ts +++ b/src/lib/startupScripts.ts @@ -27,9 +27,11 @@ BEGIN END ); END; -$$; +$$;` +}); -CREATE OR REPLACE FUNCTION remove_item_from_bank( +startupScripts.push({ + sql: `CREATE OR REPLACE FUNCTION remove_item_from_bank( bank JSONB, key TEXT, quantity INT From 44b316750caa3152c6671fc297d54b63031995cb Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Tue, 30 Jul 2024 15:31:36 +1000 Subject: [PATCH 119/145] Fix preStartup --- src/index.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/index.ts b/src/index.ts index 27b6929ead..e9cce8683f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,7 +8,7 @@ import { MahojiClient } from '@oldschoolgg/toolkit'; import { init } from '@sentry/node'; import type { TextChannel } from 'discord.js'; import { GatewayIntentBits, Options, Partials } from 'discord.js'; -import { isObject, noOp } from 'e'; +import { isObject } from 'e'; import { SENTRY_DSN, SupportServer } from './config'; import { BLACKLISTED_GUILDS, BLACKLISTED_USERS } from './lib/blacklists'; @@ -16,6 +16,7 @@ import { Channel, Events, gitHash, globalConfig } from './lib/constants'; import { economyLog } from './lib/economyLogs'; import { onMessage } from './lib/events'; import { modalInteractionHook } from './lib/modals'; +import { preStartup } from './lib/preStartup'; import { OldSchoolBotClient } from './lib/structures/OldSchoolBotClient'; import { CACHED_ACTIVE_USER_IDS } from './lib/util/cachedUserIDs'; import { interactionHook } from './lib/util/globalInteractions'; @@ -194,8 +195,7 @@ client.once('ready', () => onStartup()); async function main() { await Promise.all([ - roboChimpClient.$connect().then(noOp), - prisma.$connect().then(noOp), + preStartup(), import('exit-hook').then(({ asyncExitHook }) => asyncExitHook(exitCleanup, { wait: 2000 From 0331c451f18384fe356a0ad047e8add242ca78aa Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Thu, 1 Aug 2024 01:49:38 +1000 Subject: [PATCH 120/145] Add cl_array column --- prisma/schema.prisma | 1 + 1 file changed, 1 insertion(+) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e25b832224..c16671d51a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -303,6 +303,7 @@ model User { QP Int @default(0) bank Json @default("{}") @db.Json collectionLogBank Json @default("{}") @db.JsonB + cl_array Int[] @default([]) blowpipe Json @default("{\"scales\":0,\"dartID\":null,\"dartQuantity\":0}") @db.Json slayer_unlocks Int[] @default([]) @map("slayer.unlocks") slayer_blocked_ids Int[] @default([]) @map("slayer.blocked_ids") From 5761c8f316cf72e574cf1cebc372b74a79ca5998 Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Thu, 1 Aug 2024 02:02:03 +1000 Subject: [PATCH 121/145] Test fixes --- src/lib/util/clLeaderboard.ts | 6 +-- tests/integration/clArrayUpdate.test.ts | 61 +++++++++++-------------- 2 files changed, 30 insertions(+), 37 deletions(-) diff --git a/src/lib/util/clLeaderboard.ts b/src/lib/util/clLeaderboard.ts index 43def04ef5..da506b3fd7 100644 --- a/src/lib/util/clLeaderboard.ts +++ b/src/lib/util/clLeaderboard.ts @@ -22,7 +22,7 @@ export async function fetchCLLeaderboard({ const specificUsers = userIds.length > 0 ? await prisma.$queryRawUnsafe<{ id: string; qty: number }[]>(` - SELECT user_id::text AS id, CARDINALITY(cl_array) - CARDINALITY(cl_array - array[${items + SELECT user_id::text AS id, CARDINALITY(s.cl_array) - CARDINALITY(s.cl_array - array[${items .map(i => `${i}`) .join(', ')}]) AS qty FROM user_stats s INNER JOIN users u ON s.user_id::text = u.id @@ -30,12 +30,12 @@ export async function fetchCLLeaderboard({ `) : []; const generalUsers = await prisma.$queryRawUnsafe<{ id: string; qty: number }[]>(` - SELECT user_id::text AS id, CARDINALITY(cl_array) - CARDINALITY(cl_array - array[${items + SELECT user_id::text AS id, CARDINALITY(user_stats.cl_array) - CARDINALITY(user_stats.cl_array - array[${items .map(i => `${i}`) .join(', ')}]) AS qty FROM user_stats ${ironmenOnly ? 'INNER JOIN "users" on "users"."id" = "user_stats"."user_id"::text' : ''} - WHERE (cl_array && array[${items.map(i => `${i}`).join(', ')}] + WHERE (user_stats.cl_array && array[${items.map(i => `${i}`).join(', ')}] ${ironmenOnly ? 'AND "users"."minion.ironman" = true' : ''}) ${userIds.length > 0 ? `AND user_id::text NOT IN (${userIdsList})` : ''} ORDER BY qty DESC diff --git a/tests/integration/clArrayUpdate.test.ts b/tests/integration/clArrayUpdate.test.ts index 9c190aca5a..15f0917f16 100644 --- a/tests/integration/clArrayUpdate.test.ts +++ b/tests/integration/clArrayUpdate.test.ts @@ -1,4 +1,3 @@ -import { Time } from 'e'; import { expect, test } from 'vitest'; import { Bank } from 'oldschooljs'; @@ -6,39 +5,33 @@ import { itemID } from 'oldschooljs/dist/util'; import { roboChimpSyncData } from '../../src/lib/roboChimp'; import { createTestUser } from './util'; -test( - 'All Commands Base Test', - async () => { - const user = await createTestUser(); - await user.addItemsToBank({ items: new Bank().add('Coal', 100) }); - await roboChimpSyncData(user); - expect(user.fetchStats({ cl_array: true, cl_array_length: true })).resolves.toMatchObject({ - cl_array: [], - cl_array_length: 0 - }); +test('CL Updates', async () => { + const user = await createTestUser(); + await user.addItemsToBank({ items: new Bank().add('Coal', 100) }); + await roboChimpSyncData(user); + expect(await user.fetchStats({ cl_array: true, cl_array_length: true })).toMatchObject({ + cl_array: [], + cl_array_length: 0 + }); - await user.addItemsToBank({ items: new Bank().add('Egg', 100), collectionLog: true }); - await roboChimpSyncData(user); - expect(user.fetchStats({ cl_array: true, cl_array_length: true })).resolves.toMatchObject({ - cl_array: [itemID('Egg')], - cl_array_length: 1 - }); + await user.addItemsToBank({ items: new Bank().add('Egg', 100), collectionLog: true }); + await roboChimpSyncData(user); + expect(await user.fetchStats({ cl_array: true, cl_array_length: true })).toMatchObject({ + cl_array: [itemID('Egg')], + cl_array_length: 1 + }); - await user.addItemsToBank({ items: new Bank().add('Egg', 100), collectionLog: true }); - await roboChimpSyncData(user); - expect(user.fetchStats({ cl_array: true, cl_array_length: true })).resolves.toMatchObject({ - cl_array: [itemID('Egg')], - cl_array_length: 1 - }); + await user.addItemsToBank({ items: new Bank().add('Egg', 100), collectionLog: true }); + await roboChimpSyncData(user); + expect(await user.fetchStats({ cl_array: true, cl_array_length: true })).toMatchObject({ + cl_array: [itemID('Egg')], + cl_array_length: 1 + }); - await user.addItemsToBank({ items: new Bank().add('Trout', 100), collectionLog: true }); - await roboChimpSyncData(user); - expect(user.fetchStats({ cl_array: true, cl_array_length: true })).resolves.toMatchObject({ - cl_array: [itemID('Trout'), itemID('Egg')], - cl_array_length: 2 - }); - }, - { - timeout: Time.Minute * 10 - } -); + await user.addItemsToBank({ items: new Bank().add('Trout', 100), collectionLog: true }); + await roboChimpSyncData(user); + expect(await user.fetchStats({ cl_array: true, cl_array_length: true })).toMatchObject({ + cl_array: [itemID('Trout'), itemID('Egg')], + cl_array_length: 2 + }); +}); From ad6d6d41f0d1f152a14783210e31c80997ccbd9e Mon Sep 17 00:00:00 2001 From: GC <30398469+gc@users.noreply.github.com> Date: Fri, 2 Aug 2024 01:50:28 +1000 Subject: [PATCH 122/145] Roles task rewrite (#5982) --- prisma/schema.prisma | 6 +- src/lib/constants.ts | 8 +- src/lib/handleNewCLItems.ts | 22 +- src/lib/preStartup.ts | 5 +- src/lib/rawSql.ts | 16 + src/lib/rolesTask.ts | 767 ++++++++++------------- src/lib/util/addSubTaskToActivityTask.ts | 28 +- src/lib/util/clLeaderboard.ts | 140 +++-- src/mahoji/commands/leaderboard.ts | 14 +- src/mahoji/commands/rp.ts | 148 ++--- tests/integration/misc.test.ts | 31 +- tests/integration/rolesTask.test.ts | 2 +- 12 files changed, 518 insertions(+), 669 deletions(-) create mode 100644 src/lib/rawSql.ts diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c16671d51a..0c894e7942 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -303,7 +303,6 @@ model User { QP Int @default(0) bank Json @default("{}") @db.Json collectionLogBank Json @default("{}") @db.JsonB - cl_array Int[] @default([]) blowpipe Json @default("{\"scales\":0,\"dartID\":null,\"dartQuantity\":0}") @db.Json slayer_unlocks Int[] @default([]) @map("slayer.unlocks") slayer_blocked_ids Int[] @default([]) @map("slayer.blocked_ids") @@ -405,7 +404,6 @@ model User { store_bitfield Int[] @default([]) - // Migrate farmingPatches_herb Json? @map("farmingPatches.herb") @db.Json farmingPatches_fruit_tree Json? @map("farmingPatches.fruit tree") @db.Json farmingPatches_tree Json? @map("farmingPatches.tree") @db.Json @@ -437,6 +435,8 @@ model User { gift_boxes_owned GiftBox[] @relation("gift_boxes_owned") gift_boxes_created GiftBox[] @relation("gift_boxes_created") + cl_array Int[] @default([]) + @@index([id, last_command_date]) @@map("users") } @@ -745,7 +745,7 @@ model UserStats { creature_scores Json @default("{}") monster_scores Json @default("{}") laps_scores Json @default("{}") - sacrificed_bank Json @default("{}") + sacrificed_bank Json @default("{}") @db.JsonB openable_scores Json @default("{}") gp_luckypick BigInt @default(0) diff --git a/src/lib/constants.ts b/src/lib/constants.ts index c3732622d0..cb7b989f77 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -344,7 +344,9 @@ export const BadgesEnum = { TopSkiller: 9, TopCollector: 10, TopMinigame: 11, - SotWTrophy: 12 + SotWTrophy: 12, + Slayer: 13, + TopGiveawayer: 14 } as const; export const badges: { [key: number]: string } = { @@ -360,7 +362,9 @@ export const badges: { [key: number]: string } = { [BadgesEnum.TopSkiller]: Emoji.Skiller, [BadgesEnum.TopCollector]: Emoji.CollectionLog, [BadgesEnum.TopMinigame]: Emoji.MinigameIcon, - [BadgesEnum.SotWTrophy]: Emoji.SOTWTrophy + [BadgesEnum.SotWTrophy]: Emoji.SOTWTrophy, + [BadgesEnum.Slayer]: Emoji.Slayer, + [BadgesEnum.TopGiveawayer]: Emoji.SantaHat }; export const MAX_XP = 200_000_000; diff --git a/src/lib/handleNewCLItems.ts b/src/lib/handleNewCLItems.ts index c57afd9041..d5e84942fa 100644 --- a/src/lib/handleNewCLItems.ts +++ b/src/lib/handleNewCLItems.ts @@ -9,6 +9,7 @@ import { allCLItems, allCollectionLogsFlat, calcCLDetails } from './data/Collect import { calculateMastery } from './mastery'; import { calculateOwnCLRanking, roboChimpSyncData } from './roboChimp'; +import { RawSQL } from './rawSql'; import { MUserStats } from './structures/MUserStats'; import { fetchCLLeaderboard } from './util/clLeaderboard'; import { insertUserEvent } from './util/userEvents'; @@ -49,6 +50,10 @@ export async function handleNewCLItems({ await prisma.historicalData.create({ data: await createHistoricalData(user) }); } + if (didGetNewCLItem) { + await prisma.$queryRawUnsafe(RawSQL.updateCLArray(user.id)); + } + if (!didGetNewCLItem) return; const previousCLDetails = calcCLDetails(previousCL); @@ -101,15 +106,14 @@ export async function handleNewCLItems({ })}!` : ''; - const nthUser = ( - await fetchCLLeaderboard({ - ironmenOnly: false, - items: finishedCL.items, - resultLimit: 100_000, - method: 'raw_cl', - userEvents: null - }) - ).filter(u => u.qty === finishedCL.items.length).length; + const leaderboardUsers = await fetchCLLeaderboard({ + ironmenOnly: false, + items: finishedCL.items, + resultLimit: 100_000, + clName: finishedCL.name + }); + + const nthUser = leaderboardUsers.users.filter(u => u.qty === finishedCL.items.length).length; const placeStr = nthUser > 100 ? '' : ` They are the ${formatOrdinal(nthUser)} user to finish this CL.`; diff --git a/src/lib/preStartup.ts b/src/lib/preStartup.ts index 0b98474cd1..b47aefc1f0 100644 --- a/src/lib/preStartup.ts +++ b/src/lib/preStartup.ts @@ -1,3 +1,4 @@ +import { noOp } from 'e'; import { syncCustomPrices } from '../mahoji/lib/events'; import { syncActivityCache } from './Task'; import { cacheBadges } from './badges'; @@ -5,6 +6,7 @@ import { syncBlacklists } from './blacklists'; import { GrandExchange } from './grandExchange'; import { cacheGEPrices } from './marketPrices'; import { populateRoboChimpCache } from './perkTier'; +import { RawSQL } from './rawSql'; import { runStartupScripts } from './startupScripts'; import { logWrapFn } from './util'; import { syncActiveUserIDs } from './util/cachedUserIDs'; @@ -21,6 +23,7 @@ export const preStartup = logWrapFn('PreStartup', async () => { cacheBadges(), GrandExchange.init(), populateRoboChimpCache(), - cacheGEPrices() + cacheGEPrices(), + prisma.$queryRawUnsafe(RawSQL.updateAllUsersCLArrays()).then(noOp) ]); }); diff --git a/src/lib/rawSql.ts b/src/lib/rawSql.ts new file mode 100644 index 0000000000..54b68fc695 --- /dev/null +++ b/src/lib/rawSql.ts @@ -0,0 +1,16 @@ +import { Prisma } from '@prisma/client'; + +const u = Prisma.UserScalarFieldEnum; + +export const RawSQL = { + updateAllUsersCLArrays: () => `UPDATE users +SET ${u.cl_array} = ( + SELECT (ARRAY(SELECT jsonb_object_keys("${u.collectionLogBank}")::int)) +) +WHERE last_command_date > now() - INTERVAL '1 week';`, + updateCLArray: (userID: string) => `UPDATE users +SET ${u.cl_array} = ( + SELECT (ARRAY(SELECT jsonb_object_keys("${u.collectionLogBank}")::int)) +) +WHERE ${u.id} = '${userID}';` +}; diff --git a/src/lib/rolesTask.ts b/src/lib/rolesTask.ts index dc694bb83c..91482bfae7 100644 --- a/src/lib/rolesTask.ts +++ b/src/lib/rolesTask.ts @@ -1,535 +1,418 @@ -import { Prisma } from '@prisma/client'; -import { noOp, notEmpty } from 'e'; +import { noOp, uniqueArr } from 'e'; -import { SupportServer, production } from '../config'; -import { ClueTiers } from '../lib/clues/clueTiers'; -import { Roles } from '../lib/constants'; +import { SupportServer } from '../config'; +import { BadgesEnum, Roles } from '../lib/constants'; import { getCollectionItems } from '../lib/data/Collections'; import { Minigames } from '../lib/settings/minigames'; +import { Prisma } from '@prisma/client'; import Skills from '../lib/skilling/skills'; -import { convertXPtoLVL, getUsername } from '../lib/util'; -import { logError } from '../lib/util/logError'; +import { Stopwatch, convertXPtoLVL, getUsernameSync } from '../lib/util'; +import { ClueTiers } from './clues/clueTiers'; import { TeamLoot } from './simulation/TeamLoot'; import type { ItemBank } from './types'; +import { fetchMultipleCLLeaderboards } from './util/clLeaderboard'; -function addToUserMap(userMap: Record, id: string, reason: string) { - if (!userMap[id]) userMap[id] = []; - userMap[id].push(reason); +interface RoleResult { + roleID: string; + userID: string; + reason: string; + badge?: (typeof BadgesEnum)[keyof typeof BadgesEnum]; } const minigames = Minigames.map(game => game.column).filter(i => i !== 'tithe_farm'); -const collections = ['pets', 'skilling', 'clues', 'bosses', 'minigames', 'raids', 'slayer', 'other', 'custom']; - -for (const cl of collections) { +const CLS_THAT_GET_ROLE = [ + 'pets', + 'skilling', + 'clues', + 'bosses', + 'minigames', + 'raids', + 'slayer', + 'other', + 'custom', + 'overall' +]; + +for (const cl of CLS_THAT_GET_ROLE) { const items = getCollectionItems(cl); if (!items || items.length === 0) { throw new Error(`${cl} isn't a valid CL.`); } } -const mostSlayerPointsQuery = `SELECT id, 'Most Points' as desc -FROM users -WHERE "slayer.points" > 50 -ORDER BY "slayer.points" DESC -LIMIT 1;`; - -const longerSlayerTaskStreakQuery = `SELECT user_id::text as id, 'Longest Task Streak' as desc -FROM user_stats -WHERE "slayer_task_streak" > 20 -ORDER BY "slayer_task_streak" DESC -LIMIT 1;`; - -const mostSlayerTasksDoneQuery = `SELECT user_id::text as id, 'Most Tasks' as desc -FROM slayer_tasks -GROUP BY user_id -ORDER BY count(user_id)::int DESC -LIMIT 1;`; - -async function addRoles({ - users, - role, - badge, - userMap -}: { - users: string[]; - role: string; - badge: number | null; - userMap?: Record; -}): Promise { - if (process.env.TEST) return ''; - const g = globalClient.guilds.cache.get(SupportServer); - if (!g) throw new Error('No support guild'); - const added: string[] = []; - const removed: string[] = []; - const _role = await g.roles.fetch(role).catch(noOp); - if (!_role) return `\nCould not check ${role} role`; - for (const u of users.filter(notEmpty)) { - await g.members.fetch(u).catch(noOp); - } - const roleName = _role.name!; - const noChangeUserDescriptions: string[] = []; - for (const mem of g.members.cache.values()) { - const mUser = await mUserFetch(mem.user.id); - if (mem.roles.cache.has(role) && !users.includes(mem.user.id)) { - if (production) { - await mem.roles.remove(role).catch(noOp); - } - - if (badge && mUser.user.badges.includes(badge)) { - await mUser.update({ - badges: mUser.user.badges.filter(i => i !== badge) - }); - } - removed.push(mem.user.username); - } - - if (users.includes(mem.user.id)) { - noChangeUserDescriptions.push(`${mem.user.username}`); - if (production && !mem.roles.cache.has(role)) { - added.push(mem.user.username); - await mem.roles.add(role).catch(noOp); - } - if (badge && !mUser.user.badges.includes(badge)) { - await mUser.update({ - badges: { - push: badge - } - }); - } - } - } - let str = `\n**${roleName}**`; - if (added.length > 0) { - str += `\nAdded to: ${added.join(', ')}.`; - } - if (removed.length > 0) { - str += `\nRemoved from: ${removed.join(', ')}.`; - } - if (userMap) { - const userArr = []; - for (const [id, arr] of Object.entries(userMap)) { - const username = await getUsername(id); - userArr.push(`${username}(${arr.join(', ')})`); - } - str += `\n${userArr.join(',')}`; - } - if (added.length > 0 || removed.length > 0) { - str += '\n'; - } else { - return `**No Changes:** ${str}`; - } - return str; -} - -export async function runRolesTask() { +async function topSkillers() { + const results: RoleResult[] = []; const skillVals = Object.values(Skills); - const results: string[] = []; - const q = async (str: string) => { - const result = await prisma.$queryRawUnsafe(str).catch(err => { - logError(`This query failed: ${str}`, err); - return []; - }); - return result; - }; - - // Top Skillers - async function topSkillers() { - const topSkillers = ( - await Promise.all([ - ...skillVals.map(s => - q< - { - id: string; - xp: string; - }[] - >(`SELECT id, "skills.${s.id}" as xp FROM users ORDER BY xp DESC LIMIT 1;`) - ), - q< - { - id: string; - }[] - >( - `SELECT id, ${skillVals.map(s => `"skills.${s.id}"`)}, ${skillVals - .map(s => `"skills.${s.id}"::bigint`) - .join(' + ')} as totalxp FROM users ORDER BY totalxp DESC LIMIT 1;` - ) - ]) - ).map(i => i[0]?.id); - - // Rank 1 Total Level - const rankOneTotal = ( - await q( - `SELECT id, ${skillVals.map(s => `"skills.${s.id}"`)}, ${skillVals - .map(s => `"skills.${s.id}"::bigint`) - .join(' + ')} as totalxp FROM users ORDER BY totalxp DESC LIMIT 200;` - ) + const [top200TotalXPUsers, ...top200ms] = await prisma.$transaction([ + prisma.$queryRawUnsafe( + `SELECT id, ${skillVals.map(s => `"skills.${s.id}"`)}, ${skillVals + .map(s => `"skills.${s.id}"::bigint`) + .join(' + ')} as totalxp FROM users ORDER BY totalxp DESC LIMIT 200;` + ), + ...skillVals.map(s => + prisma.$queryRawUnsafe<{ + id: string; + xp: string; + skill: string; + }>(`SELECT id, "skills.${s.id}" AS xp, '${s.id}' AS skill FROM users ORDER BY xp DESC LIMIT 1;`) ) - .map((u: any) => { - let totalLevel = 0; - for (const skill of skillVals) { - totalLevel += convertXPtoLVL(Number(u[`skills.${skill.id}` as keyof any]) as any); - } - return { - id: u.id, - totalLevel - }; - }) - .sort((a: any, b: any) => b.totalLevel - a.totalLevel)[0]; - topSkillers.push(rankOneTotal.id); - - results.push(await addRoles({ users: topSkillers, role: Roles.TopSkiller, badge: 9 })); + ]); + + for (const { id, skill } of top200ms) { + results.push({ + userID: id, + roleID: Roles.TopSkiller, + reason: `Rank 1 ${skill} XP`, + badge: BadgesEnum.TopSkiller + }); } - // Top Collectors - async function topCollector() { - const userMap = {}; - - function generateQuery(items: number[], ironmenOnly: boolean, limit: number) { - const t = ` -SELECT id, (cardinality(u.cl_keys) - u.inverse_length) as qty - FROM ( - SELECT ARRAY(SELECT * FROM JSONB_OBJECT_KEYS("collectionLogBank")) "cl_keys", - id, "collectionLogBank", - cardinality(ARRAY(SELECT * FROM JSONB_OBJECT_KEYS("collectionLogBank" - array[${items - .map(i => `'${i}'`) - .join(', ')}]))) "inverse_length" - FROM users - WHERE "collectionLogBank" ?| array[${items.map(i => `'${i}'`).join(', ')}] - ${ironmenOnly ? 'AND "minion.ironman" = true' : ''} - ) u - ORDER BY qty DESC - LIMIT ${limit}; -`; - - return t; - } + const rankOneTotal = top200TotalXPUsers + .map((u: any) => { + let totalLevel = 0; + for (const skill of skillVals) { + totalLevel += convertXPtoLVL(Number(u[`skills.${skill.id}` as keyof any]) as any); + } + return { + id: u.id, + totalLevel + }; + }) + .sort((a: any, b: any) => b.totalLevel - a.totalLevel)[0]; - const topCollectors = ( - await Promise.all( - collections.map(async clName => { - const items = getCollectionItems(clName); - if (!items || items.length === 0) { - logError(`${clName} collection log doesnt exist`); - return []; - } - - function handleErr(): any[] { - logError(`Failed to select top collectors for ${clName}`); - return []; - } - - const [users, ironUsers] = await Promise.all([ - q(generateQuery(items, false, 1)) - .then(i => i.filter((i: any) => i.qty > 0) as any[]) - .catch(handleErr), - q(generateQuery(items, true, 1)) - .then(i => i.filter((i: any) => i.qty > 0) as any[]) - .catch(handleErr) - ]); - - const result = []; - const userID = users[0]?.id; - const ironmanID = ironUsers[0]?.id; - - if (userID) { - addToUserMap(userMap, userID, `Rank 1 ${clName} CL`); - result.push(userID); - } - if (ironmanID) { - addToUserMap(userMap, ironmanID, `Rank 1 Ironman ${clName} CL`); - result.push(ironmanID); - } - - return result; - }) - ) - ).flat(2); - - const topIronUsers = (await q(generateQuery(getCollectionItems('overall'), true, 3))).filter( - (i: any) => i.qty > 0 - ) as any[]; - for (let i = 0; i < topIronUsers.length; i++) { - const id = topIronUsers[i]?.id; - addToUserMap(userMap, id, `Rank ${i + 1} Ironman Collector`); - topCollectors.push(id); - } - const topNormieUsers = (await q(generateQuery(getCollectionItems('overall'), false, 3))).filter( - (i: any) => i.qty > 0 - ) as any[]; - for (let i = 0; i < topNormieUsers.length; i++) { - const id = topNormieUsers[i]?.id; - addToUserMap(userMap, id, `Rank ${i + 1} Collector`); - topCollectors.push(id); - } + results.push({ + userID: rankOneTotal.id, + roleID: Roles.TopSkiller, + reason: 'Rank 1 Total Level', + badge: BadgesEnum.TopSkiller + }); - results.push(await addRoles({ users: topCollectors, role: Roles.TopCollector, badge: 10, userMap })); - } + return results; +} - // Top sacrificers - async function topSacrificers() { - const userMap = {}; - const topSacrificers: string[] = []; - const mostValue = await q('SELECT id FROM users ORDER BY "sacrificedValue" DESC LIMIT 3;'); - for (let i = 0; i < 3; i++) { - if (mostValue[i] !== undefined) { - topSacrificers.push(mostValue[i].id); - addToUserMap(userMap, mostValue[i].id, `Rank ${i + 1} Sacrifice Value`); - } +async function topCollector() { + const results: RoleResult[] = []; + const rankOneInSpecifiedCLs = await fetchMultipleCLLeaderboards( + CLS_THAT_GET_ROLE.map(cl => { + const items = getCollectionItems(cl); + const base = { + items, + clName: cl, + resultLimit: cl === 'overall' ? 3 : 1 + } as const; + return [ + { ...base, ironmenOnly: true }, + { ...base, ironmenOnly: false } + ]; + }).flat(2) + ); + for (const { users, clName, ironmenOnly } of rankOneInSpecifiedCLs) { + for (const user of users) { + results.push({ + userID: user.id, + roleID: Roles.TopCollector, + reason: `Rank 1 ${ironmenOnly ? 'Iron' : 'Main'} ${clName}`, + badge: BadgesEnum.TopCollector + }); } - const mostValueIronman = await q( - 'SELECT id FROM users WHERE "minion.ironman" = true ORDER BY "sacrificedValue" DESC LIMIT 1;' - ); - topSacrificers.push(mostValueIronman[0].id); - addToUserMap(userMap, mostValueIronman[0].id, 'Rank 1 Ironman Sacrificed Value'); + } + return results; +} - const mostUniques = await q(`SELECT u.id, u.sacbanklength FROM ( +async function topSacrificers() { + const results: RoleResult[] = []; + const users = await prisma.$transaction([ + prisma.$queryRawUnsafe<{ id: string; reason: string }[]>( + `SELECT id, 'Top 3' AS reason FROM users ORDER BY "sacrificedValue" DESC LIMIT 3;` + ), + prisma.$queryRawUnsafe<{ id: string; reason: string }[]>( + `SELECT id, 'Top Ironman' AS reason FROM users WHERE "minion.ironman" = true ORDER BY "sacrificedValue" DESC LIMIT 1;` + ), + prisma.$queryRawUnsafe<{ id: string; reason: string }[]>(`SELECT u.id, 'Top Uniques' AS reason FROM ( SELECT (SELECT COUNT(*)::int FROM JSONB_OBJECT_KEYS("sacrificed_bank")) sacbanklength, user_id::text as id FROM user_stats ) u -ORDER BY u.sacbanklength DESC LIMIT 1;`); - - const mostUniquesIron = await q(`SELECT u.id, u.sacbanklength FROM ( +ORDER BY u.sacbanklength DESC LIMIT 1;`), + prisma.$queryRawUnsafe<{ id: string; reason: string }[]>(`SELECT u.id, 'Top Ironman Uniques' AS reason FROM ( SELECT (SELECT COUNT(*)::int FROM JSONB_OBJECT_KEYS("sacrificed_bank")) sacbanklength, user_id::text as id FROM user_stats INNER JOIN users ON "user_stats"."user_id"::text = "users"."id" WHERE "users"."minion.ironman" = true ) u -ORDER BY u.sacbanklength DESC LIMIT 1;`); - topSacrificers.push(mostUniques[0].id); - addToUserMap(userMap, mostUniques[0].id, 'Most Uniques Sacrificed'); - topSacrificers.push(mostUniquesIron[0].id); - addToUserMap(userMap, mostUniquesIron[0].id, 'Most Ironman Uniques Sacrificed'); - - results.push(await addRoles({ users: topSacrificers, role: Roles.TopSacrificer, badge: 8, userMap })); +ORDER BY u.sacbanklength DESC LIMIT 1;`) + ]); + + for (const res of users.flat()) { + results.push({ + userID: res.id, + reason: res.reason, + roleID: Roles.TopSacrificer, + badge: BadgesEnum.TopSacrifice + }); } - // Top minigamers - async function topMinigamers() { - const topMinigamers = ( - await Promise.all( - minigames.map(m => - q( - `SELECT user_id, '${m}' as m + return results; +} + +async function topMinigamers() { + const results: RoleResult[] = []; + const topMinigamers = await prisma.$transaction( + minigames.map(m => + prisma.$queryRawUnsafe<{ id: string; minigame_name: string; qty: number }[]>( + `SELECT user_id::text AS id, '${m}' AS minigame_name FROM minigames ORDER BY ${m} DESC LIMIT 1;` - ) - ) ) - ).map((i: any) => [i[0].user_id, Minigames.find(m => m.column === i[0].m)?.name]); - - const userMap = {}; - for (const [id, m] of topMinigamers) { - addToUserMap(userMap, id, `Rank 1 ${m}`); - } + ) + ); - results.push( - await addRoles({ - users: topMinigamers.map(i => i[0]), - role: Roles.TopMinigamer, - badge: 11, - userMap - }) - ); + for (const { id, minigame_name } of topMinigamers.flat()) { + results.push({ + userID: id, + roleID: Roles.TopMinigamer, + reason: `Rank 1 ${minigame_name}`, + badge: BadgesEnum.TopMinigame + }); } - // Top clue hunters - async function topClueHunters() { - const topClueHunters = ( - await Promise.all( - ClueTiers.map(t => - q( - `SELECT id, '${t.name}' as n, (openable_scores->>'${t.id}')::int as qty -FROM users -INNER JOIN "user_stats" ON "user_stats"."user_id"::text = "users"."id" + return results; +} + +async function topClueHunters() { + const results: RoleResult[] = []; + const topClueHunters = await prisma.$transaction( + ClueTiers.map(t => + prisma.$queryRawUnsafe<{ user_id: string; tier_name: string; qty: string }>( + ` +SELECT user_id::text, '${t.name}' AS tier_name, (openable_scores->>'${t.id}')::int AS qty +FROM user_stats WHERE "openable_scores"->>'${t.id}' IS NOT NULL ORDER BY qty DESC LIMIT 1;` - ) - ) ) ) - .filter((i: any) => Boolean(i[0]?.id)) - .map((i: any) => [i[0]?.id, i[0]?.n]); - - const userMap = {}; - - for (const [id, n] of topClueHunters) { - addToUserMap(userMap, id, `Rank 1 ${n} Clues`); - } + ); - results.push( - await addRoles({ - users: topClueHunters.map(i => i[0]), - role: Roles.TopClueHunter, - badge: null, - userMap - }) - ); + for (const res of topClueHunters) { + results.push({ + userID: res.user_id, + roleID: Roles.TopClueHunter, + reason: `Rank 1 ${res.tier_name} Clues`, + badge: BadgesEnum.TopMinigame + }); } + return results; +} - // Top farmers - async function farmers() { - const queries = [ - `SELECT id, 'Top 2 Farming Contracts' as desc +async function topFarmers() { + const results: RoleResult[] = []; + const queries = [ + `SELECT id, 'Top 2 Farming Contracts' as desc FROM users WHERE "minion.farmingContract" IS NOT NULL AND "minion.ironman" = true ORDER BY ("minion.farmingContract"->>'contractsCompleted')::int DESC LIMIT 2;`, - `SELECT id, 'Top 2 Ironman Farming Contracts' as desc + `SELECT id, 'Top 2 Ironman Farming Contracts' as desc FROM users WHERE "minion.farmingContract" IS NOT NULL ORDER BY ("minion.farmingContract"->>'contractsCompleted')::int DESC LIMIT 2;`, - `SELECT user_id::text as id, 'Top 2 Most Farming Trips' as desc + `SELECT user_id::text as id, 'Top 2 Most Farming Trips' as desc FROM activity WHERE type = 'Farming' GROUP BY user_id ORDER BY count(user_id)::int DESC LIMIT 2;`, - `SELECT user_id::text as id, 'Top 2 Tithe Farm' as desc + `SELECT user_id::text as id, 'Top 2 Tithe Farm' as desc FROM user_stats ORDER BY "tithe_farms_completed" DESC LIMIT 2;` - ]; - const res = (await Promise.all(queries.map(q))).map((i: any) => [i[0]?.id, i[0]?.desc]); - const userMap = {}; - for (const [id, desc] of res) { - addToUserMap(userMap, id, desc); - } - - results.push( - await addRoles({ - users: res.map(i => i[0]), - role: '894194027363205150', - badge: null, - userMap - }) - ); + ]; + const res = await prisma.$transaction(queries.map(q => prisma.$queryRawUnsafe<{ id: string; desc: string }[]>(q))); + for (const { id, desc } of res.flat()) { + results.push({ + userID: id, + roleID: '894194027363205150', + reason: desc, + badge: BadgesEnum.Slayer + }); } + return results; +} - // Top slayers - async function slayer() { - const topSlayers = ( - await Promise.all( - [mostSlayerPointsQuery, longerSlayerTaskStreakQuery, mostSlayerTasksDoneQuery].map(query => q(query)) - ) - ) - .filter((i: any) => Boolean(i[0]?.id)) - .map((i: any) => [i[0]?.id, i[0]?.desc]); - - const userMap = {}; - for (const [id, desc] of topSlayers) { - addToUserMap(userMap, id, desc); - } - - results.push( - await addRoles({ - users: topSlayers.map(i => i[0]), - role: Roles.TopSlayer, - badge: null, - userMap - }) - ); +async function fetchSlayerResults() { + const results: RoleResult[] = []; + const topSlayers = await prisma.$transaction([ + prisma.$queryRawUnsafe<{ id: string; desc: string }[]>(`SELECT id, 'Most Points' as desc +FROM users +WHERE "slayer.points" > 50 +ORDER BY "slayer.points" DESC +LIMIT 1;`), + prisma.$queryRawUnsafe<{ id: string; desc: string }[]>(`SELECT user_id::text as id, 'Longest Task Streak' as desc +FROM user_stats +WHERE "slayer_task_streak" > 20 +ORDER BY "slayer_task_streak" DESC +LIMIT 1;`), + prisma.$queryRawUnsafe<{ id: string; desc: string }[]>(`SELECT user_id::text as id, 'Most Tasks' as desc +FROM slayer_tasks +GROUP BY user_id +ORDER BY count(user_id)::int DESC +LIMIT 1;`) + ]); + + for (const { id, desc } of topSlayers.flat()) { + results.push({ + userID: id, + roleID: Roles.TopSlayer, + reason: desc, + badge: BadgesEnum.Slayer + }); } + return results; +} - // Top giveawayers - async function giveaways() { - const GIVEAWAY_CHANNELS = [ - '792691343284764693', - '732207379818479756', - '342983479501389826', - '982989775399174184', - '346304390858145792' - ]; - const res = await prisma.$queryRaw<{ user_id: string; qty: number }[]>`SELECT user_id, COUNT(user_id)::int AS qty -FROM giveaway -WHERE channel_id IN (${Prisma.join(GIVEAWAY_CHANNELS)}) -AND user_id NOT IN ('157797566833098752') -GROUP BY user_id -ORDER BY qty DESC -LIMIT 50;`; - const userIDsToCheck = res.map(i => i.user_id); - - const giveawayBank = new TeamLoot(); - - const giveaways = await prisma.giveaway.findMany({ - where: { - channel_id: { - in: GIVEAWAY_CHANNELS - }, - user_id: { - in: userIDsToCheck - } +async function giveaways() { + const results: RoleResult[] = []; + const GIVEAWAY_CHANNELS = [ + '792691343284764693', + '732207379818479756', + '342983479501389826', + '982989775399174184', + '346304390858145792' + ]; + const res = await prisma.$queryRaw<{ user_id: string; qty: number }[]>`SELECT user_id, COUNT(user_id)::int AS qty + FROM giveaway + WHERE channel_id IN (${Prisma.join(GIVEAWAY_CHANNELS)}) + AND user_id NOT IN ('157797566833098752') + GROUP BY user_id + ORDER BY qty DESC + LIMIT 50;`; + const userIDsToCheck = res.map(i => i.user_id); + + const giveawayBank = new TeamLoot(); + + const giveaways = await prisma.giveaway.findMany({ + where: { + channel_id: { + in: GIVEAWAY_CHANNELS + }, + user_id: { + in: userIDsToCheck } - }); - for (const giveaway of giveaways) { - giveawayBank.add(giveaway.user_id, giveaway.loot as ItemBank); } + }); + + if (giveaways.length === 0) return results; - const userMap = {}; - const [[highestID, loot]] = giveawayBank.entries().sort((a, b) => b[1].value() - a[1].value()); - addToUserMap(userMap, highestID, `Most Value Given Away (${loot.value()})`); - - results.push( - await addRoles({ - users: [highestID], - role: '1052481561603346442', - badge: null, - userMap - }) - ); + for (const giveaway of giveaways) { + giveawayBank.add(giveaway.user_id, giveaway.loot as ItemBank); } - // Global CL % - async function globalCL() { - const result = await roboChimpClient.$queryRaw<{ id: string; total_cl_percent: number }[]>`SELECT ((osb_cl_percent + bso_cl_percent) / 2) AS total_cl_percent, id::text AS id -FROM public.user -WHERE osb_cl_percent IS NOT NULL AND bso_cl_percent IS NOT NULL -ORDER BY total_cl_percent DESC -LIMIT 10;`; - - results.push( - await addRoles({ - users: result.slice(0, 10).map(i => i.id), - role: Roles.TopGlobalCL, - badge: null - }) - ); + const [[highestID, loot]] = giveawayBank.entries().sort((a, b) => b[1].value() - a[1].value()); + + results.push({ + userID: highestID, + roleID: '1052481561603346442', + reason: `Most Value Given Away (${loot.value()})`, + badge: BadgesEnum.TopGiveawayer + }); + return results; +} + +async function globalCL() { + const results: RoleResult[] = []; + const result = await roboChimpClient.$queryRaw<{ id: string; total_cl_percent: number }[]>`SELECT ((osb_cl_percent + bso_cl_percent) / 2) AS total_cl_percent, id::text AS id + FROM public.user + WHERE osb_cl_percent IS NOT NULL AND bso_cl_percent IS NOT NULL + ORDER BY total_cl_percent DESC + LIMIT 10;`; + + for (const user of result) { + results.push({ + userID: user.id, + roleID: Roles.TopGlobalCL, + reason: `Top Global CL ${user.total_cl_percent}%`, + badge: BadgesEnum.TopCollector + }); } + return results; +} + +export async function runRolesTask(dryRun: boolean) { + const results: RoleResult[] = []; const tup = [ - ['Top Slayer', slayer], + ['Top Slayer', fetchSlayerResults], ['Top Clue Hunters', topClueHunters], ['Top Minigamers', topMinigamers], ['Top Sacrificers', topSacrificers], ['Top Collectors', topCollector], ['Top Skillers', topSkillers], - ['Top Farmers', farmers], + ['Top Farmers', topFarmers], ['Top Giveawayers', giveaways], ['Global CL', globalCL] ] as const; - const failed: string[] = []; - await Promise.all( - tup.map(async ([name, fn]) => { - try { - await fn(); - } catch (err: any) { - if (process.env.TEST) { - throw err; - } - failed.push(`${name} (${err.message})`); - logError(err); - } + await Promise.all([ + ...tup.map(async ([name, fn]) => { + const stopwatch = new Stopwatch(); + const res = await fn(); + console.log(`Ran ${name} in ${stopwatch.stop()}`); + results.push(...res); }) - ); + ]); + + const allBadgeIDs = uniqueArr(results.map(i => i.badge)); + const allRoleIDs = uniqueArr(results.map(i => i.roleID)); + + if (!dryRun) { + const roleNames = new Map(); + const supportServerGuild = globalClient.guilds.cache.get(SupportServer)!; + if (!supportServerGuild) throw new Error('No support guild'); + + // Remove all top badges from all users (and add back later) + const badgeIDs = `ARRAY[${allBadgeIDs.join(',')}]`; + await prisma.$queryRawUnsafe(` +UPDATE users +SET badges = badges - ${badgeIDs} +WHERE badges && ${badgeIDs} +`); + + // Remove roles from ineligible users + for (const member of supportServerGuild.members.cache.values()) { + const rolesToRemove = member.roles.cache.filter(r => allRoleIDs.includes(r.id)); + if (rolesToRemove.size > 0) { + await member.roles.remove(rolesToRemove.map(r => r.id)).catch(noOp); + } + } + + // Add roles to users + for (const { userID, roleID, badge } of results) { + const role = await supportServerGuild.roles.fetch(roleID).catch(noOp); + const member = await supportServerGuild.members.fetch(userID).catch(noOp); + if (!member || !role) continue; + roleNames.set(roleID, role.name); - const res = `**Roles** -${results.join('\n')} -${failed.length > 0 ? `Failed: ${failed.join(', ')}` : ''}`; + if (!member.roles.cache.has(roleID)) { + await member.roles.add(roleID).catch(noOp); + } + + if (badge) { + const user = await mUserFetch(userID); + if (!user.user.badges.includes(badge)) { + await user.update({ + badges: { + push: badge + } + }); + } + } + } + return `**Roles**\n${results.map(r => `${getUsernameSync(r.userID)} got ${roleNames.get(r.roleID)} because ${r.reason}`).join('\n')}`; + } - return res; + return 'Dry run'; } diff --git a/src/lib/util/addSubTaskToActivityTask.ts b/src/lib/util/addSubTaskToActivityTask.ts index e717d57794..8e0fe2f06c 100644 --- a/src/lib/util/addSubTaskToActivityTask.ts +++ b/src/lib/util/addSubTaskToActivityTask.ts @@ -39,24 +39,28 @@ export default async function addSubTaskToActivityTask 0 ? userIds.map(i => `'${i}'`).join(', ') : 'NULL'; - const specificUsers = - userIds.length > 0 - ? await prisma.$queryRawUnsafe<{ id: string; qty: number }[]>(` - SELECT user_id::text AS id, CARDINALITY(s.cl_array) - CARDINALITY(s.cl_array - array[${items - .map(i => `${i}`) - .join(', ')}]) AS qty - FROM user_stats s INNER JOIN users u ON s.user_id::text = u.id - WHERE ${ironmenOnly ? 'u."minion.ironman" = true AND' : ''} user_id::text IN (${userIdsList}) -`) - : []; - const generalUsers = await prisma.$queryRawUnsafe<{ id: string; qty: number }[]>(` - SELECT user_id::text AS id, CARDINALITY(user_stats.cl_array) - CARDINALITY(user_stats.cl_array - array[${items - .map(i => `${i}`) - .join(', ')}]) AS qty - FROM user_stats - ${ironmenOnly ? 'INNER JOIN "users" on "users"."id" = "user_stats"."user_id"::text' : ''} - WHERE (user_stats.cl_array && array[${items.map(i => `${i}`).join(', ')}] - ${ironmenOnly ? 'AND "users"."minion.ironman" = true' : ''}) - ${userIds.length > 0 ? `AND user_id::text NOT IN (${userIdsList})` : ''} - ORDER BY qty DESC - LIMIT ${resultLimit} +export async function fetchMultipleCLLeaderboards( + leaderboards: { + ironmenOnly: boolean; + items: number[]; + resultLimit: number; + clName: string; + }[] +) { + const userEvents = await prisma.userEvent.findMany({ + where: { + type: 'CLCompletion' + }, + orderBy: { + date: 'asc' + } + }); + const parsedLeaderboards = leaderboards.map(l => { + const userEventMap = userEventsToMap( + userEvents.filter(e => e.collection_log_name && stringMatches(e.collection_log_name, l.clName)) + ); + return { + ...l, + userEventMap + }; + }); + + const results = await prisma.$transaction([ + ...parsedLeaderboards.map(({ items, userEventMap, ironmenOnly, resultLimit }) => { + const SQL_ITEMS = `ARRAY[${items.map(i => `${i}`).join(', ')}]`; + const userIds = Array.from(userEventMap.keys()); + const userIdsList = userIds.length > 0 ? userIds.map(i => `'${i}'`).join(', ') : 'NULL'; + + return prisma.$queryRawUnsafe<{ id: string; qty: number }[]>(` +SELECT id, qty +FROM ( + SELECT id, CARDINALITY(cl_array & ${SQL_ITEMS}) AS qty + FROM users + WHERE (cl_array && ${SQL_ITEMS} + ${ironmenOnly ? 'AND "users"."minion.ironman" = true' : ''}) ${userIdsList.length > 0 ? `OR id IN (${userIdsList})` : ''} + LIMIT ${resultLimit} + ) AS subquery +ORDER BY qty DESC; `); + }) + ]); - const users = [...specificUsers, ...generalUsers] + return results.map((res, index) => { + const src = parsedLeaderboards[index]; + + const users = res .sort((a, b) => { const valueDifference = b.qty - a.qty; if (valueDifference !== 0) { return valueDifference; } - const dateA = userEventMap.get(a.id); - const dateB = userEventMap.get(b.id); + const dateA = src.userEventMap.get(a.id); + const dateB = src.userEventMap.get(b.id); if (dateA && dateB) { return dateA - dateB; } @@ -62,30 +70,26 @@ export async function fetchCLLeaderboard({ return 0; }) .filter(i => i.qty > 0); - return users; - } - const users = ( - await prisma.$queryRawUnsafe<{ id: string; qty: number }[]>(` -SELECT id, (cardinality(u.cl_keys) - u.inverse_length) as qty - FROM ( - SELECT array(SELECT * FROM jsonb_object_keys("collectionLogBank")) "cl_keys", - id, "collectionLogBank", - cardinality(array(SELECT * FROM jsonb_object_keys("collectionLogBank" - array[${items - .map(i => `'${i}'`) - .join(', ')}]))) "inverse_length" - FROM users - WHERE ("collectionLogBank" ?| array[${items.map(i => `'${i}'`).join(', ')}] - ${ironmenOnly ? 'AND "minion.ironman" = true' : ''}) - ${ - userIds.length > 0 - ? `OR (id in (${userIds.map(i => `'${i}'`).join(', ')})${ironmenOnly ? 'AND "minion.ironman" = true' : ''})` - : '' - } -) u -ORDER BY qty DESC -LIMIT ${resultLimit}; -`) - ).filter(i => i.qty > 0); - return users; + return { + ...src, + users + }; + }); +} + +export async function fetchCLLeaderboard({ + ironmenOnly, + items, + resultLimit, + clName +}: { + ironmenOnly: boolean; + items: number[]; + resultLimit: number; + method?: 'cl_array'; + clName: string; +}) { + const result = await fetchMultipleCLLeaderboards([{ ironmenOnly, items, resultLimit, clName }]); + return result[0]; } diff --git a/src/mahoji/commands/leaderboard.ts b/src/mahoji/commands/leaderboard.ts index f1c2e39cd4..f98a813caf 100644 --- a/src/mahoji/commands/leaderboard.ts +++ b/src/mahoji/commands/leaderboard.ts @@ -324,20 +324,10 @@ async function clLb( ) { const { resolvedCl, items } = getCollectionItems(inputType, false, false, true); if (!items || items.length === 0) { - return "That's not a valid collection log category. Check +cl for all possible logs."; + return "That's not a valid collection log category. Check /cl for all possible logs."; } - const userEventOrders = await prisma.userEvent.findMany({ - where: { - type: 'CLCompletion', - collection_log_name: resolvedCl.toLowerCase() - }, - orderBy: { - date: 'asc' - } - }); - - const users = await fetchCLLeaderboard({ ironmenOnly, items, resultLimit: 200, userEvents: userEventOrders }); + const { users } = await fetchCLLeaderboard({ ironmenOnly, items, resultLimit: 200, clName: resolvedCl }); inputType = toTitleCase(inputType.toLowerCase()); return doMenuWrapper({ diff --git a/src/mahoji/commands/rp.ts b/src/mahoji/commands/rp.ts index 99f8533f3f..0ca844d664 100644 --- a/src/mahoji/commands/rp.ts +++ b/src/mahoji/commands/rp.ts @@ -11,8 +11,7 @@ import { Bank } from 'oldschooljs'; import type { Item } from 'oldschooljs/dist/meta/types'; import { ADMIN_IDS, OWNER_IDS, SupportServer, production } from '../../config'; -import { analyticsTick } from '../../lib/analytics'; -import { BitField, Channel } from '../../lib/constants'; +import { BitField, Channel, globalConfig } from '../../lib/constants'; import { allCollectionLogsFlat } from '../../lib/data/Collections'; import type { GearSetupType } from '../../lib/gear/types'; import { GrandExchange } from '../../lib/grandExchange'; @@ -21,10 +20,11 @@ import { unEquipAllCommand } from '../../lib/minions/functions/unequipAllCommand import { unequipPet } from '../../lib/minions/functions/unequipPet'; import { premiumPatronTime } from '../../lib/premiumPatronTime'; +import { runRolesTask } from '../../lib/rolesTask'; import { TeamLoot } from '../../lib/simulation/TeamLoot'; import { SkillsEnum } from '../../lib/skilling/types'; import type { ItemBank } from '../../lib/types'; -import { dateFm, isValidDiscordSnowflake, returnStringOrFile } from '../../lib/util'; +import { dateFm, isValidDiscordSnowflake } from '../../lib/util'; import getOSItem from '../../lib/util/getOSItem'; import { handleMahojiConfirmation } from '../../lib/util/handleMahojiConfirmation'; import { deferInteraction } from '../../lib/util/interactionReply'; @@ -44,7 +44,14 @@ import { sellPriceOfItem } from './sell'; const itemFilters = [ { name: 'Tradeable', - filter: (item: Item) => itemIsTradeable(item.id, true) + filter: (item: Item) => itemIsTradeable(item.id, true), + run: async () => { + const isValid = await GrandExchange.extensiveVerification(); + if (isValid) { + return 'No issues found.'; + } + return 'Something was invalid. Check logs!'; + } } ]; @@ -142,6 +149,35 @@ function isProtectedAccount(user: MUser) { return false; } +const actions = [ + { + name: 'validate_ge', + allowed: (user: MUser) => ADMIN_IDS.includes(user.id) || OWNER_IDS.includes(user.id), + run: async () => { + const isValid = await GrandExchange.extensiveVerification(); + if (isValid) { + return 'No issues found.'; + } + return 'Something was invalid. Check logs!'; + } + }, + { + name: 'sync_roles', + allowed: (user: MUser) => + ADMIN_IDS.includes(user.id) || OWNER_IDS.includes(user.id) || user.bitfield.includes(BitField.isModerator), + run: async () => { + return runRolesTask(!globalConfig.isProduction); + } + }, + { + name: 'sync_usernames', + allowed: (user: MUser) => ADMIN_IDS.includes(user.id) || OWNER_IDS.includes(user.id), + run: async () => { + return usernameSync(); + } + } +]; + export const rpCommand: OSBMahojiCommand = { name: 'rp', description: 'Admin tools second set', @@ -150,45 +186,13 @@ export const rpCommand: OSBMahojiCommand = { { type: ApplicationCommandOptionType.SubcommandGroup, name: 'action', - description: 'Action tools', - options: [ - { - type: ApplicationCommandOptionType.Subcommand, - name: 'validate_ge', - description: 'Validate the g.e.', - options: [] - }, - { - type: ApplicationCommandOptionType.Subcommand, - name: 'patreon_reset', - description: 'Reset all patreon data.', - options: [] - }, - { - type: ApplicationCommandOptionType.Subcommand, - name: 'view_all_items', - description: 'View all item IDs present in banks/cls.', - options: [] - }, - { - type: ApplicationCommandOptionType.Subcommand, - name: 'analytics_tick', - description: 'analyticsTick.', - options: [] - }, - { - type: ApplicationCommandOptionType.Subcommand, - name: 'networth_sync', - description: 'networth_sync.', - options: [] - }, - { - type: ApplicationCommandOptionType.Subcommand, - name: 'redis_sync', - description: 'redis sync.', - options: [] - } - ] + description: 'Actions', + options: actions.map(a => ({ + type: ApplicationCommandOptionType.Subcommand, + name: a.name, + description: a.name, + options: [] + })) }, { type: ApplicationCommandOptionType.SubcommandGroup, @@ -520,14 +524,7 @@ export const rpCommand: OSBMahojiCommand = { max_total?: { user: MahojiUserOption; type: UserEventType; message_id: string }; max?: { user: MahojiUserOption; type: UserEventType; skill: xp_gains_skill_enum; message_id: string }; }; - action?: { - validate_ge?: {}; - patreon_reset?: {}; - view_all_items?: {}; - analytics_tick?: {}; - networth_sync?: {}; - redis_sync?: {}; - }; + action?: any; player?: { viewbank?: { user: MahojiUserOption; json?: boolean }; add_patron_time?: { user: MahojiUserOption; tier: number; time: string }; @@ -620,52 +617,13 @@ Date: ${dateFm(date)}`; if (!isMod) return randArrItem(gifs); - if (options.action?.validate_ge) { - const isValid = await GrandExchange.extensiveVerification(); - if (isValid) { - return 'No issues found.'; - } - return 'Something was invalid. Check logs!'; - } - if (options.action?.analytics_tick) { - await analyticsTick(); - return 'Finished.'; - } - if (options.action?.redis_sync) { - const result = await usernameSync(); - return result; - } - if (options.action?.networth_sync) { - const users = await prisma.user.findMany({ - where: { - GP: { - gt: 10_000_000_000 - } - }, - take: 20, - orderBy: { - GP: 'desc' - }, - select: { - id: true + if (options.action) { + for (const action of actions) { + if (options.action[action.name]) { + if (!action.allowed(adminUser)) return randArrItem(gifs); + return action.run(); } - }); - for (const { id } of users) { - const user = await mUserFetch(id); - await user.update({ - cached_networth_value: (await user.calculateNetWorth()).value - }); } - return 'Done.'; - } - if (options.action?.view_all_items) { - const result = await prisma.$queryRawUnsafe<{ item_id: number }[]>(`SELECT DISTINCT json_object_keys(bank)::int AS item_id -FROM users -UNION -SELECT DISTINCT jsonb_object_keys("collectionLogBank")::int AS item_id -FROM users -ORDER BY item_id ASC;`); - return returnStringOrFile(`[${result.map(i => i.item_id).join(',')}]`); } if (options.player?.set_buy_date) { diff --git a/tests/integration/misc.test.ts b/tests/integration/misc.test.ts index 2d113c1b15..eae4db6a9a 100644 --- a/tests/integration/misc.test.ts +++ b/tests/integration/misc.test.ts @@ -1,4 +1,3 @@ -import type { UserEvent } from '@prisma/client'; import { randArrItem } from 'e'; import { describe, expect, test } from 'vitest'; @@ -18,30 +17,14 @@ describe('Integration Misc', () => { expect(await global.prisma!.analytic.count()).toBeGreaterThanOrEqual(1); }); test('fetchCLLeaderboard', async () => { + const cl = randArrItem(allCollectionLogsFlat); for (const ironManOnly of [true, false]) { - for (const method of ['cl_array', 'raw_cl'] as const) { - for (const userEvents of [ - [ - { - id: 'asdf', - date: new Date(), - user_id: '123', - type: 'CLCompletion', - skill: null, - collection_log_name: 'giant mole' - } as UserEvent - ], - null - ]) { - await fetchCLLeaderboard({ - ironmenOnly: ironManOnly, - method, - userEvents, - resultLimit: 100, - items: randArrItem(allCollectionLogsFlat).items - }); - } - } + await fetchCLLeaderboard({ + ironmenOnly: ironManOnly, + resultLimit: 100, + items: cl.items, + clName: cl.name + }); } await Promise.all([fetchCLLeaderboard]); }); diff --git a/tests/integration/rolesTask.test.ts b/tests/integration/rolesTask.test.ts index 252bee8faf..dcd69d0dcc 100644 --- a/tests/integration/rolesTask.test.ts +++ b/tests/integration/rolesTask.test.ts @@ -43,7 +43,7 @@ describe.skip('Roles Task', async () => { duration: 10_000 } }); - const result = await runRolesTask(); + const result = await runRolesTask(true); expect(result).toBeTruthy(); expect(result).includes('Roles'); }); From 98904fab6163e409c06b8ca10c60f212198afce0 Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Fri, 2 Aug 2024 17:15:00 +1000 Subject: [PATCH 123/145] Remove old sync_roles command --- src/mahoji/commands/admin.ts | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/src/mahoji/commands/admin.ts b/src/mahoji/commands/admin.ts index 64af8ef97a..631d507ccf 100644 --- a/src/mahoji/commands/admin.ts +++ b/src/mahoji/commands/admin.ts @@ -482,11 +482,6 @@ export const adminCommand: OSBMahojiCommand = { } ] }, - { - type: ApplicationCommandOptionType.Subcommand, - name: 'sync_roles', - description: 'Sync roles' - }, { type: ApplicationCommandOptionType.Subcommand, name: 'badges', @@ -665,7 +660,6 @@ export const adminCommand: OSBMahojiCommand = { sync_blacklist?: {}; loot_track?: { name: string }; cancel_task?: { user: MahojiUserOption }; - sync_roles?: {}; badges?: { user: MahojiUserOption; add?: string; remove?: string }; bypass_age?: { user: MahojiUserOption }; command?: { enable?: string; disable?: string }; @@ -697,20 +691,6 @@ export const adminCommand: OSBMahojiCommand = { minionActivityCacheDelete(user.id); return 'Done.'; } - if (options.sync_roles) { - // try { - // const result = await runRolesTask(); - // if (result.length < 2000) return result; - // return { - // content: 'The result was too big! Check the file.', - // files: [new AttachmentBuilder(Buffer.from(result), { name: 'roles.txt' })] - // }; - // } catch (err: any) { - // logError(err); - // return `Failed to run roles task. ${err.message}`; - // } - return 'The roles task is disabled for now.'; - } if (options.badges) { if ((!options.badges.remove && !options.badges.add) || (options.badges.add && options.badges.remove)) { From f24a0a46901a7f3c066a68c17e0f7bc492b2609f Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Fri, 2 Aug 2024 17:15:17 +1000 Subject: [PATCH 124/145] Use a promise-queue for roles task and return file --- src/lib/rolesTask.ts | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/lib/rolesTask.ts b/src/lib/rolesTask.ts index 91482bfae7..9a1ccbfaf0 100644 --- a/src/lib/rolesTask.ts +++ b/src/lib/rolesTask.ts @@ -6,8 +6,9 @@ import { getCollectionItems } from '../lib/data/Collections'; import { Minigames } from '../lib/settings/minigames'; import { Prisma } from '@prisma/client'; +import PQueue from 'p-queue'; import Skills from '../lib/skilling/skills'; -import { Stopwatch, convertXPtoLVL, getUsernameSync } from '../lib/util'; +import { Stopwatch, convertXPtoLVL, getUsernameSync, returnStringOrFile } from '../lib/util'; import { ClueTiers } from './clues/clueTiers'; import { TeamLoot } from './simulation/TeamLoot'; import type { ItemBank } from './types'; @@ -344,6 +345,8 @@ async function globalCL() { export async function runRolesTask(dryRun: boolean) { const results: RoleResult[] = []; + const promiseQueue = new PQueue({ concurrency: 2 }); + const tup = [ ['Top Slayer', fetchSlayerResults], ['Top Clue Hunters', topClueHunters], @@ -356,14 +359,14 @@ export async function runRolesTask(dryRun: boolean) { ['Global CL', globalCL] ] as const; - await Promise.all([ - ...tup.map(async ([name, fn]) => { + for (const [name, fn] of tup) { + promiseQueue.add(async () => { const stopwatch = new Stopwatch(); const res = await fn(); - console.log(`Ran ${name} in ${stopwatch.stop()}`); + console.log(`[RolesTask] Ran ${name} in ${stopwatch.stop()}`); results.push(...res); - }) - ]); + }); + } const allBadgeIDs = uniqueArr(results.map(i => i.badge)); const allRoleIDs = uniqueArr(results.map(i => i.roleID)); @@ -411,7 +414,10 @@ WHERE badges && ${badgeIDs} } } } - return `**Roles**\n${results.map(r => `${getUsernameSync(r.userID)} got ${roleNames.get(r.roleID)} because ${r.reason}`).join('\n')}`; + + return returnStringOrFile( + `**Roles**\n${results.map(r => `${getUsernameSync(r.userID)} got ${roleNames.get(r.roleID)} because ${r.reason}`).join('\n')}` + ); } return 'Dry run'; From dcc3a22ac65086531d3e30d64e84872b04208872 Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Fri, 2 Aug 2024 17:30:17 +1000 Subject: [PATCH 125/145] Roles task improvements/fixes --- src/lib/rolesTask.ts | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/src/lib/rolesTask.ts b/src/lib/rolesTask.ts index 9a1ccbfaf0..48350015f5 100644 --- a/src/lib/rolesTask.ts +++ b/src/lib/rolesTask.ts @@ -368,6 +368,10 @@ export async function runRolesTask(dryRun: boolean) { }); } + await promiseQueue.onIdle(); + + debugLog(`Finished role functions, ${results.length} results`); + const allBadgeIDs = uniqueArr(results.map(i => i.badge)); const allRoleIDs = uniqueArr(results.map(i => i.roleID)); @@ -377,6 +381,8 @@ export async function runRolesTask(dryRun: boolean) { if (!supportServerGuild) throw new Error('No support guild'); // Remove all top badges from all users (and add back later) + + debugLog('Removing badges...'); const badgeIDs = `ARRAY[${allBadgeIDs.join(',')}]`; await prisma.$queryRawUnsafe(` UPDATE users @@ -385,22 +391,31 @@ WHERE badges && ${badgeIDs} `); // Remove roles from ineligible users + debugLog('Remove roles from ineligible users...'); for (const member of supportServerGuild.members.cache.values()) { const rolesToRemove = member.roles.cache.filter(r => allRoleIDs.includes(r.id)); if (rolesToRemove.size > 0) { - await member.roles.remove(rolesToRemove.map(r => r.id)).catch(noOp); + await member.roles.remove(rolesToRemove.map(r => r.id)).catch(console.error); } } // Add roles to users + debugLog('Add roles to users...'); for (const { userID, roleID, badge } of results) { - const role = await supportServerGuild.roles.fetch(roleID).catch(noOp); + const role = await supportServerGuild.roles.fetch(roleID).catch(console.error); const member = await supportServerGuild.members.fetch(userID).catch(noOp); - if (!member || !role) continue; + if (!member) { + debugLog(`Failed to find member ${userID}`); + continue; + } + if (!role) { + debugLog(`Failed to find role ${roleID}`); + continue; + } roleNames.set(roleID, role.name); if (!member.roles.cache.has(roleID)) { - await member.roles.add(roleID).catch(noOp); + await member.roles.add(roleID).catch(console.error); } if (badge) { From cd9f3bec240e000cf636b10be7e8b0fcb61a0fdd Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Fri, 2 Aug 2024 18:14:15 +1000 Subject: [PATCH 126/145] Fix some issues --- src/lib/rolesTask.ts | 1 + src/mahoji/commands/admin.ts | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/lib/rolesTask.ts b/src/lib/rolesTask.ts index 48350015f5..d25934040c 100644 --- a/src/lib/rolesTask.ts +++ b/src/lib/rolesTask.ts @@ -402,6 +402,7 @@ WHERE badges && ${badgeIDs} // Add roles to users debugLog('Add roles to users...'); for (const { userID, roleID, badge } of results) { + if (!userID) continue; const role = await supportServerGuild.roles.fetch(roleID).catch(console.error); const member = await supportServerGuild.members.fetch(userID).catch(noOp); if (!member) { diff --git a/src/mahoji/commands/admin.ts b/src/mahoji/commands/admin.ts index 631d507ccf..595f318521 100644 --- a/src/mahoji/commands/admin.ts +++ b/src/mahoji/commands/admin.ts @@ -864,6 +864,7 @@ export const adminCommand: OSBMahojiCommand = { ${META_CONSTANTS.RENDERED_STR}` }).catch(noOp); import('exit-hook').then(({ gracefulExit }) => gracefulExit(1)); + return; } if (options.shut_down) { debugLog('SHUTTING DOWN'); @@ -873,13 +874,14 @@ ${META_CONSTANTS.RENDERED_STR}` content: `Shutting down in ${dateFm(new Date(Date.now() + timer))}.` }); await economyLog('Flushing economy log due to shutdown', true); - await Promise.all([sleep(timer), GrandExchange.queue.onEmpty()]); + await Promise.all([sleep(timer), GrandExchange.queue.onIdle()]); await sendToChannelID(Channel.GeneralChannel, { content: `I am shutting down! Goodbye :( ${META_CONSTANTS.RENDERED_STR}` }).catch(noOp); import('exit-hook').then(({ gracefulExit }) => gracefulExit(0)); + return; } if (options.sync_blacklist) { From 21a377823052a1543d379932fb3f86bf2f906f32 Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Fri, 2 Aug 2024 18:50:33 +1000 Subject: [PATCH 127/145] Fixes --- src/lib/rolesTask.ts | 4 ++-- src/lib/util/clLeaderboard.ts | 11 ++++++----- src/mahoji/commands/admin.ts | 4 ++-- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/lib/rolesTask.ts b/src/lib/rolesTask.ts index d25934040c..cf2952421f 100644 --- a/src/lib/rolesTask.ts +++ b/src/lib/rolesTask.ts @@ -8,7 +8,7 @@ import { Minigames } from '../lib/settings/minigames'; import { Prisma } from '@prisma/client'; import PQueue from 'p-queue'; import Skills from '../lib/skilling/skills'; -import { Stopwatch, convertXPtoLVL, getUsernameSync, returnStringOrFile } from '../lib/util'; +import { type CommandResponse, Stopwatch, convertXPtoLVL, getUsernameSync, returnStringOrFile } from '../lib/util'; import { ClueTiers } from './clues/clueTiers'; import { TeamLoot } from './simulation/TeamLoot'; import type { ItemBank } from './types'; @@ -342,7 +342,7 @@ async function globalCL() { return results; } -export async function runRolesTask(dryRun: boolean) { +export async function runRolesTask(dryRun: boolean): Promise { const results: RoleResult[] = []; const promiseQueue = new PQueue({ concurrency: 2 }); diff --git a/src/lib/util/clLeaderboard.ts b/src/lib/util/clLeaderboard.ts index fe7faab3bb..175a9c5596 100644 --- a/src/lib/util/clLeaderboard.ts +++ b/src/lib/util/clLeaderboard.ts @@ -33,17 +33,18 @@ export async function fetchMultipleCLLeaderboards( const userIds = Array.from(userEventMap.keys()); const userIdsList = userIds.length > 0 ? userIds.map(i => `'${i}'`).join(', ') : 'NULL'; - return prisma.$queryRawUnsafe<{ id: string; qty: number }[]>(` + const query = ` SELECT id, qty FROM ( SELECT id, CARDINALITY(cl_array & ${SQL_ITEMS}) AS qty FROM users WHERE (cl_array && ${SQL_ITEMS} - ${ironmenOnly ? 'AND "users"."minion.ironman" = true' : ''}) ${userIdsList.length > 0 ? `OR id IN (${userIdsList})` : ''} - LIMIT ${resultLimit} + ${ironmenOnly ? 'AND "users"."minion.ironman" = true' : ''}) ${userIds.length > 0 ? `OR id IN (${userIdsList})` : ''} ) AS subquery -ORDER BY qty DESC; -`); +ORDER BY qty DESC +LIMIT ${resultLimit}; +`; + return prisma.$queryRawUnsafe<{ id: string; qty: number }[]>(query); }) ]); diff --git a/src/mahoji/commands/admin.ts b/src/mahoji/commands/admin.ts index 595f318521..92701fbe01 100644 --- a/src/mahoji/commands/admin.ts +++ b/src/mahoji/commands/admin.ts @@ -864,7 +864,7 @@ export const adminCommand: OSBMahojiCommand = { ${META_CONSTANTS.RENDERED_STR}` }).catch(noOp); import('exit-hook').then(({ gracefulExit }) => gracefulExit(1)); - return; + return 'Turning off...'; } if (options.shut_down) { debugLog('SHUTTING DOWN'); @@ -881,7 +881,7 @@ ${META_CONSTANTS.RENDERED_STR}` ${META_CONSTANTS.RENDERED_STR}` }).catch(noOp); import('exit-hook').then(({ gracefulExit }) => gracefulExit(0)); - return; + return 'Turning off...'; } if (options.sync_blacklist) { From 13836a0ddad23614d5796e895bd3988be2908533 Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Fri, 2 Aug 2024 20:18:01 +1000 Subject: [PATCH 128/145] Roles task fixes --- package.json | 1 + src/lib/rolesTask.ts | 48 +++++++++++++++++++++++++------------------- yarn.lock | 17 ++++++++++++++++ 3 files changed, 45 insertions(+), 21 deletions(-) diff --git a/package.json b/package.json index fbd80a360a..69c6554519 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "p-queue": "^6.6.2", "piscina": "^4.6.1", "random-js": "^2.1.0", + "remeda": "^2.7.0", "simple-statistics": "^7.8.3", "sonic-boom": "^4.0.1", "zlib-sync": "^0.1.9", diff --git a/src/lib/rolesTask.ts b/src/lib/rolesTask.ts index cf2952421f..b9c7d0e375 100644 --- a/src/lib/rolesTask.ts +++ b/src/lib/rolesTask.ts @@ -7,19 +7,22 @@ import { Minigames } from '../lib/settings/minigames'; import { Prisma } from '@prisma/client'; import PQueue from 'p-queue'; -import Skills from '../lib/skilling/skills'; +import { partition } from 'remeda'; +import z from 'zod'; import { type CommandResponse, Stopwatch, convertXPtoLVL, getUsernameSync, returnStringOrFile } from '../lib/util'; import { ClueTiers } from './clues/clueTiers'; import { TeamLoot } from './simulation/TeamLoot'; +import { SkillsArray } from './skilling/types'; import type { ItemBank } from './types'; import { fetchMultipleCLLeaderboards } from './util/clLeaderboard'; -interface RoleResult { - roleID: string; - userID: string; - reason: string; - badge?: (typeof BadgesEnum)[keyof typeof BadgesEnum]; -} +const RoleResultSchema = z.object({ + roleID: z.string().min(17).max(19), + userID: z.string().min(17).max(19), + reason: z.string(), + badge: z.number().int().optional() +}); +type RoleResult = z.infer; const minigames = Minigames.map(game => game.column).filter(i => i !== 'tithe_farm'); @@ -45,24 +48,24 @@ for (const cl of CLS_THAT_GET_ROLE) { async function topSkillers() { const results: RoleResult[] = []; - const skillVals = Object.values(Skills); const [top200TotalXPUsers, ...top200ms] = await prisma.$transaction([ prisma.$queryRawUnsafe( - `SELECT id, ${skillVals.map(s => `"skills.${s.id}"`)}, ${skillVals - .map(s => `"skills.${s.id}"::bigint`) - .join(' + ')} as totalxp FROM users ORDER BY totalxp DESC LIMIT 200;` + `SELECT id, ${SkillsArray.map(s => `"skills.${s}"`)}, ${SkillsArray.map(s => `"skills.${s}"::bigint`).join( + ' + ' + )} as totalxp FROM users ORDER BY totalxp DESC LIMIT 200;` ), - ...skillVals.map(s => - prisma.$queryRawUnsafe<{ + ...SkillsArray.map(s => { + const query = `SELECT id, "skills.${s}" AS xp, '${s}' AS skill FROM users ORDER BY xp DESC LIMIT 1;`; + return prisma.$queryRawUnsafe<{ id: string; xp: string; skill: string; - }>(`SELECT id, "skills.${s.id}" AS xp, '${s.id}' AS skill FROM users ORDER BY xp DESC LIMIT 1;`) - ) + }>(query); + }) ]); - for (const { id, skill } of top200ms) { + for (const { id, skill } of top200ms.flat()) { results.push({ userID: id, roleID: Roles.TopSkiller, @@ -74,8 +77,8 @@ async function topSkillers() { const rankOneTotal = top200TotalXPUsers .map((u: any) => { let totalLevel = 0; - for (const skill of skillVals) { - totalLevel += convertXPtoLVL(Number(u[`skills.${skill.id}` as keyof any]) as any); + for (const skill of SkillsArray) { + totalLevel += convertXPtoLVL(Number(u[`skills.${skill}` as keyof any]) as any); } return { id: u.id, @@ -196,7 +199,7 @@ LIMIT 1;` ) ); - for (const res of topClueHunters) { + for (const res of topClueHunters.flat()) { results.push({ userID: res.user_id, roleID: Roles.TopClueHunter, @@ -364,7 +367,11 @@ export async function runRolesTask(dryRun: boolean): Promise { const stopwatch = new Stopwatch(); const res = await fn(); console.log(`[RolesTask] Ran ${name} in ${stopwatch.stop()}`); - results.push(...res); + const [validResults, invalidResults] = partition(res, i => RoleResultSchema.safeParse(i).success); + results.push(...validResults); + if (invalidResults.length > 0) { + console.error(`[RolesTask] Invalid results for ${name}: ${JSON.stringify(invalidResults)}`); + } }); } @@ -381,7 +388,6 @@ export async function runRolesTask(dryRun: boolean): Promise { if (!supportServerGuild) throw new Error('No support guild'); // Remove all top badges from all users (and add back later) - debugLog('Removing badges...'); const badgeIDs = `ARRAY[${allBadgeIDs.join(',')}]`; await prisma.$queryRawUnsafe(` diff --git a/yarn.lock b/yarn.lock index be7b9c09c5..5a427b1ee5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3972,6 +3972,15 @@ __metadata: languageName: node linkType: hard +"remeda@npm:^2.7.0": + version: 2.7.0 + resolution: "remeda@npm:2.7.0" + dependencies: + type-fest: "npm:^4.21.0" + checksum: 10c0/4e7d0dc616f00961653244ea9df3f297720fc9346ac8ec7502abf4c434741af4a4750d5bd83ea9938ee406089b37e3a2270b8f022d48b345ba83218e47dd8918 + languageName: node + linkType: hard + "require-directory@npm:^2.1.1": version: 2.1.1 resolution: "require-directory@npm:2.1.1" @@ -4148,6 +4157,7 @@ __metadata: prettier: "npm:^3.3.2" prisma: "npm:^5.17.0" random-js: "npm:^2.1.0" + remeda: "npm:^2.7.0" simple-statistics: "npm:^7.8.3" sonic-boom: "npm:^4.0.1" tsx: "npm:^4.16.2" @@ -4613,6 +4623,13 @@ __metadata: languageName: node linkType: hard +"type-fest@npm:^4.21.0": + version: 4.23.0 + resolution: "type-fest@npm:4.23.0" + checksum: 10c0/c42bb14e99329ab37983d1f188e307bf0cc705a23807d9b2268d8fb2ae781d610ac6e2058dde8f9ea2b1b8ddc77ceb578d157fa81f69f8f70aef1d42fb002996 + languageName: node + linkType: hard + "typescript@npm:^5.2.2, typescript@npm:^5.5.3": version: 5.5.3 resolution: "typescript@npm:5.5.3" From f3d124d0163017257e45cc54ebb7fbe0ea35e775 Mon Sep 17 00:00:00 2001 From: nwjgit <69014816+nwjgit@users.noreply.github.com> Date: Tue, 6 Aug 2024 05:15:22 -0500 Subject: [PATCH 129/145] Fix CA & few adjustments (#5981) --- src/lib/combat_achievements/combatAchievements.ts | 8 ++++---- src/lib/combat_achievements/hard.ts | 2 +- src/lib/combat_achievements/medium.ts | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/lib/combat_achievements/combatAchievements.ts b/src/lib/combat_achievements/combatAchievements.ts index 5545b07c0d..5aef785fb0 100644 --- a/src/lib/combat_achievements/combatAchievements.ts +++ b/src/lib/combat_achievements/combatAchievements.ts @@ -162,13 +162,13 @@ const indexesWithRng = entries.flatMap(i => i[1].tasks.filter(t => 'rng' in t)); export const combatAchievementTripEffect = async ({ data, messages, user }: Parameters[0]) => { const dataCopy = deepClone(data); if (dataCopy.type === 'Inferno' && !dataCopy.diedPreZuk && !dataCopy.diedZuk) { - (dataCopy as any).quantity = 1; + (dataCopy as any).q = 1; } if (dataCopy.type === 'Colosseum') { - (dataCopy as any).quantity = 1; + (dataCopy as any).q = 1; } - if (!('quantity' in dataCopy)) return; - let quantity = Number(dataCopy.quantity); + if (!('q' in dataCopy)) return; + let quantity = Number(dataCopy.q); if (Number.isNaN(quantity)) return; if (data.type === 'TombsOfAmascut') { diff --git a/src/lib/combat_achievements/hard.ts b/src/lib/combat_achievements/hard.ts index 82f33c3077..1522239efb 100644 --- a/src/lib/combat_achievements/hard.ts +++ b/src/lib/combat_achievements/hard.ts @@ -597,7 +597,7 @@ export const hardCombatAchievements: CombatAchievement[] = [ monster: 'Tempoross', desc: 'Subdue Tempoross, getting rewarded with 10 reward permits from a single Tempoross fight.', rng: { - chancePerKill: 30, + chancePerKill: 5, hasChance: data => data.type === 'Tempoross' } }, diff --git a/src/lib/combat_achievements/medium.ts b/src/lib/combat_achievements/medium.ts index 420f3c1b43..30d8255701 100644 --- a/src/lib/combat_achievements/medium.ts +++ b/src/lib/combat_achievements/medium.ts @@ -431,7 +431,7 @@ export const mediumCombatAchievements: CombatAchievement[] = [ monster: 'Skotizo', desc: 'Kill Skotizo with no altars active.', rng: { - chancePerKill: 15, + chancePerKill: 5, hasChance: isCertainMonsterTrip(Monsters.Skotizo.id) } }, From d0193ffa25f84f5be1bd10918eb19969151bd38f Mon Sep 17 00:00:00 2001 From: GC <30398469+gc@users.noreply.github.com> Date: Tue, 6 Aug 2024 20:52:47 +1000 Subject: [PATCH 130/145] Fix docker (#5987) --- .github/workflows/integration_tests.yml | 2 +- package.json | 2 +- tests/integration/redis.test.ts | 8 ++++---- yarn.lock | 8 ++++---- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/integration_tests.yml b/.github/workflows/integration_tests.yml index 165927f7c5..5442864076 100644 --- a/.github/workflows/integration_tests.yml +++ b/.github/workflows/integration_tests.yml @@ -20,4 +20,4 @@ jobs: uses: docker/setup-buildx-action@v3 - name: Run Integration Tests - run: docker-compose up --build --abort-on-container-exit + run: docker compose up --build --abort-on-container-exit --remove-orphans && docker compose down --volumes --remove-orphans diff --git a/package.json b/package.json index 69c6554519..0e160aa765 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ }, "dependencies": { "@napi-rs/canvas": "^0.1.53", - "@oldschoolgg/toolkit": "git+https://github.com/oldschoolgg/toolkit.git#2813f25327093fcf2cb12bee7d4c85ce629069a0", + "@oldschoolgg/toolkit": "git+https://github.com/oldschoolgg/toolkit.git#cd7c6865229ca7dc4a66b3816586f2d3f4a4fbed", "@prisma/client": "^5.17.0", "@sapphire/ratelimits": "^2.4.9", "@sapphire/snowflake": "^3.5.3", diff --git a/tests/integration/redis.test.ts b/tests/integration/redis.test.ts index 7584d1bd67..1407d57583 100644 --- a/tests/integration/redis.test.ts +++ b/tests/integration/redis.test.ts @@ -15,7 +15,7 @@ test('Should add patron badge', async () => { const user = await createTestUser(); expect(user.user.badges).not.includes(BadgesEnum.Patron); const _redis = makeSender(); - _redis.publish({ + await _redis.publish({ type: 'patron_tier_change', discord_ids: [user.id], new_tier: 1, @@ -31,7 +31,7 @@ test('Should remove patron badge', async () => { const user = await createTestUser(undefined, { badges: [BadgesEnum.Patron] }); expect(user.user.badges).includes(BadgesEnum.Patron); const _redis = makeSender(); - _redis.publish({ + await _redis.publish({ type: 'patron_tier_change', discord_ids: [user.id], new_tier: 0, @@ -52,7 +52,7 @@ test('Should add to cache', async () => { })) }); const _redis = makeSender(); - _redis.publish({ + await _redis.publish({ type: 'patron_tier_change', discord_ids: users.map(u => u.id), new_tier: 5, @@ -76,7 +76,7 @@ test('Should remove from cache', async () => { })) }); const _redis = makeSender(); - _redis.publish({ + await _redis.publish({ type: 'patron_tier_change', discord_ids: users.map(u => u.id), new_tier: 0, diff --git a/yarn.lock b/yarn.lock index 5a427b1ee5..5dfb8015f6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -646,9 +646,9 @@ __metadata: languageName: node linkType: hard -"@oldschoolgg/toolkit@git+https://github.com/oldschoolgg/toolkit.git#2813f25327093fcf2cb12bee7d4c85ce629069a0": +"@oldschoolgg/toolkit@git+https://github.com/oldschoolgg/toolkit.git#cd7c6865229ca7dc4a66b3816586f2d3f4a4fbed": version: 0.0.24 - resolution: "@oldschoolgg/toolkit@https://github.com/oldschoolgg/toolkit.git#commit=2813f25327093fcf2cb12bee7d4c85ce629069a0" + resolution: "@oldschoolgg/toolkit@https://github.com/oldschoolgg/toolkit.git#commit=cd7c6865229ca7dc4a66b3816586f2d3f4a4fbed" dependencies: decimal.js: "npm:^10.4.3" deep-object-diff: "npm:^1.1.9" @@ -664,7 +664,7 @@ __metadata: peerDependencies: discord.js: ^14.15.3 oldschooljs: ^2.5.9 - checksum: 10c0/c83f2188e18ac1e7d79edd9ab06b7ab0f96f8774a404a26fd766d22606bd65a8def0c0a74d0f681c5bde0d7b5b5bbb16f829e751b4e75f6821a1baf5c62b2580 + checksum: 10c0/42eaec1c99c671adab7b56ca7e11d37bf5a0e07d0f0da0a892cdf477a78c061ea131a43b1c578d09f1c6b02e05d1ce47db9586ad9a8de62679cc492c847c3fca languageName: node linkType: hard @@ -4122,7 +4122,7 @@ __metadata: dependencies: "@biomejs/biome": "npm:^1.8.3" "@napi-rs/canvas": "npm:^0.1.53" - "@oldschoolgg/toolkit": "git+https://github.com/oldschoolgg/toolkit.git#2813f25327093fcf2cb12bee7d4c85ce629069a0" + "@oldschoolgg/toolkit": "git+https://github.com/oldschoolgg/toolkit.git#cd7c6865229ca7dc4a66b3816586f2d3f4a4fbed" "@prisma/client": "npm:^5.17.0" "@sapphire/ratelimits": "npm:^2.4.9" "@sapphire/snowflake": "npm:^3.5.3" From cc17c1cb45661fdd1719f105ea56083a2200d655 Mon Sep 17 00:00:00 2001 From: GC <30398469+gc@users.noreply.github.com> Date: Tue, 6 Aug 2024 21:20:58 +1000 Subject: [PATCH 131/145] Add rebase cmd action (#5988) --- .github/workflows/rebase_command.yml | 20 ++++++++++++++++++++ .github/workflows/slash_command_dispatch.yml | 15 +++++++++++++++ 2 files changed, 35 insertions(+) create mode 100644 .github/workflows/rebase_command.yml create mode 100644 .github/workflows/slash_command_dispatch.yml diff --git a/.github/workflows/rebase_command.yml b/.github/workflows/rebase_command.yml new file mode 100644 index 0000000000..5ec001e848 --- /dev/null +++ b/.github/workflows/rebase_command.yml @@ -0,0 +1,20 @@ +name: rebase-command +on: + repository_dispatch: + types: [rebase-command] +jobs: + rebase: + runs-on: ubuntu-latest + steps: + - uses: peter-evans/rebase@v3 + id: rebase + with: + head: ${{ github.event.client_payload.pull_request.head.label }} + - name: Add reaction + if: steps.rebase.outputs.rebased-count == 1 + uses: peter-evans/create-or-update-comment@v1 + with: + token: ${{ secrets.REBASE_TOKEN }} + repository: ${{ github.event.client_payload.github.payload.repository.full_name }} + comment-id: ${{ github.event.client_payload.github.payload.comment.id }} + reaction-type: hooray diff --git a/.github/workflows/slash_command_dispatch.yml b/.github/workflows/slash_command_dispatch.yml new file mode 100644 index 0000000000..fe6aa33450 --- /dev/null +++ b/.github/workflows/slash_command_dispatch.yml @@ -0,0 +1,15 @@ +name: Slash Command Dispatch +on: + issue_comment: + types: [created] +jobs: + slashCommandDispatch: + runs-on: ubuntu-latest + steps: + - name: Slash Command Dispatch + uses: peter-evans/slash-command-dispatch@v3 + with: + token: ${{ secrets.REBASE_TOKEN }} + commands: rebase + permission: write + issue-type: pull-request From ee2be2d73443c174995cf5c862d65e860d3dfd67 Mon Sep 17 00:00:00 2001 From: GC <30398469+gc@users.noreply.github.com> Date: Tue, 6 Aug 2024 22:03:49 +1000 Subject: [PATCH 132/145] Remove rebase command stuff (#5989) --- .github/workflows/rebase_command.yml | 20 -------------------- .github/workflows/slash_command_dispatch.yml | 15 --------------- 2 files changed, 35 deletions(-) delete mode 100644 .github/workflows/rebase_command.yml delete mode 100644 .github/workflows/slash_command_dispatch.yml diff --git a/.github/workflows/rebase_command.yml b/.github/workflows/rebase_command.yml deleted file mode 100644 index 5ec001e848..0000000000 --- a/.github/workflows/rebase_command.yml +++ /dev/null @@ -1,20 +0,0 @@ -name: rebase-command -on: - repository_dispatch: - types: [rebase-command] -jobs: - rebase: - runs-on: ubuntu-latest - steps: - - uses: peter-evans/rebase@v3 - id: rebase - with: - head: ${{ github.event.client_payload.pull_request.head.label }} - - name: Add reaction - if: steps.rebase.outputs.rebased-count == 1 - uses: peter-evans/create-or-update-comment@v1 - with: - token: ${{ secrets.REBASE_TOKEN }} - repository: ${{ github.event.client_payload.github.payload.repository.full_name }} - comment-id: ${{ github.event.client_payload.github.payload.comment.id }} - reaction-type: hooray diff --git a/.github/workflows/slash_command_dispatch.yml b/.github/workflows/slash_command_dispatch.yml deleted file mode 100644 index fe6aa33450..0000000000 --- a/.github/workflows/slash_command_dispatch.yml +++ /dev/null @@ -1,15 +0,0 @@ -name: Slash Command Dispatch -on: - issue_comment: - types: [created] -jobs: - slashCommandDispatch: - runs-on: ubuntu-latest - steps: - - name: Slash Command Dispatch - uses: peter-evans/slash-command-dispatch@v3 - with: - token: ${{ secrets.REBASE_TOKEN }} - commands: rebase - permission: write - issue-type: pull-request From 23a85fed09c2db87faa2b36b8b72f8e4d19029fd Mon Sep 17 00:00:00 2001 From: Keres <88959451+DaughtersOfNyx@users.noreply.github.com> Date: Tue, 6 Aug 2024 22:04:17 +1000 Subject: [PATCH 133/145] Fix incorrect command name in Hunt (#5986) --- src/mahoji/lib/abstracted_commands/mageTrainingArenaCommand.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mahoji/lib/abstracted_commands/mageTrainingArenaCommand.ts b/src/mahoji/lib/abstracted_commands/mageTrainingArenaCommand.ts index 1ee8cb5719..3c71531837 100644 --- a/src/mahoji/lib/abstracted_commands/mageTrainingArenaCommand.ts +++ b/src/mahoji/lib/abstracted_commands/mageTrainingArenaCommand.ts @@ -113,7 +113,7 @@ ${mageTrainingArenaBuyables .map(i => `${i.item.name} - ${i.cost} pts - ${formatDuration((i.cost / pizazzPointsPerHour) * (Time.Minute * 60))}`) .join('\n')} -Hint: Magic Training Arena is combined into 1 room, and 1 set of points - rewards take approximately the same amount of time to get. To get started use **/minigames mage_training_arena train**. You can buy rewards using **/minigames mage_training_arena buy**.`; +Hint: Magic Training Arena is combined into 1 room, and 1 set of points - rewards take approximately the same amount of time to get. To get started use **/minigames mage_training_arena start**. You can buy rewards using **/minigames mage_training_arena buy**.`; } export async function mageTrainingArenaStartCommand(user: MUser, channelID: string): CommandResponse { From cf085e45c6b1357d0956326ee3a0b09950313c22 Mon Sep 17 00:00:00 2001 From: Keres <88959451+DaughtersOfNyx@users.noreply.github.com> Date: Tue, 6 Aug 2024 22:13:25 +1000 Subject: [PATCH 134/145] Fix typo in the finished hunting message (#5978) --- src/tasks/minions/HunterActivity/hunterActivity.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tasks/minions/HunterActivity/hunterActivity.ts b/src/tasks/minions/HunterActivity/hunterActivity.ts index 914d29dfdd..7a333ffdc2 100644 --- a/src/tasks/minions/HunterActivity/hunterActivity.ts +++ b/src/tasks/minions/HunterActivity/hunterActivity.ts @@ -179,7 +179,7 @@ export const hunterTask: MinionTask = { let str = `${user}, ${user.minionName} finished hunting ${creature.name}${ crystalImpling ? '.' - : `${quantity}x times, due to clever creatures you missed out on ${ + : ` ${quantity}x times, due to clever creatures you missed out on ${ quantity - successfulQuantity }x catches. ` }${xpStr}\n\nYou received: ${loot}.${magicSecStr.length > 1 ? magicSecStr : ''}`; From eb0dc0ad30a25e7eed7246a646f57cea36ab8f08 Mon Sep 17 00:00:00 2001 From: nwjgit <69014816+nwjgit@users.noreply.github.com> Date: Tue, 6 Aug 2024 07:15:39 -0500 Subject: [PATCH 135/145] Allow multiple combat options for PVM trips (#5962) --- .../data/killableMonsters/vannakaMonsters.ts | 1 - src/lib/minions/functions/index.ts | 4 +- src/lib/minions/types.ts | 2 +- src/lib/slayer/slayerUtil.ts | 67 +++++++++++-------- .../abstracted_commands/autoSlayCommand.ts | 18 ++--- .../lib/abstracted_commands/minionKill.ts | 49 ++++++++------ 6 files changed, 80 insertions(+), 61 deletions(-) diff --git a/src/lib/minions/data/killableMonsters/vannakaMonsters.ts b/src/lib/minions/data/killableMonsters/vannakaMonsters.ts index d6c0feb8b6..23e6c5ff52 100644 --- a/src/lib/minions/data/killableMonsters/vannakaMonsters.ts +++ b/src/lib/minions/data/killableMonsters/vannakaMonsters.ts @@ -243,7 +243,6 @@ export const vannakaMonsters: KillableMonster[] = [ healAmountNeeded: 12, attackStyleToUse: GearStat.AttackRanged, attackStylesUsed: [GearStat.AttackMagic], - canCannon: true, pkActivityRating: 4, pkBaseDeathChance: 6, revsWeaponBoost: true diff --git a/src/lib/minions/functions/index.ts b/src/lib/minions/functions/index.ts index f3bd7b49eb..2464f0acec 100644 --- a/src/lib/minions/functions/index.ts +++ b/src/lib/minions/functions/index.ts @@ -50,7 +50,7 @@ export function resolveAttackStyles( // Automatically use magic if barrage/burst is chosen if ( params.boostMethod && - (params.boostMethod === 'barrage' || params.boostMethod === 'burst') && + (params.boostMethod.includes('barrage') || params.boostMethod.includes('burst')) && !attackStyles.includes(SkillsEnum.Magic) ) { if (attackStyles.includes(SkillsEnum.Defence)) { @@ -63,7 +63,7 @@ export function resolveAttackStyles( } export async function addMonsterXP(user: MUser, params: AddMonsterXpParams) { - const boostMethod = params.burstOrBarrage ? 'barrage' : 'none'; + const boostMethod = params.burstOrBarrage ? ['barrage'] : ['none']; const [, osjsMon, attackStyles] = resolveAttackStyles(user, { monsterID: params.monsterID, diff --git a/src/lib/minions/types.ts b/src/lib/minions/types.ts index e5efadbb39..cc81695e13 100644 --- a/src/lib/minions/types.ts +++ b/src/lib/minions/types.ts @@ -177,7 +177,7 @@ export interface AddMonsterXpParams { export interface ResolveAttackStylesParams { monsterID: number | undefined; - boostMethod?: string; + boostMethod?: string[]; } export interface BlowpipeData { diff --git a/src/lib/slayer/slayerUtil.ts b/src/lib/slayer/slayerUtil.ts index 8ed9e4cdbb..c1d9d5cc4e 100644 --- a/src/lib/slayer/slayerUtil.ts +++ b/src/lib/slayer/slayerUtil.ts @@ -34,42 +34,53 @@ interface DetermineBoostParams { cbOpts: CombatOptionsEnum[]; user: MUser; monster: KillableMonster; - method?: PvMMethod | null; + methods?: PvMMethod[] | null; isOnTask?: boolean; wildyBurst?: boolean; } -export function determineBoostChoice(params: DetermineBoostParams) { - let boostChoice = 'none'; +export function determineCombatBoosts(params: DetermineBoostParams) { + // if EHP slayer (PvMMethod) the methods are initialized with boostMethods variable + const boostMethods = (params.methods ?? ['none']).flat().filter(method => method); - if (params.method && params.method === 'none') { - return boostChoice; + // check if user has cannon combat option turned on + if (params.cbOpts.includes(CombatOptionsEnum.AlwaysCannon)) { + boostMethods.includes('cannon') ? null : boostMethods.push('cannon'); } - if (params.method && params.method === 'chinning') { - boostChoice = 'chinning'; - } else if (params.method && params.method === 'barrage') { - boostChoice = 'barrage'; - } else if (params.method && params.method === 'burst') { - boostChoice = 'burst'; - } else if (params.method && params.method === 'cannon') { - boostChoice = 'cannon'; - } else if ( - params.cbOpts.includes(CombatOptionsEnum.AlwaysIceBarrage) && - (params.monster?.canBarrage || params.wildyBurst) - ) { - boostChoice = 'barrage'; - } else if ( - params.cbOpts.includes(CombatOptionsEnum.AlwaysIceBurst) && - (params.monster?.canBarrage || params.wildyBurst) - ) { - boostChoice = 'burst'; - } else if (params.cbOpts.includes(CombatOptionsEnum.AlwaysCannon)) { - boostChoice = 'cannon'; + + // check for special burst case under wildyBurst variable + if (params.wildyBurst) { + if (params.cbOpts.includes(CombatOptionsEnum.AlwaysIceBarrage)) { + boostMethods.includes('barrage') ? null : boostMethods.push('barrage'); + } + if (params.cbOpts.includes(CombatOptionsEnum.AlwaysIceBurst)) { + boostMethods.includes('burst') ? null : boostMethods.push('burst'); + } } - if (boostChoice === 'barrage' && params.user.skillLevel(SkillsEnum.Magic) < 94) { - boostChoice = 'burst'; + // check if the monster can be barraged + if (params.monster.canBarrage) { + // check if the monster exists in catacombs + if (params.monster.existsInCatacombs) { + if (params.cbOpts.includes(CombatOptionsEnum.AlwaysIceBarrage)) { + boostMethods.includes('barrage') ? null : boostMethods.push('barrage'); + } + if (params.cbOpts.includes(CombatOptionsEnum.AlwaysIceBurst)) { + boostMethods.includes('burst') ? null : boostMethods.push('burst'); + } + } else if (!params.monster.cannonMulti) { + // prevents cases such as: cannoning in singles but receiving multi combat bursting boost + return boostMethods; + } else { + if (params.cbOpts.includes(CombatOptionsEnum.AlwaysIceBarrage)) { + boostMethods.includes('barrage') ? null : boostMethods.push('barrage'); + } + if (params.cbOpts.includes(CombatOptionsEnum.AlwaysIceBurst)) { + boostMethods.includes('burst') ? null : boostMethods.push('burst'); + } + } } - return boostChoice; + + return boostMethods; } export async function calculateSlayerPoints(currentStreak: number, master: SlayerMaster, user: MUser) { diff --git a/src/mahoji/lib/abstracted_commands/autoSlayCommand.ts b/src/mahoji/lib/abstracted_commands/autoSlayCommand.ts index 6d2d7183dc..f6e99e8ad3 100644 --- a/src/mahoji/lib/abstracted_commands/autoSlayCommand.ts +++ b/src/mahoji/lib/abstracted_commands/autoSlayCommand.ts @@ -17,7 +17,7 @@ interface AutoslayLink { // Name and Monster must be specified if either is. efficientName?: string; efficientMonster?: number; - efficientMethod?: PvMMethod; + efficientMethod?: PvMMethod | PvMMethod[]; slayerMasters?: SlayerMasterEnum[]; } @@ -147,7 +147,7 @@ const AutoSlayMaxEfficiencyTable: AutoslayLink[] = [ monsterID: Monsters.SmokeDevil.id, efficientName: Monsters.SmokeDevil.name, efficientMonster: Monsters.SmokeDevil.id, - efficientMethod: 'barrage' + efficientMethod: ['barrage', 'cannon'] }, { monsterID: Monsters.DarkBeast.id, @@ -216,13 +216,13 @@ const WildyAutoSlayMaxEfficiencyTable: AutoslayLink[] = [ monsterID: Monsters.AbyssalDemon.id, efficientName: Monsters.AbyssalDemon.name, efficientMonster: Monsters.AbyssalDemon.id, - efficientMethod: 'barrage' + efficientMethod: ['barrage', 'cannon'] }, { monsterID: Monsters.Ankou.id, efficientName: Monsters.Ankou.name, efficientMonster: Monsters.Ankou.id, - efficientMethod: 'barrage' + efficientMethod: ['barrage', 'cannon'] }, { monsterID: Monsters.BlackDemon.id, @@ -240,7 +240,7 @@ const WildyAutoSlayMaxEfficiencyTable: AutoslayLink[] = [ monsterID: Monsters.Bloodveld.id, efficientName: Monsters.Bloodveld.name, efficientMonster: Monsters.Bloodveld.id, - efficientMethod: 'barrage' + efficientMethod: 'none' }, { monsterID: Monsters.ChaosDruid.id, @@ -264,7 +264,7 @@ const WildyAutoSlayMaxEfficiencyTable: AutoslayLink[] = [ monsterID: Monsters.DustDevil.id, efficientName: Monsters.DustDevil.name, efficientMonster: Monsters.DustDevil.id, - efficientMethod: 'barrage' + efficientMethod: ['barrage', 'cannon'] }, { monsterID: Monsters.ElderChaosDruid.id, @@ -318,7 +318,7 @@ const WildyAutoSlayMaxEfficiencyTable: AutoslayLink[] = [ monsterID: Monsters.Jelly.id, efficientName: Monsters.Jelly.name, efficientMonster: Monsters.Jelly.id, - efficientMethod: 'barrage' + efficientMethod: ['barrage', 'cannon'] }, { monsterID: Monsters.LesserDemon.id, @@ -348,7 +348,7 @@ const WildyAutoSlayMaxEfficiencyTable: AutoslayLink[] = [ monsterID: Monsters.GreaterNechryael.id, efficientName: Monsters.GreaterNechryael.name, efficientMonster: Monsters.GreaterNechryael.id, - efficientMethod: 'barrage' + efficientMethod: ['barrage', 'cannon'] }, { monsterID: Monsters.RevenantImp.id, @@ -485,7 +485,7 @@ export async function autoSlayCommand({ name: ehpMonster.efficientName }; if (ehpMonster.efficientMethod) { - args.method = ehpMonster.efficientMethod; + args.method = ehpMonster.efficientMethod as unknown as CommandOptions; } runCommand({ commandName: 'k', diff --git a/src/mahoji/lib/abstracted_commands/minionKill.ts b/src/mahoji/lib/abstracted_commands/minionKill.ts index 94bd7ad621..f729fee9a7 100644 --- a/src/mahoji/lib/abstracted_commands/minionKill.ts +++ b/src/mahoji/lib/abstracted_commands/minionKill.ts @@ -48,7 +48,7 @@ import type { Consumable } from '../../../lib/minions/types'; import { calcPOHBoosts } from '../../../lib/poh'; import { SkillsEnum } from '../../../lib/skilling/types'; import { SlayerTaskUnlocksEnum } from '../../../lib/slayer/slayerUnlocks'; -import { determineBoostChoice, getUsersCurrentSlayerInfo } from '../../../lib/slayer/slayerUtil'; +import { determineCombatBoosts, getUsersCurrentSlayerInfo } from '../../../lib/slayer/slayerUtil'; import { maxOffenceStats } from '../../../lib/structures/Gear'; import type { Peak } from '../../../lib/tickers'; import type { MonsterActivityTaskOptions } from '../../../lib/types/minions'; @@ -134,7 +134,7 @@ export async function minionKillCommand( channelID: string, name: string, quantity: number | undefined, - method: PvMMethod | undefined, + method: PvMMethod | PvMMethod[] | undefined, wilderness: boolean | undefined, solo: boolean | undefined ): Promise { @@ -206,19 +206,18 @@ export async function minionKillCommand( } } - // Add jelly check as can barrage in wilderness + // Add check for burstable monsters in wilderness const jelly = monster.id === Monsters.Jelly.id; - const bloodveld = monster.id === Monsters.Bloodveld.id; + const wildyBurst = jelly && isInWilderness; - const wildyBurst = (jelly || bloodveld) && isInWilderness; - - // Set chosen boost based on priority: + // determines what pvm methods the user can use const myCBOpts = user.combatOptions; - const boostChoice = determineBoostChoice({ + const methods = [method] as PvMMethod[]; + const combatMethods = determineCombatBoosts({ cbOpts: myCBOpts as CombatOptionsEnum[], user, monster, - method, + methods, isOnTask, wildyBurst }); @@ -239,7 +238,7 @@ export async function minionKillCommand( const [, osjsMon, attackStyles] = resolveAttackStyles(user, { monsterID: monster.id, - boostMethod: boostChoice + boostMethod: combatMethods }); const [newTime, skillBoostMsg] = applySkillBoost(user, timeToFinish, attackStyles); @@ -457,21 +456,25 @@ export async function minionKillCommand( } // Check for stats - if (boostChoice === 'barrage' && user.skillLevel(SkillsEnum.Magic) < 94) { + if (combatMethods.includes('barrage') && user.skillLevel(SkillsEnum.Magic) < 94) { return `You need 94 Magic to use Ice Barrage. You have ${user.skillLevel(SkillsEnum.Magic)}`; } - if (boostChoice === 'burst' && user.skillLevel(SkillsEnum.Magic) < 70) { + if (combatMethods.includes('burst') && user.skillLevel(SkillsEnum.Magic) < 70) { return `You need 70 Magic to use Ice Burst. You have ${user.skillLevel(SkillsEnum.Magic)}`; } - if (boostChoice === 'chinning' && user.skillLevel(SkillsEnum.Ranged) < 65) { + if (combatMethods.includes('chinning') && user.skillLevel(SkillsEnum.Ranged) < 65) { return `You need 65 Ranged to use Chinning method. You have ${user.skillLevel(SkillsEnum.Ranged)}`; } - // Wildy Monster checks - if (isInWilderness === true && boostChoice === 'cannon') { + // Wildy monster cannon checks + if (isInWilderness === true && combatMethods.includes('cannon')) { if (monster.id === Monsters.HillGiant.id || monster.id === Monsters.MossGiant.id) { usingCannon = isInWilderness; } + if (monster.id === Monsters.Spider.id || Monsters.Scorpion.id) { + usingCannon = isInWilderness; + cannonMulti = isInWilderness; + } if (monster.wildySlayerCave) { usingCannon = isInWilderness; cannonMulti = isInWilderness; @@ -482,8 +485,9 @@ export async function minionKillCommand( } } + // Burst/barrage check with wilderness conditions if ((method === 'burst' || method === 'barrage') && !monster?.canBarrage) { - if (jelly || bloodveld) { + if (jelly) { if (!isInWilderness) { return `${monster.name} can only be barraged or burst in the wilderness.`; } @@ -496,14 +500,18 @@ export async function minionKillCommand( } } - if (boostChoice === 'barrage' && attackStyles.includes(SkillsEnum.Magic) && (monster?.canBarrage || wildyBurst)) { + if ( + combatMethods.includes('barrage') && + attackStyles.includes(SkillsEnum.Magic) && + (monster?.canBarrage || wildyBurst) + ) { consumableCosts.push(iceBarrageConsumables); calculateVirtusBoost(); timeToFinish = reduceNumByPercent(timeToFinish, boostIceBarrage + virtusBoost); boosts.push(`${boostIceBarrage + virtusBoost}% for Ice Barrage${virtusBoostMsg}`); burstOrBarrage = SlayerActivityConstants.IceBarrage; } else if ( - boostChoice === 'burst' && + combatMethods.includes('burst') && attackStyles.includes(SkillsEnum.Magic) && (monster?.canBarrage || wildyBurst) ) { @@ -512,13 +520,14 @@ export async function minionKillCommand( timeToFinish = reduceNumByPercent(timeToFinish, boostIceBurst + virtusBoost); boosts.push(`${boostIceBurst + virtusBoost}% for Ice Burst${virtusBoostMsg}`); burstOrBarrage = SlayerActivityConstants.IceBurst; - } else if ((boostChoice === 'cannon' && hasCannon && monster?.cannonMulti) || cannonMulti) { + } + if ((combatMethods.includes('cannon') && hasCannon && monster?.cannonMulti) || cannonMulti) { usingCannon = true; cannonMulti = true; consumableCosts.push(cannonMultiConsumables); timeToFinish = reduceNumByPercent(timeToFinish, boostCannonMulti); boosts.push(`${boostCannonMulti}% for Cannon in multi`); - } else if ((boostChoice === 'cannon' && hasCannon && monster?.canCannon) || usingCannon) { + } else if ((combatMethods.includes('cannon') && hasCannon && monster?.canCannon) || usingCannon) { usingCannon = true; consumableCosts.push(cannonSingleConsumables); timeToFinish = reduceNumByPercent(timeToFinish, boostCannon); From 99adf7682c7213a33c1fb33158ed455795ee5d7e Mon Sep 17 00:00:00 2001 From: Keres <88959451+DaughtersOfNyx@users.noreply.github.com> Date: Tue, 6 Aug 2024 22:16:05 +1000 Subject: [PATCH 136/145] Remove prospector items from Falador Hard Diary req (#5977) --- src/lib/diaries.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/lib/diaries.ts b/src/lib/diaries.ts index d74961b72a..861361f43d 100644 --- a/src/lib/diaries.ts +++ b/src/lib/diaries.ts @@ -468,13 +468,7 @@ export const FaladorDiary: Diary = { woodcutting: 71 }, qp: 32, - collectionLogReqs: resolveItems([ - 'Mind rune', - 'Prospector jacket', - 'Prospector helmet', - 'Prospector legs', - 'Prospector boots' - ]), + collectionLogReqs: resolveItems(['Mind rune', 'Prospector helmet']), monsterScores: { 'Skeletal Wyvern': 1, 'Blue Dragon': 1 From 7816fe44e994a3fb7bcdb425d99851e1814bfb00 Mon Sep 17 00:00:00 2001 From: TastyPumPum <79149170+TastyPumPum@users.noreply.github.com> Date: Tue, 6 Aug 2024 13:19:10 +0100 Subject: [PATCH 137/145] Add Valamore Farming Patches (#5966) --- src/lib/skilling/functions/calcsFarming.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/lib/skilling/functions/calcsFarming.ts b/src/lib/skilling/functions/calcsFarming.ts index 170e12dd98..a234f023fe 100644 --- a/src/lib/skilling/functions/calcsFarming.ts +++ b/src/lib/skilling/functions/calcsFarming.ts @@ -1,5 +1,6 @@ import { randInt } from 'e'; +import { QuestID } from '../../minions/data/quests'; import type { Plant } from '../types'; import { SkillsEnum } from '../types'; @@ -28,6 +29,19 @@ export function calcNumOfPatches(plant: Plant, user: MUser, qp: number): [number break; } } + + if (user.user.finished_quest_ids.includes(QuestID.ChildrenOfTheSun)) { + switch (plant.seedType) { + case 'allotment': + numOfPatches += 2; + break; + case 'herb': + case 'flower': + numOfPatches += 1; + break; + } + } + return [numOfPatches]; } From bdc6f788205c59a273686cfbf954662f872bbdca Mon Sep 17 00:00:00 2001 From: nwjgit <69014816+nwjgit@users.noreply.github.com> Date: Tue, 6 Aug 2024 07:22:46 -0500 Subject: [PATCH 138/145] quest/lamp fixes (#5949) --- src/lib/data/itemAliases.ts | 5 ++++- src/lib/minions/data/quests.ts | 5 ++--- src/mahoji/lib/abstracted_commands/lampCommand.ts | 10 ++++++++-- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/lib/data/itemAliases.ts b/src/lib/data/itemAliases.ts index ec44fda255..557bd0b85f 100644 --- a/src/lib/data/itemAliases.ts +++ b/src/lib/data/itemAliases.ts @@ -178,12 +178,15 @@ setItemAlias(2993, 'Chompy bird hat (dragon archer)'); setItemAlias(2994, 'Chompy bird hat (expert ogre dragon archer)'); setItemAlias(2995, 'Chompy bird hat (expert dragon archer)'); -// Item aliases +// Achievement diary lamps setItemAlias(11_137, 'Antique lamp 1'); setItemAlias(11_139, 'Antique lamp 2'); setItemAlias(11_141, 'Antique lamp 3'); setItemAlias(11_185, 'Antique lamp 4'); +// Defender of varrock quest lamp +setItemAlias(28_820, 'Antique lamp (defender of varrock)'); + // Dragonfire shields setItemAlias(11_284, 'Uncharged dragonfire shield'); setItemAlias(11_283, 'Dragonfire shield'); diff --git a/src/lib/minions/data/quests.ts b/src/lib/minions/data/quests.ts index 70451b6ac9..91a92ec0cb 100644 --- a/src/lib/minions/data/quests.ts +++ b/src/lib/minions/data/quests.ts @@ -84,7 +84,7 @@ export const quests: Quest[] = [ }, combatLevelReq: 50, qpReq: 10, - rewards: new Bank().add(28_587).add(28_587).add(28_588).add(28_589).add(28_590).freeze(), + rewards: new Bank().add(28_587).add(28_588).add(28_589).add(28_590).freeze(), calcTime: (user: MUser) => { let duration = Time.Minute * 10; if (user.combatLevel < 90) { @@ -112,8 +112,7 @@ export const quests: Quest[] = [ }, combatLevelReq: 65, qpReq: 20, - // Awaiting item update for the lamp to be added - // rewards: new Bank().add(28_820).freeze(), + rewards: new Bank().add(28_820).freeze(), skillsRewards: { smithing: 15_000, hunter: 15_000 diff --git a/src/mahoji/lib/abstracted_commands/lampCommand.ts b/src/mahoji/lib/abstracted_commands/lampCommand.ts index 2a5d9bb0b3..db678e76fb 100644 --- a/src/mahoji/lib/abstracted_commands/lampCommand.ts +++ b/src/mahoji/lib/abstracted_commands/lampCommand.ts @@ -84,13 +84,12 @@ export const XPLamps: IXPLamp[] = [ minimumLevel: 1, allowedSkills: [SkillsEnum.Magic] }, - /* Needs OSJS Update { itemID: 28_820, amount: 5000, name: 'Antique lamp (defender of varrock)', minimumLevel: 1 - },*/ + }, { itemID: itemID('Antique lamp (easy ca)'), amount: 5000, @@ -149,6 +148,13 @@ export const Lampables: IXPObject[] = [ skills[skill] = data.user.skillLevel(skill) * ([ + SkillsEnum.Attack, + SkillsEnum.Strength, + SkillsEnum.Defence, + SkillsEnum.Magic, + SkillsEnum.Ranged, + SkillsEnum.Hitpoints, + SkillsEnum.Prayer, SkillsEnum.Mining, SkillsEnum.Woodcutting, SkillsEnum.Herblore, From 83d9fa7372fc9b4ccfbcb86dcd89334c329348a4 Mon Sep 17 00:00:00 2001 From: Me <8233310+minimicronano@users.noreply.github.com> Date: Tue, 6 Aug 2024 12:26:33 +0000 Subject: [PATCH 139/145] Update setup steps to mention db schema update (#5947) --- SETUP.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SETUP.md b/SETUP.md index 87c7f5d7a9..ee08f7bdf0 100644 --- a/SETUP.md +++ b/SETUP.md @@ -15,7 +15,7 @@ This assumes you are using VSCode as your IDE. If you have errors or issues, you 2. Install [Postgres 16](https://www.postgresql.org/download/) and PGAdmin4 for interacting with postgres (optional, but helpful) 3. Install Yarn using: `npm i -g yarn` 4. Clone the repo: `git clone https://github.com/oldschoolgg/oldschoolbot.git` -5. Run `corepack enable` and `yarn` in the root of the repo. +5. Run the following commands in the root of the repo: `corepack enable`, `yarn`, `npx prisma db push` and `npx prisma db push --schema ./prisma/robochimp.prisma` ### Configuration From fed4468c9d9461030a3a941dbc6601f0022f7a89 Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Tue, 6 Aug 2024 22:38:08 +1000 Subject: [PATCH 140/145] Fix docker commands --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 0e160aa765..a11ef3d90e 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "test": "concurrently --raw --kill-others-on-fail \"tsc -p src && yarn test:circular\" \"yarn test:lint\" \"yarn test:unit\" \"tsc -p tests/integration --noEmit\" \"tsc -p tests/unit --noEmit\"", "test:lint": "biome check --diagnostic-level=error", "test:unit": "vitest run --coverage --config vitest.unit.config.mts", - "test:docker": "docker-compose up --build --abort-on-container-exit --remove-orphans && docker-compose down --volumes --remove-orphans", + "test:docker": "docker compose up --build --abort-on-container-exit --remove-orphans && docker compose down --volumes --remove-orphans", "test:watch": "vitest --config vitest.unit.config.mts --coverage", "buildandrun": "yarn build:esbuild && node --enable-source-maps dist", "build:esbuild": "concurrently --raw \"yarn build:main\" \"yarn build:workers\"", From b91e7ff34c30ebb2a8663aa8c3dbd9b092a74caa Mon Sep 17 00:00:00 2001 From: Me <8233310+minimicronano@users.noreply.github.com> Date: Sat, 10 Aug 2024 23:27:45 +0000 Subject: [PATCH 141/145] Fix grammar (#5992) --- src/lib/MUser.ts | 2 +- src/lib/colosseum.ts | 2 +- src/lib/data/buyables/skillCapeBuyables.ts | 2 +- src/lib/data/cox.ts | 4 +- src/lib/degradeableItems.ts | 6 +- src/lib/minions/functions/lmsSimCommand.ts | 2 +- src/lib/musicCape.ts | 2 +- src/lib/simulation/nex.ts | 2 +- src/lib/simulation/toa.ts | 18 +++--- src/lib/skilling/types.ts | 2 +- src/lib/util/chatHeadImage.ts | 2 +- src/mahoji/commands/admin.ts | 2 +- src/mahoji/commands/bingo.ts | 8 +-- src/mahoji/commands/drycalc.ts | 2 +- src/mahoji/commands/hunt.ts | 4 +- src/mahoji/commands/laps.ts | 2 +- src/mahoji/commands/mine.ts | 2 +- src/mahoji/commands/mix.ts | 2 +- src/mahoji/commands/offer.ts | 4 +- src/mahoji/commands/smelt.ts | 2 +- src/mahoji/commands/steal.ts | 2 +- .../aerialFishingCommand.ts | 2 +- .../lib/abstracted_commands/bankBgCommand.ts | 2 +- .../abstracted_commands/chompyHuntCommand.ts | 2 +- .../lib/abstracted_commands/coxCommand.ts | 2 +- .../lib/abstracted_commands/diceCommand.ts | 2 +- .../abstracted_commands/driftNetCommand.ts | 2 +- .../abstracted_commands/fightCavesCommand.ts | 4 +- .../lib/abstracted_commands/fishingTrawler.ts | 2 +- .../abstracted_commands/gauntletCommand.ts | 2 +- .../lib/abstracted_commands/gearCommands.ts | 2 +- .../giantsFoundryCommand.ts | 2 +- .../gnomeRestaurantCommand.ts | 2 +- .../lib/abstracted_commands/infernoCommand.ts | 4 +- .../lib/abstracted_commands/lmsCommand.ts | 2 +- .../abstracted_commands/nightmareCommand.ts | 6 +- .../abstracted_commands/puroPuroCommand.ts | 2 +- .../pyramidPlunderCommand.ts | 2 +- .../abstracted_commands/sepulchreCommand.ts | 4 +- .../lib/abstracted_commands/tobCommand.ts | 12 ++-- .../lib/abstracted_commands/trekCommand.ts | 4 +- .../volcanicMineCommand.ts | 2 +- .../warriorsGuildCommand.ts | 6 +- src/mahoji/mahojiSettings.ts | 2 +- .../minions/HunterActivity/hunterActivity.ts | 2 +- src/tasks/minions/mageArena2Activity.ts | 12 ++-- .../minions/minigames/fightCavesActivity.ts | 18 ++++-- .../minions/minigames/infernoActivity.ts | 60 +++++++++++-------- 48 files changed, 126 insertions(+), 112 deletions(-) diff --git a/src/lib/MUser.ts b/src/lib/MUser.ts index 3c4bd10f0e..c9b47bac4f 100644 --- a/src/lib/MUser.ts +++ b/src/lib/MUser.ts @@ -137,7 +137,7 @@ export class MUserClass { this.bitfield = this.user.bitfield as readonly BitField[]; } - countSkillsAtleast99() { + countSkillsAtLeast99() { return Object.values(this.skillsAsLevels).filter(lvl => lvl >= 99).length; } diff --git a/src/lib/colosseum.ts b/src/lib/colosseum.ts index 08f53f2ee8..2a37ae359e 100644 --- a/src/lib/colosseum.ts +++ b/src/lib/colosseum.ts @@ -600,7 +600,7 @@ export async function colosseumCommand(user: MUser, channelID: string) { cost.add('Dragon arrow', 50); } else { messages.push( - 'Missed 7% Venator bow boost. If you have one, charge it and keep it in your bank. You also need atleast 50 dragon arrows equipped.' + 'Missed 7% Venator bow boost. If you have one, charge it and keep it in your bank. You also need at least 50 dragon arrows equipped.' ); } diff --git a/src/lib/data/buyables/skillCapeBuyables.ts b/src/lib/data/buyables/skillCapeBuyables.ts index d6061f7a9f..fddc4fd69c 100644 --- a/src/lib/data/buyables/skillCapeBuyables.ts +++ b/src/lib/data/buyables/skillCapeBuyables.ts @@ -12,7 +12,7 @@ for (const skillcape of Skillcapes) { outputItems: (user: MUser) => { const output = new Bank().add(skillcape.hood); - if (user.countSkillsAtleast99() > 1) { + if (user.countSkillsAtLeast99() > 1) { output.add(skillcape.trimmed); } else { output.add(skillcape.untrimmed); diff --git a/src/lib/data/cox.ts b/src/lib/data/cox.ts index 05a7df1fc7..912f2ecf42 100644 --- a/src/lib/data/cox.ts +++ b/src/lib/data/cox.ts @@ -240,11 +240,11 @@ export const minimumCoxSuppliesNeeded = new Bank({ export async function checkCoxTeam(users: MUser[], cm: boolean, quantity = 1): Promise { const hasHerbalist = users.some(u => u.skillLevel(SkillsEnum.Herblore) >= 78); if (!hasHerbalist) { - return 'nobody with atleast level 78 Herblore'; + return 'nobody with at least level 78 Herblore'; } const hasFarmer = users.some(u => u.skillLevel(SkillsEnum.Farming) >= 55); if (!hasFarmer) { - return 'nobody with atleast level 55 Farming'; + return 'nobody with at least level 55 Farming'; } const suppliesNeeded = minimumCoxSuppliesNeeded.clone().multiply(quantity); const userWithoutSupplies = users.find(u => !u.bank.has(suppliesNeeded)); diff --git a/src/lib/degradeableItems.ts b/src/lib/degradeableItems.ts index dc3010152a..42a0d37496 100644 --- a/src/lib/degradeableItems.ts +++ b/src/lib/degradeableItems.ts @@ -396,10 +396,8 @@ export async function degradeItem({ const chargesAfter = user.user[degItem.settingsKey]; assert(typeof chargesAfter === 'number' && chargesAfter > 0); return { - userMessage: `Your ${ - item.name - } degraded by ${chargesToDegrade} charges, and now has ${chargesAfter} remaining.${ - pennyReduction > 0 ? ` Your Ghommal's lucky penny saved ${pennyReduction} charges` : '' + userMessage: `Your ${item.name} degraded by ${chargesToDegrade} charges, and now has ${chargesAfter} remaining${ + pennyReduction > 0 ? `. Your Ghommal's lucky penny saved ${pennyReduction} charges` : '' }` }; } diff --git a/src/lib/minions/functions/lmsSimCommand.ts b/src/lib/minions/functions/lmsSimCommand.ts index ef34e9c1bc..ae715ec690 100644 --- a/src/lib/minions/functions/lmsSimCommand.ts +++ b/src/lib/minions/functions/lmsSimCommand.ts @@ -125,7 +125,7 @@ export async function lmsSimCommand(channel: Channel | undefined, names?: string if (filtered.size < 4) { return channel.send( - 'Please specify atleast 4 players for Last Man Standing, like so: `+lms Alex, Kyra, Magna, Rick`, or type `+lms auto` to automatically pick people from the chat.' + 'Please specify at least 4 players for Last Man Standing, like so: `+lms Alex, Kyra, Magna, Rick`, or type `+lms auto` to automatically pick people from the chat.' ); } diff --git a/src/lib/musicCape.ts b/src/lib/musicCape.ts index ee32e947d1..84bba310e1 100644 --- a/src/lib/musicCape.ts +++ b/src/lib/musicCape.ts @@ -100,7 +100,7 @@ export const musicCapeRequirements = new Requirements() } }) .add({ - name: 'Runecraft all runes atleast once', + name: 'Runecraft all runes at least once', has: ({ uniqueRunesCrafted }) => { const runesToCheck = resolveItems([ 'Mind rune', diff --git a/src/lib/simulation/nex.ts b/src/lib/simulation/nex.ts index a5f928a3d3..34f649c2a0 100644 --- a/src/lib/simulation/nex.ts +++ b/src/lib/simulation/nex.ts @@ -59,7 +59,7 @@ export function checkNexUser(user: MUser): [false] | [true, string] { if (!user.hasSkillReqs(minStats)) { return [true, `${tag} doesn't have the skill requirements: ${formatSkillRequirements(minStats)}.`]; } - if (user.GP < 1_000_000) return [true, `${tag} needs atleast 1m GP to cover potential deaths.`]; + if (user.GP < 1_000_000) return [true, `${tag} needs at least 1m GP to cover potential deaths.`]; const { offence, defence, rangeGear } = nexGearStats(user); if (offence < 50) { return [ diff --git a/src/lib/simulation/toa.ts b/src/lib/simulation/toa.ts index 8e0d27684f..810b071f63 100644 --- a/src/lib/simulation/toa.ts +++ b/src/lib/simulation/toa.ts @@ -247,7 +247,7 @@ const toaRequirements: { return true; }, desc: () => - `atleast ${BP_DARTS_NEEDED}x darts per raid, and using one of: ${ALLOWED_DARTS.map(i => i.name).join( + `at least ${BP_DARTS_NEEDED}x darts per raid, and using one of: ${ALLOWED_DARTS.map(i => i.name).join( ', ' )}, loaded in Blowpipe` }, @@ -278,7 +278,7 @@ const toaRequirements: { return true; }, desc: () => - `decent range gear (BiS is ${maxRangeGear.toString()}), atleast ${BOW_ARROWS_NEEDED}x arrows equipped, and one of these bows: ${REQUIRED_RANGE_WEAPONS.map( + `decent range gear (BiS is ${maxRangeGear.toString()}), at least ${BOW_ARROWS_NEEDED}x arrows equipped, and one of these bows: ${REQUIRED_RANGE_WEAPONS.map( itemNameFromID ).join(', ')}` }, @@ -335,11 +335,11 @@ const toaRequirements: { minimumSuppliesNeeded = minSuppliesWithAtkStr; } if (!user.owns(minimumSuppliesNeeded.clone().multiply(quantity))) { - return `You need atleast this much supplies: ${minimumSuppliesNeeded}.`; + return `You need at least this much supplies: ${minimumSuppliesNeeded}.`; } const bfCharges = BLOOD_FURY_CHARGES_PER_RAID * quantity; if (user.gear.melee.hasEquipped('Amulet of blood fury') && user.user.blood_fury_charges < bfCharges) { - return `You need atleast ${bfCharges} Blood fury charges to use it, otherwise it has to be unequipped: ${mentionCommand( + return `You need at least ${bfCharges} Blood fury charges to use it, otherwise it has to be unequipped: ${mentionCommand( globalClient, 'minion', 'charge' @@ -348,7 +348,7 @@ const toaRequirements: { const tumCharges = TUMEKEN_SHADOW_PER_RAID * quantity; if (user.gear.mage.hasEquipped("Tumeken's shadow") && user.user.tum_shadow_charges < tumCharges) { - return `You need atleast ${tumCharges} Tumeken's shadow charges to use it, otherwise it has to be unequipped: ${mentionCommand( + return `You need at least ${tumCharges} Tumeken's shadow charges to use it, otherwise it has to be unequipped: ${mentionCommand( globalClient, 'minion', 'charge' @@ -357,7 +357,7 @@ const toaRequirements: { return true; }, - desc: () => `Need atleast ${minimumSuppliesNeeded}` + desc: () => `Need at least ${minimumSuppliesNeeded}` }, { name: 'Rune Pouch', @@ -368,7 +368,7 @@ const toaRequirements: { } return true; }, - desc: () => `Need atleast ${minimumSuppliesNeeded}` + desc: () => `Need at least ${minimumSuppliesNeeded}` }, { name: 'Poison Protection', @@ -1044,7 +1044,7 @@ async function checkTOAUser( true, `${ user.usernameOrMention - } doesn't have enough Serpentine helm charges. You need atleast ${serpHelmCharges} charges to do a ${formatDuration( + } doesn't have enough Serpentine helm charges. You need at least ${serpHelmCharges} charges to do a ${formatDuration( duration )} TOA raid.` ]; @@ -1057,7 +1057,7 @@ async function checkTOAUser( if (kc < dividedRaidLevel) { return [ true, - `${user.usernameOrMention}, you need atleast ${dividedRaidLevel} TOA KC to ${ + `${user.usernameOrMention}, you need at least ${dividedRaidLevel} TOA KC to ${ teamSize === 2 ? 'duo' : 'solo' } a level ${raidLevel} TOA raid.` ]; diff --git a/src/lib/skilling/types.ts b/src/lib/skilling/types.ts index 5d08411b7b..364f407eff 100644 --- a/src/lib/skilling/types.ts +++ b/src/lib/skilling/types.ts @@ -313,7 +313,7 @@ export enum HunterTechniqueEnum { BoxTrapping = 'box trapping', ButterflyNetting = 'butterfly netting', DeadfallTrapping = 'deadfall trapping', - Falconry = 'falconry', + Falconry = 'hawking', MagicBoxTrapping = 'magic box trapping', NetTrapping = 'net trapping', PitfallTrapping = 'pitfall trapping', diff --git a/src/lib/util/chatHeadImage.ts b/src/lib/util/chatHeadImage.ts index 36c117262c..0aee1092b1 100644 --- a/src/lib/util/chatHeadImage.ts +++ b/src/lib/util/chatHeadImage.ts @@ -34,7 +34,7 @@ const names: Record = { santa: 'Santa', izzy: "Cap'n Izzy No-Beard", alry: 'Alry the Angler', - ketKeh: 'Tzhaar-Ket-Keh', + ketKeh: 'TzHaar-Ket-Keh', gertrude: 'Gertrude', antiSanta: 'Anti-Santa', bunny: 'Easter Bunny', diff --git a/src/mahoji/commands/admin.ts b/src/mahoji/commands/admin.ts index 92701fbe01..27bb5bdfef 100644 --- a/src/mahoji/commands/admin.ts +++ b/src/mahoji/commands/admin.ts @@ -986,7 +986,7 @@ ${guildCommands.length} Guild commands`; FROM users WHERE bank->>'${item.id}' IS NOT NULL;`); return `There are ${ownedResult[0].qty.toLocaleString()} ${item.name} owned by everyone. -There are ${await countUsersWithItemInCl(item.id, isIron)} ${isIron ? 'ironmen' : 'people'} with atleast 1 ${ +There are ${await countUsersWithItemInCl(item.id, isIron)} ${isIron ? 'ironmen' : 'people'} with at least 1 ${ item.name } in their collection log.`; } diff --git a/src/mahoji/commands/bingo.ts b/src/mahoji/commands/bingo.ts index 6f56fdc656..0f73adc593 100644 --- a/src/mahoji/commands/bingo.ts +++ b/src/mahoji/commands/bingo.ts @@ -675,7 +675,7 @@ export const bingoCommand: OSBMahojiCommand = { const fee = BOT_TYPE === 'OSB' ? 20_000_000 : 50_000_000; const creationCost = new Bank().add('Coins', fee); if (user.GP < creationCost.amount('Coins')) { - return `You need atleast ${creationCost} to create a bingo.`; + return `You need at least ${creationCost} to create a bingo.`; } const channel = globalClient.channels.cache.get(options.create_bingo.notifications_channel_id); @@ -713,9 +713,9 @@ export const bingoCommand: OSBMahojiCommand = { return 'Team size must be between 1 and 5.'; } - // Start date must be atleast 3 hours into the future + // Start date must be at least 3 hours into the future if (createOptions.start_date.getTime() < Date.now() + Time.Minute * 3) { - return 'Start date must be atleast 3 minutes into the future.'; + return 'Start date must be at least 3 minutes into the future.'; } // Start date cannot be more than 31 days into the future @@ -914,7 +914,7 @@ Example: \`add_tile:Coal|Trout|Egg\` is a tile where you have to receive a coal const cost = new Bank().add('Coins', amount); if (user.GP < cost.amount('Coins')) { - return `You need atleast ${cost} to add that much GP to the prize pool.`; + return `You need at least ${cost} to add that much GP to the prize pool.`; } await handleMahojiConfirmation( diff --git a/src/mahoji/commands/drycalc.ts b/src/mahoji/commands/drycalc.ts index 1975749cf5..dbc12697d6 100644 --- a/src/mahoji/commands/drycalc.ts +++ b/src/mahoji/commands/drycalc.ts @@ -39,7 +39,7 @@ export const dryCalcCommand: OSBMahojiCommand = { )}%** chance of not receiving any drop, and a **${round( dropChance, 2 - )}%** chance of receiving atleast one drop.`; + )}%** chance of receiving at least one drop.`; return output; } diff --git a/src/mahoji/commands/hunt.ts b/src/mahoji/commands/hunt.ts index 64d6d7e1ff..883a735cd4 100644 --- a/src/mahoji/commands/hunt.ts +++ b/src/mahoji/commands/hunt.ts @@ -280,8 +280,8 @@ export const huntCommand: OSBMahojiCommand = { type: 'Hunter' }); - let response = `${user.minionName} is now ${crystalImpling ? 'hunting' : `${creature.huntTechnique}`} ${ - crystalImpling ? '' : ` ${quantity}x ` + let response = `${user.minionName} is now ${crystalImpling ? 'hunting' : `${creature.huntTechnique}`}${ + crystalImpling ? ' ' : ` ${quantity}x ` }${creature.name}, it'll take around ${formatDuration(duration)} to finish.`; if (boosts.length > 0) { diff --git a/src/mahoji/commands/laps.ts b/src/mahoji/commands/laps.ts index 2e7961b462..45585d18b8 100644 --- a/src/mahoji/commands/laps.ts +++ b/src/mahoji/commands/laps.ts @@ -127,7 +127,7 @@ export const lapsCommand: OSBMahojiCommand = { } if (course.qpRequired && user.QP < course.qpRequired) { - return `You need atleast ${course.qpRequired} Quest Points to do this course.`; + return `You need at least ${course.qpRequired} Quest Points to do this course.`; } const maxTripLength = calcMaxTripLength(user, 'Agility'); diff --git a/src/mahoji/commands/mine.ts b/src/mahoji/commands/mine.ts index 8de09264f4..7f8ab8a624 100644 --- a/src/mahoji/commands/mine.ts +++ b/src/mahoji/commands/mine.ts @@ -91,7 +91,7 @@ export const mineCommand: OSBMahojiCommand = { return `To mine ${ore.name}, you need ${formatSkillRequirements(sinsOfTheFatherSkillRequirements)}.`; } if (user.QP < 125) { - return `To mine ${ore.name}, you need atleast 125 Quest Points.`; + return `To mine ${ore.name}, you need at least 125 Quest Points.`; } } diff --git a/src/mahoji/commands/mix.ts b/src/mahoji/commands/mix.ts index b16a74d103..85db808a86 100644 --- a/src/mahoji/commands/mix.ts +++ b/src/mahoji/commands/mix.ts @@ -72,7 +72,7 @@ export const mixCommand: OSBMahojiCommand = { } if (mixableItem.qpRequired && user.QP < mixableItem.qpRequired) { - return `You need atleast **${mixableItem.qpRequired}** QP to make ${mixableItem.item.name}.`; + return `You need at least **${mixableItem.qpRequired}** QP to make ${mixableItem.item.name}.`; } const requiredItems = new Bank(mixableItem.inputItems); diff --git a/src/mahoji/commands/offer.ts b/src/mahoji/commands/offer.ts index dd52db93ae..08401b2474 100644 --- a/src/mahoji/commands/offer.ts +++ b/src/mahoji/commands/offer.ts @@ -208,10 +208,10 @@ export const offerCommand: OSBMahojiCommand = { const specialBone = specialBones.find(bone => stringMatches(bone.item.name, options.name)); if (specialBone) { if (user.QP < 8) { - return 'You need atleast 8 QP to offer long/curved bones for XP.'; + return 'You need at least 8 QP to offer long/curved bones for XP.'; } if (user.skillLevel(SkillsEnum.Construction) < 30) { - return 'You need atleast level 30 Construction to offer long/curved bones for XP.'; + return 'You need at least level 30 Construction to offer long/curved bones for XP.'; } const amountHas = userBank.amount(specialBone.item.id); if (!quantity) quantity = Math.max(amountHas, 1); diff --git a/src/mahoji/commands/smelt.ts b/src/mahoji/commands/smelt.ts index ca8bded912..d099c2d281 100644 --- a/src/mahoji/commands/smelt.ts +++ b/src/mahoji/commands/smelt.ts @@ -145,7 +145,7 @@ export const smeltingCommand: OSBMahojiCommand = { coinsToRemove = Math.floor(gpPerHour * (duration / Time.Hour)); const gp = user.GP; if (gp < coinsToRemove) { - return `You need atleast ${coinsToRemove} GP to work at the Blast Furnace.`; + return `You need at least ${coinsToRemove} GP to work at the Blast Furnace.`; } cost.add('Coins', coinsToRemove); diff --git a/src/mahoji/commands/steal.ts b/src/mahoji/commands/steal.ts index 5ba6d227d1..c1248ed3df 100644 --- a/src/mahoji/commands/steal.ts +++ b/src/mahoji/commands/steal.ts @@ -70,7 +70,7 @@ export const stealCommand: OSBMahojiCommand = { } if (stealable.qpRequired && user.QP < stealable.qpRequired) { - return `You need atleast **${stealable.qpRequired}** QP to ${ + return `You need at least **${stealable.qpRequired}** QP to ${ stealable.type === 'pickpockable' ? 'pickpocket' : 'steal from' } a ${stealable.name}.`; } diff --git a/src/mahoji/lib/abstracted_commands/aerialFishingCommand.ts b/src/mahoji/lib/abstracted_commands/aerialFishingCommand.ts index d8e69518b2..b21649e697 100644 --- a/src/mahoji/lib/abstracted_commands/aerialFishingCommand.ts +++ b/src/mahoji/lib/abstracted_commands/aerialFishingCommand.ts @@ -8,7 +8,7 @@ import { calcMaxTripLength } from '../../../lib/util/calcMaxTripLength'; export async function aerialFishingCommand(user: MUser, channelID: string) { if (user.skillLevel(SkillsEnum.Fishing) < 43 || user.skillLevel(SkillsEnum.Hunter) < 35) { - return 'You need atleast level 35 Hunter and 43 Fishing to do Aerial fishing.'; + return 'You need at least level 35 Hunter and 43 Fishing to do Aerial fishing.'; } const timePerFish = randomVariation(2, 7.5) * Time.Second; diff --git a/src/mahoji/lib/abstracted_commands/bankBgCommand.ts b/src/mahoji/lib/abstracted_commands/bankBgCommand.ts index 4cae340826..9af5fa5346 100644 --- a/src/mahoji/lib/abstracted_commands/bankBgCommand.ts +++ b/src/mahoji/lib/abstracted_commands/bankBgCommand.ts @@ -36,7 +36,7 @@ export async function bankBgCommand(interaction: ChatInputCommandInteraction, us if (selectedImage.sacValueRequired) { const sac = Number(user.user.sacrificedValue); if (sac < selectedImage.sacValueRequired) { - return `You have to have sacrificed atleast ${toKMB( + return `You have to have sacrificed at least ${toKMB( selectedImage.sacValueRequired )} GP worth of items to use this background.`; } diff --git a/src/mahoji/lib/abstracted_commands/chompyHuntCommand.ts b/src/mahoji/lib/abstracted_commands/chompyHuntCommand.ts index 0e2a1853a1..49341819e6 100644 --- a/src/mahoji/lib/abstracted_commands/chompyHuntCommand.ts +++ b/src/mahoji/lib/abstracted_commands/chompyHuntCommand.ts @@ -40,7 +40,7 @@ export async function chompyHuntClaimCommand(user: MUser) { export async function chompyHuntCommand(user: MUser, channelID: string) { if (user.QP < 10) { - return 'You need atleast 10 QP to hunt Chompy birds.'; + return 'You need at least 10 QP to hunt Chompy birds.'; } const rangeGear = user.gear.range; diff --git a/src/mahoji/lib/abstracted_commands/coxCommand.ts b/src/mahoji/lib/abstracted_commands/coxCommand.ts index 366ed284e0..6eeaca3135 100644 --- a/src/mahoji/lib/abstracted_commands/coxCommand.ts +++ b/src/mahoji/lib/abstracted_commands/coxCommand.ts @@ -95,7 +95,7 @@ export async function coxCommand( if (isChallengeMode) { const normalKC = await getMinigameScore(user.id, 'raids'); if (normalKC < 200) { - return 'You need atleast 200 completions of the Chambers of Xeric before you can attempt Challenge Mode.'; + return 'You need at least 200 completions of the Chambers of Xeric before you can attempt Challenge Mode.'; } } if (user.minionIsBusy) { diff --git a/src/mahoji/lib/abstracted_commands/diceCommand.ts b/src/mahoji/lib/abstracted_commands/diceCommand.ts index b53ebbbe98..f8a4117615 100644 --- a/src/mahoji/lib/abstracted_commands/diceCommand.ts +++ b/src/mahoji/lib/abstracted_commands/diceCommand.ts @@ -29,7 +29,7 @@ export async function diceCommand(user: MUser, interaction: ChatInputCommandInte } if (amount < 1_000_000) { - return 'You have to dice atleast 1,000,000.'; + return 'You have to dice at least 1,000,000.'; } const gp = user.GP; diff --git a/src/mahoji/lib/abstracted_commands/driftNetCommand.ts b/src/mahoji/lib/abstracted_commands/driftNetCommand.ts index 9c24f5cde9..de2b715d50 100644 --- a/src/mahoji/lib/abstracted_commands/driftNetCommand.ts +++ b/src/mahoji/lib/abstracted_commands/driftNetCommand.ts @@ -21,7 +21,7 @@ export async function driftNetCommand( } if (user.skillLevel(SkillsEnum.Fishing) < 47 || user.skillLevel(SkillsEnum.Hunter) < 44) { - return 'You need atleast level 44 Hunter and 47 Fishing to do Drift net fishing.'; + return 'You need at least level 44 Hunter and 47 Fishing to do Drift net fishing.'; } if (!user.hasEquipped(['Graceful gloves', 'Graceful top', 'Graceful legs'])) { diff --git a/src/mahoji/lib/abstracted_commands/fightCavesCommand.ts b/src/mahoji/lib/abstracted_commands/fightCavesCommand.ts index b30248334e..543a275fa5 100644 --- a/src/mahoji/lib/abstracted_commands/fightCavesCommand.ts +++ b/src/mahoji/lib/abstracted_commands/fightCavesCommand.ts @@ -86,11 +86,11 @@ function checkGear(user: MUser): string | undefined { } if (!user.owns(fightCavesCost)) { - return `JalYt, you need supplies to have a chance in the caves...come back with ${fightCavesCost}.`; + return `JalYt, you need supplies to have a chance in the caves... Come back with ${fightCavesCost}.`; } if (user.skillLevel('prayer') < 43) { - return 'JalYt, come back when you have atleast 43 Prayer, TzTok-Jad annihilate you without protection from gods.'; + return 'JalYt, come back when you have at least 43 Prayer, TzTok-Jad annihilate you without protection from gods.'; } } diff --git a/src/mahoji/lib/abstracted_commands/fishingTrawler.ts b/src/mahoji/lib/abstracted_commands/fishingTrawler.ts index 36e4ccb8fd..21657215d8 100644 --- a/src/mahoji/lib/abstracted_commands/fishingTrawler.ts +++ b/src/mahoji/lib/abstracted_commands/fishingTrawler.ts @@ -8,7 +8,7 @@ import { calcMaxTripLength } from '../../../lib/util/calcMaxTripLength'; export async function fishingTrawlerCommand(user: MUser, channelID: string) { if (user.skillLevel('fishing') < 15) { - return 'You need atleast level 15 Fishing to do the Fishing Trawler.'; + return 'You need at least level 15 Fishing to do the Fishing Trawler.'; } const tripsDone = await getMinigameScore(user.id, 'fishing_trawler'); diff --git a/src/mahoji/lib/abstracted_commands/gauntletCommand.ts b/src/mahoji/lib/abstracted_commands/gauntletCommand.ts index 086ba8d13f..362c9a1d18 100644 --- a/src/mahoji/lib/abstracted_commands/gauntletCommand.ts +++ b/src/mahoji/lib/abstracted_commands/gauntletCommand.ts @@ -44,7 +44,7 @@ const corruptedRequirements = { export async function gauntletCommand(user: MUser, channelID: string, type: 'corrupted' | 'normal' = 'normal') { if (user.minionIsBusy) return `${user.minionName} is busy.`; if (user.QP < 200) { - return 'You need atleast 200 QP to do the Gauntlet.'; + return 'You need at least 200 QP to do the Gauntlet.'; } const readableName = `${toTitleCase(type)} Gauntlet`; const requiredSkills = type === 'corrupted' ? corruptedRequirements : standardRequirements; diff --git a/src/mahoji/lib/abstracted_commands/gearCommands.ts b/src/mahoji/lib/abstracted_commands/gearCommands.ts index 3cb1fb6c54..b3a98117ce 100644 --- a/src/mahoji/lib/abstracted_commands/gearCommands.ts +++ b/src/mahoji/lib/abstracted_commands/gearCommands.ts @@ -406,7 +406,7 @@ export async function gearViewCommand(user: MUser, input: string, text: boolean) }) .join('\n\n'); - const updatedContent = `${content}\n\nThese assume you have atleast 25 prayer for the protect item prayer.`; + const updatedContent = `${content}\n\nThese assume you have at least 25 prayer for the protect item prayer.`; return { content: updatedContent }; } diff --git a/src/mahoji/lib/abstracted_commands/giantsFoundryCommand.ts b/src/mahoji/lib/abstracted_commands/giantsFoundryCommand.ts index 63bb5cfa19..2576fa30ff 100644 --- a/src/mahoji/lib/abstracted_commands/giantsFoundryCommand.ts +++ b/src/mahoji/lib/abstracted_commands/giantsFoundryCommand.ts @@ -172,7 +172,7 @@ export async function giantsFoundryStartCommand( } if (userSmithingLevel < alloy.level) { - return `${user.minionName} needs atleast level ${alloy.level} Smithing to user ${alloy.name} alloy in the Giants' Foundry.`; + return `${user.minionName} needs at least level ${alloy.level} Smithing to user ${alloy.name} alloy in the Giants' Foundry.`; } // If they have the entire Smiths' Uniform, give an extra 15% speed bonus diff --git a/src/mahoji/lib/abstracted_commands/gnomeRestaurantCommand.ts b/src/mahoji/lib/abstracted_commands/gnomeRestaurantCommand.ts index 369f9f0a56..f78cc30f8e 100644 --- a/src/mahoji/lib/abstracted_commands/gnomeRestaurantCommand.ts +++ b/src/mahoji/lib/abstracted_commands/gnomeRestaurantCommand.ts @@ -18,7 +18,7 @@ export async function gnomeRestaurantCommand(user: MUser, channelID: string) { const itemsToRemove = new Bank(); const gp = user.GP; if (gp < 5000) { - return 'You need atleast 5k GP to work at the Gnome Restaurant.'; + return 'You need at least 5k GP to work at the Gnome Restaurant.'; } itemsToRemove.add('Coins', 5000); diff --git a/src/mahoji/lib/abstracted_commands/infernoCommand.ts b/src/mahoji/lib/abstracted_commands/infernoCommand.ts index 56f2e1f54f..080b9ff36d 100644 --- a/src/mahoji/lib/abstracted_commands/infernoCommand.ts +++ b/src/mahoji/lib/abstracted_commands/infernoCommand.ts @@ -249,7 +249,7 @@ async function infernoRun({ const dartIndex = blowpipeDarts.indexOf(dartItem); const percent = dartIndex >= 3 ? dartIndex * 0.9 : -(4 * (4 - dartIndex)); if (dartIndex < 5) { - return 'Your darts are simply too weak, to work in the Inferno!'; + return 'Your darts are simply too weak to work in the Inferno!'; } duration.add(true, -percent, `${dartItem.name} in blowpipe`); @@ -516,7 +516,7 @@ export async function infernoStartCommand(user: MUser, channelID: string): Comma { name: 'image.jpg', attachment: await newChatHeadImage({ - content: "You're on your own now JalYt, you face certain death... prepare to fight for your life.", + content: "You're on your own now JalYt, you face certain death... Prepare to fight for your life.", head: 'ketKeh' }) } diff --git a/src/mahoji/lib/abstracted_commands/lmsCommand.ts b/src/mahoji/lib/abstracted_commands/lmsCommand.ts index e7460c0fb4..056bea5212 100644 --- a/src/mahoji/lib/abstracted_commands/lmsCommand.ts +++ b/src/mahoji/lib/abstracted_commands/lmsCommand.ts @@ -53,7 +53,7 @@ export async function lmsCommand( return `You don't have enough points. ${quantity}x ${itemToBuy.item.name} costs ${cost}, but you have ${stats.points}.`; } if (itemToBuy.wins && stats.gamesWon < itemToBuy.wins) { - return `You are not worthy! You need to have won atleast ${itemToBuy.wins} games to buy the ${itemToBuy.item.name}.`; + return `You are not worthy! You need to have won at least ${itemToBuy.wins} games to buy the ${itemToBuy.item.name}.`; } const loot = new Bank().add(itemToBuy.item.id, quantity * (itemToBuy.quantity ?? 1)); await handleMahojiConfirmation(interaction, `Are you sure you want to spend ${cost} points on buying ${loot}?`); diff --git a/src/mahoji/lib/abstracted_commands/nightmareCommand.ts b/src/mahoji/lib/abstracted_commands/nightmareCommand.ts index 759dd7800c..170602cfdb 100644 --- a/src/mahoji/lib/abstracted_commands/nightmareCommand.ts +++ b/src/mahoji/lib/abstracted_commands/nightmareCommand.ts @@ -89,7 +89,7 @@ async function checkReqs(user: MUser, monster: KillableMonster, isPhosani: boole return `${user.usernameOrMention} doesn't meet the requirements: ${requirements[1]}.`; } if ((await user.getKC(NightmareMonster.id)) < 50) { - return "You need to have killed The Nightmare atleast 50 times before you can face the Phosani's Nightmare."; + return "You need to have killed The Nightmare at least 50 times before you can face the Phosani's Nightmare."; } } } @@ -100,13 +100,13 @@ function perUserCost(user: MUser, quantity: number, isPhosani: boolean, hasShado const sangCharges = sangChargesPerKc * quantity; if (isPhosani) { if (hasShadow && user.user.tum_shadow_charges < tumCharges) { - return `You need atleast ${tumCharges} Tumeken's shadow charges to use it, otherwise it has to be unequipped: ${mentionCommand( + return `You need at least ${tumCharges} Tumeken's shadow charges to use it, otherwise it has to be unequipped: ${mentionCommand( globalClient, 'minion', 'charge' )}`; } else if (hasSang && user.user.sang_charges < sangCharges) { - return `You need atleast ${sangCharges} Sanguinesti staff charges to use it, otherwise it has to be unequipped: ${mentionCommand( + return `You need at least ${sangCharges} Sanguinesti staff charges to use it, otherwise it has to be unequipped: ${mentionCommand( globalClient, 'minion', 'charge' diff --git a/src/mahoji/lib/abstracted_commands/puroPuroCommand.ts b/src/mahoji/lib/abstracted_commands/puroPuroCommand.ts index 41a816b15d..c4142eb55d 100644 --- a/src/mahoji/lib/abstracted_commands/puroPuroCommand.ts +++ b/src/mahoji/lib/abstracted_commands/puroPuroCommand.ts @@ -68,7 +68,7 @@ export async function puroPuroStartCommand( } if (!impToHunt) return 'Error selecting impling, please try again.'; if (hunterLevel < impToHunt.hunterLevel) - return `${user.minionName} needs atleast level ${impToHunt.hunterLevel} hunter to hunt ${impToHunt.name} in Puro-Puro.`; + return `${user.minionName} needs at least level ${impToHunt.hunterLevel} hunter to hunt ${impToHunt.name} in Puro-Puro.`; if (!darkLure || (darkLure && !impToHunt.spell)) darkLure = false; if (darkLure) { if (user.QP < 9) return 'To use Dark Lure, you need 9 QP.'; diff --git a/src/mahoji/lib/abstracted_commands/pyramidPlunderCommand.ts b/src/mahoji/lib/abstracted_commands/pyramidPlunderCommand.ts index 8825d580a5..9c51f0e8ab 100644 --- a/src/mahoji/lib/abstracted_commands/pyramidPlunderCommand.ts +++ b/src/mahoji/lib/abstracted_commands/pyramidPlunderCommand.ts @@ -15,7 +15,7 @@ export async function pyramidPlunderCommand(user: MUser, channelID: string) { const thievingLevel = skills.thieving; const minLevel = plunderRooms[0].thievingLevel; if (thievingLevel < minLevel) { - return `You need atleast level ${minLevel} Thieving to do the Pyramid Plunder.`; + return `You need at least level ${minLevel} Thieving to do the Pyramid Plunder.`; } const completableRooms = plunderRooms.filter(room => thievingLevel >= room.thievingLevel); diff --git a/src/mahoji/lib/abstracted_commands/sepulchreCommand.ts b/src/mahoji/lib/abstracted_commands/sepulchreCommand.ts index e1bc9defe6..46b2c4fc29 100644 --- a/src/mahoji/lib/abstracted_commands/sepulchreCommand.ts +++ b/src/mahoji/lib/abstracted_commands/sepulchreCommand.ts @@ -14,11 +14,11 @@ export async function sepulchreCommand(user: MUser, channelID: string) { const thievingLevel = skills.thieving; const minLevel = sepulchreFloors[0].agilityLevel; if (agilityLevel < minLevel) { - return `You need atleast level ${minLevel} Agility to do the Hallowed Sepulchre.`; + return `You need at least level ${minLevel} Agility to do the Hallowed Sepulchre.`; } if (thievingLevel < 66) { - return 'You need atleast level 66 Thieving to do the Hallowed Sepulchre.'; + return 'You need at least level 66 Thieving to do the Hallowed Sepulchre.'; } if (!userHasGracefulEquipped(user)) { diff --git a/src/mahoji/lib/abstracted_commands/tobCommand.ts b/src/mahoji/lib/abstracted_commands/tobCommand.ts index c8f9d1930b..5b6f1b9e06 100644 --- a/src/mahoji/lib/abstracted_commands/tobCommand.ts +++ b/src/mahoji/lib/abstracted_commands/tobCommand.ts @@ -180,11 +180,11 @@ async function checkTOBUser( } const dartsNeeded = 150 * quantity; if (blowpipeData.dartQuantity < dartsNeeded) { - return [true, `${user.usernameOrMention}, you need atleast ${dartsNeeded} darts in your blowpipe.`]; + return [true, `${user.usernameOrMention}, you need at least ${dartsNeeded} darts in your blowpipe.`]; } const scalesNeeded = 1000 * quantity; if (blowpipeData.scales < scalesNeeded) { - return [true, `${user.usernameOrMention}, you need atleast ${scalesNeeded} scales in your blowpipe.`]; + return [true, `${user.usernameOrMention}, you need at least ${scalesNeeded} scales in your blowpipe.`]; } const dartIndex = blowpipeDarts.indexOf(getOSItem(blowpipeData.dartID)); if (dartIndex < 5) { @@ -209,7 +209,7 @@ async function checkTOBUser( if (rangeGear.ammo!.quantity < arrowsRequired) { return [ true, - `${user.usernameOrMention}, you need atleast ${arrowsRequired} arrows equipped in your range setup.` + `${user.usernameOrMention}, you need at least ${arrowsRequired} arrows equipped in your range setup.` ]; } @@ -217,7 +217,7 @@ async function checkTOBUser( const kc = await getMinigameScore(user.id, 'tob'); if (kc < 250) { - return [true, `${user.usernameOrMention} needs atleast 250 Theatre of Blood KC before doing Hard mode.`]; + return [true, `${user.usernameOrMention} needs at least 250 Theatre of Blood KC before doing Hard mode.`]; } if (!meleeGear.hasEquipped('Infernal cape')) { return [true, `${user.usernameOrMention} needs an Infernal cape to do Hard mode.`]; @@ -227,7 +227,7 @@ async function checkTOBUser( if (teamSize === 2) { const kc = await getMinigameScore(user.id, isHardMode ? 'tob_hard' : 'tob'); if (kc < 150) { - return [true, `${user.usernameOrMention} needs atleast 150 KC before doing duo's.`]; + return [true, `${user.usernameOrMention} needs at least 150 KC before doing duo's.`]; } } @@ -306,7 +306,7 @@ export async function tobStartCommand( if (isHardMode) { const normalKC = await getMinigameScore(user.id, 'tob'); if (normalKC < 250) { - return 'You need atleast 250 completions of the Theatre of Blood before you can attempt Hard Mode.'; + return 'You need at least 250 completions of the Theatre of Blood before you can attempt Hard Mode.'; } } if (user.minionIsBusy) { diff --git a/src/mahoji/lib/abstracted_commands/trekCommand.ts b/src/mahoji/lib/abstracted_commands/trekCommand.ts index 12f05f54dd..25b62cfcdc 100644 --- a/src/mahoji/lib/abstracted_commands/trekCommand.ts +++ b/src/mahoji/lib/abstracted_commands/trekCommand.ts @@ -53,7 +53,7 @@ export async function trekCommand(user: MUser, channelID: string, difficulty: st if (!meetsRequirements) { return `You don't have the requirements to do ${tier.difficulty} treks! Your ${readableStatName( unmetKey! - )} stat in your ${setup} setup is ${has}, but you need atleast ${ + )} stat in your ${setup} setup is ${has}, but you need at least ${ tier.minimumGearRequirements[setup]?.[unmetKey!] }.`; } @@ -62,7 +62,7 @@ export async function trekCommand(user: MUser, channelID: string, difficulty: st } if (qp < 30) { - return 'You need atleast level 30 QP to do Temple Trekking.'; + return 'You need at least level 30 QP to do Temple Trekking.'; } if (minLevel !== undefined && user.combatLevel < minLevel) { diff --git a/src/mahoji/lib/abstracted_commands/volcanicMineCommand.ts b/src/mahoji/lib/abstracted_commands/volcanicMineCommand.ts index 8ddb48a360..6abdcf379d 100644 --- a/src/mahoji/lib/abstracted_commands/volcanicMineCommand.ts +++ b/src/mahoji/lib/abstracted_commands/volcanicMineCommand.ts @@ -234,6 +234,6 @@ export async function volcanicMineStatsCommand(user: MUser) { const currentUserPoints = user.user.volcanic_mine_points; const kc = await getMinigameScore(user.id, 'volcanic_mine'); - return `You have ${currentUserPoints.toLocaleString()} Volanic Mine points points. + return `You have ${currentUserPoints.toLocaleString()} Volcanic Mine points. You have completed ${kc} games of Volcanic Mine.`; } diff --git a/src/mahoji/lib/abstracted_commands/warriorsGuildCommand.ts b/src/mahoji/lib/abstracted_commands/warriorsGuildCommand.ts index 46a0472f69..fe12eddfff 100644 --- a/src/mahoji/lib/abstracted_commands/warriorsGuildCommand.ts +++ b/src/mahoji/lib/abstracted_commands/warriorsGuildCommand.ts @@ -82,7 +82,7 @@ async function cyclopsCommand(user: MUser, channelID: string, quantity: number | // Check if either 100 warrior guild tokens or attack cape (similar items in future) const amountTokens = userBank.amount('Warrior guild token'); if (!hasAttackCape && amountTokens < 100) { - return 'You need atleast 100 Warriors guild tokens to kill Cyclops.'; + return 'You need at least 100 Warriors guild tokens to kill Cyclops.'; } // If no quantity provided, set it to the max. if (!quantity) { @@ -107,7 +107,7 @@ async function cyclopsCommand(user: MUser, channelID: string, quantity: number | if (!hasAttackCape && amountTokens < tokensToSpend) { return `You don't have enough Warrior guild tokens to kill cyclopes for ${formatDuration( duration - )}, try a lower quantity. You need atleast ${Math.floor( + )}, try a lower quantity. You need at least ${Math.floor( (duration / Time.Minute) * 10 + 10 )}x Warrior guild tokens to kill ${quantity}x cyclopes.`; } @@ -144,7 +144,7 @@ export async function warriorsGuildCommand( const atkLvl = user.skillLevel('attack'); const strLvl = user.skillLevel('strength'); if (atkLvl + strLvl < 130 && atkLvl !== 99 && strLvl !== 99) { - return "To enter the Warrior's Guild, your Attack and Strength levels must add up to atleast 130, or you must have level 99 in either."; + return "To enter the Warrior's Guild, your Attack and Strength levels must add up to at least 130, or you must have level 99 in either."; } if (choice === 'cyclops') { diff --git a/src/mahoji/mahojiSettings.ts b/src/mahoji/mahojiSettings.ts index 6eb0c25293..e4df529d54 100644 --- a/src/mahoji/mahojiSettings.ts +++ b/src/mahoji/mahojiSettings.ts @@ -257,7 +257,7 @@ export function hasMonsterRequirements(user: MUser, monster: KillableMonster) { false, `You don't have the requirements to kill ${monster.name}! Your ${readableStatName( unmetKey! - )} stat in your ${setup} setup is ${has}, but you need atleast ${ + )} stat in your ${setup} setup is ${has}, but you need at least ${ monster.minimumGearRequirements[setup]?.[unmetKey!] }.` ]; diff --git a/src/tasks/minions/HunterActivity/hunterActivity.ts b/src/tasks/minions/HunterActivity/hunterActivity.ts index 7a333ffdc2..24994a87f5 100644 --- a/src/tasks/minions/HunterActivity/hunterActivity.ts +++ b/src/tasks/minions/HunterActivity/hunterActivity.ts @@ -178,7 +178,7 @@ export const hunterTask: MinionTask = { let str = `${user}, ${user.minionName} finished hunting ${creature.name}${ crystalImpling - ? '.' + ? '. ' : ` ${quantity}x times, due to clever creatures you missed out on ${ quantity - successfulQuantity }x catches. ` diff --git a/src/tasks/minions/mageArena2Activity.ts b/src/tasks/minions/mageArena2Activity.ts index 4a348b5e13..7cc092ecf6 100644 --- a/src/tasks/minions/mageArena2Activity.ts +++ b/src/tasks/minions/mageArena2Activity.ts @@ -14,12 +14,12 @@ export const mageArenaTwoTask: MinionTask = { let loot: Bank | undefined = undefined; if (percentChance(70)) { const deathReason = randArrItem([ - 'Died to Porazdir.', - 'Killed by Derwen.', - 'Killed by Justiciar Zachariah.', - "PK'd by a clan.", - 'Killed by Chaos Elemental.', - 'Killed by a PKer.' + 'Died to Porazdir', + 'Killed by Derwen', + 'Killed by Justiciar Zachariah', + "PK'd by a clan", + 'Killed by Chaos Elemental', + 'Killed by a PKer' ]); str = `${user}, ${user.minionName} failed to complete the Mage Arena II: ${deathReason}. Try again.`; } else { diff --git a/src/tasks/minions/minigames/fightCavesActivity.ts b/src/tasks/minions/minigames/fightCavesActivity.ts index f4ced983d9..a5cb2e71c2 100644 --- a/src/tasks/minions/minigames/fightCavesActivity.ts +++ b/src/tasks/minions/minigames/fightCavesActivity.ts @@ -35,7 +35,7 @@ export const fightCavesTask: MinionTask = { { fight_caves_attempts: true } ); - const attemptsStr = `You have tried Fight caves ${newFightCavesAttempts}x times.`; + const attemptsStr = `You have tried Fight caves ${newFightCavesAttempts}x times`; // Add slayer const usersTask = await getUsersCurrentSlayerInfo(user.id); @@ -81,7 +81,7 @@ export const fightCavesTask: MinionTask = { preJadDeathTime )} into your attempt.${slayerMsg} The following supplies were refunded back into your bank: ${itemLootBank}.`, await chatHeadImage({ - content: `You die before you even reach TzTok-Jad...atleast you tried, I give you ${tokkulReward}x Tokkul. ${attemptsStr}`, + content: `You die before you even reach TzTok-Jad... At least you tried, I give you ${tokkulReward}x Tokkul. ${attemptsStr}.`, head: 'mejJal' }), data, @@ -117,7 +117,7 @@ export const fightCavesTask: MinionTask = { channelID, `${user} ${msg}`, await chatHeadImage({ - content: `TzTok-Jad stomp you to death...nice try though JalYt, for your effort I give you ${tokkulReward}x Tokkul. ${attemptsStr}.`, + content: `TzTok-Jad stomp you to death... Nice try though JalYt, for your effort I give you ${tokkulReward}x Tokkul. ${attemptsStr}.`, head: 'mejJal' }), data, @@ -152,8 +152,8 @@ export const fightCavesTask: MinionTask = { itemsToAdd: loot }); - const rangeXP = await user.addXP({ skillName: SkillsEnum.Ranged, amount: 47_580, duration }); - const hpXP = await user.addXP({ skillName: SkillsEnum.Hitpoints, amount: 15_860, duration }); + const rangeXP = await user.addXP({ skillName: SkillsEnum.Ranged, amount: 47_580, duration, minimal: true }); + const hpXP = await user.addXP({ skillName: SkillsEnum.Hitpoints, amount: 15_860, duration, minimal: true }); let msg = `${rangeXP}. ${hpXP}.`; if (isOnTask) { @@ -186,7 +186,13 @@ export const fightCavesTask: MinionTask = { } }); - const slayXP = await user.addXP({ skillName: SkillsEnum.Slayer, amount: slayerXP, duration }); + const slayXP = await user.addXP({ + skillName: SkillsEnum.Slayer, + amount: slayerXP, + duration, + minimal: true + }); + const xpMessage = `${msg} ${slayXP}`; msg = `Jad task completed. ${xpMessage}. \n**You've completed ${currentStreak} tasks and received ${points} points; giving you a total of ${secondNewUser.newUser.slayer_points}; return to a Slayer master.**`; diff --git a/src/tasks/minions/minigames/infernoActivity.ts b/src/tasks/minions/minigames/infernoActivity.ts index c5dc66f388..02693b14b7 100644 --- a/src/tasks/minions/minigames/infernoActivity.ts +++ b/src/tasks/minions/minigames/infernoActivity.ts @@ -51,33 +51,43 @@ export const infernoTask: MinionTask = { const [hasDiary] = await userhasDiaryTier(user, diariesObject.KaramjaDiary.elite); if (hasDiary) tokkul *= 2; const baseBank = new Bank().add('Tokkul', tokkul); - - let xpStr = await user.addXP({ - skillName: SkillsEnum.Ranged, - amount: calcPercentOfNum(percentMadeItThrough, 80_000), - duration, - minimal: true - }); - xpStr += await user.addXP({ - skillName: SkillsEnum.Hitpoints, - amount: calcPercentOfNum(percentMadeItThrough, 35_000), - duration, - minimal: true - }); - xpStr += await user.addXP({ - skillName: SkillsEnum.Magic, - amount: calcPercentOfNum(percentMadeItThrough, 25_000), - duration, - minimal: true - }); + const xpBonuses = []; + + xpBonuses.push( + await user.addXP({ + skillName: SkillsEnum.Ranged, + amount: calcPercentOfNum(percentMadeItThrough, 80_000), + duration, + minimal: true + }) + ); + xpBonuses.push( + await user.addXP({ + skillName: SkillsEnum.Hitpoints, + amount: calcPercentOfNum(percentMadeItThrough, 35_000), + duration, + minimal: true + }) + ); + xpBonuses.push( + await user.addXP({ + skillName: SkillsEnum.Magic, + amount: calcPercentOfNum(percentMadeItThrough, 25_000), + duration, + minimal: true + }) + ); if (isOnTask) { - xpStr += await user.addXP({ - skillName: SkillsEnum.Slayer, - amount: deathTime === null ? 125_000 : calcPercentOfNum(percentMadeItThrough, 25_000), - duration - }); + xpBonuses.push( + await user.addXP({ + skillName: SkillsEnum.Slayer, + amount: deathTime === null ? 125_000 : calcPercentOfNum(percentMadeItThrough, 25_000), + duration + }) + ); } + const xpStr = xpBonuses.join(', '); if (!deathTime) { await incrementMinigameScore(userID, 'inferno', 1); } @@ -156,7 +166,7 @@ export const infernoTask: MinionTask = { if (diedPreZuk) { text += `You died ${formatDuration(deathTime!)} into your attempt, before you reached Zuk.`; - chatText = `You die before you even reach TzKal-Zuk...atleast you tried, I give you ${baseBank.amount( + chatText = `You die before you even reach TzKal-Zuk... At least you tried, I give you ${baseBank.amount( 'Tokkul' )}x Tokkul.`; } else if (diedZuk) { From 6af89b175fc5e4c66724a3b822c5d8fb38186e15 Mon Sep 17 00:00:00 2001 From: GC <30398469+gc@users.noreply.github.com> Date: Thu, 15 Aug 2024 07:26:57 +1000 Subject: [PATCH 142/145] Catch role task errors better (#6002) --- src/lib/rolesTask.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/lib/rolesTask.ts b/src/lib/rolesTask.ts index b9c7d0e375..2145740d7d 100644 --- a/src/lib/rolesTask.ts +++ b/src/lib/rolesTask.ts @@ -15,6 +15,7 @@ import { TeamLoot } from './simulation/TeamLoot'; import { SkillsArray } from './skilling/types'; import type { ItemBank } from './types'; import { fetchMultipleCLLeaderboards } from './util/clLeaderboard'; +import { logError } from './util/logError'; const RoleResultSchema = z.object({ roleID: z.string().min(17).max(19), @@ -365,12 +366,17 @@ export async function runRolesTask(dryRun: boolean): Promise { for (const [name, fn] of tup) { promiseQueue.add(async () => { const stopwatch = new Stopwatch(); - const res = await fn(); - console.log(`[RolesTask] Ran ${name} in ${stopwatch.stop()}`); - const [validResults, invalidResults] = partition(res, i => RoleResultSchema.safeParse(i).success); - results.push(...validResults); - if (invalidResults.length > 0) { - console.error(`[RolesTask] Invalid results for ${name}: ${JSON.stringify(invalidResults)}`); + try { + const res = await fn(); + const [validResults, invalidResults] = partition(res, i => RoleResultSchema.safeParse(i).success); + results.push(...validResults); + if (invalidResults.length > 0) { + logError(`[RolesTask] Invalid results for ${name}: ${JSON.stringify(invalidResults)}`); + } + } catch (err) { + logError(`[RolesTask] Error in ${name}: ${err}`); + } finally { + debugLog(`[RolesTask] Ran ${name} in ${stopwatch.stop()}`); } }); } From 8f328de2de3c150b0c8d1113a1f6d554e131d775 Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Thu, 15 Aug 2024 07:36:50 +1000 Subject: [PATCH 143/145] Catch top-level errors from roles task --- src/mahoji/commands/rp.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/mahoji/commands/rp.ts b/src/mahoji/commands/rp.ts index 0ca844d664..ee0f40f6ce 100644 --- a/src/mahoji/commands/rp.ts +++ b/src/mahoji/commands/rp.ts @@ -29,6 +29,7 @@ import getOSItem from '../../lib/util/getOSItem'; import { handleMahojiConfirmation } from '../../lib/util/handleMahojiConfirmation'; import { deferInteraction } from '../../lib/util/interactionReply'; import itemIsTradeable from '../../lib/util/itemIsTradeable'; +import { logError } from '../../lib/util/logError'; import { makeBankImage } from '../../lib/util/makeBankImage'; import { migrateUser } from '../../lib/util/migrateUser'; import { parseBank } from '../../lib/util/parseStringBank'; @@ -621,7 +622,13 @@ Date: ${dateFm(date)}`; for (const action of actions) { if (options.action[action.name]) { if (!action.allowed(adminUser)) return randArrItem(gifs); - return action.run(); + try { + const result = await action.run(); + return result; + } catch (err) { + logError(err); + return 'An error occurred.'; + } } } } From 84d04b00a540f930e3617c92a55c85a7f5319207 Mon Sep 17 00:00:00 2001 From: gc <30398469+gc@users.noreply.github.com> Date: Thu, 15 Aug 2024 08:09:46 +1000 Subject: [PATCH 144/145] Improve role task queries --- src/lib/rawSql.ts | 12 ++++++++++++ src/lib/rolesTask.ts | 9 +++++---- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/lib/rawSql.ts b/src/lib/rawSql.ts index 54b68fc695..45def967e9 100644 --- a/src/lib/rawSql.ts +++ b/src/lib/rawSql.ts @@ -1,4 +1,5 @@ import { Prisma } from '@prisma/client'; +import { logError } from './util/logError'; const u = Prisma.UserScalarFieldEnum; @@ -14,3 +15,14 @@ SET ${u.cl_array} = ( ) WHERE ${u.id} = '${userID}';` }; + +export async function loggedRawPrismaQuery(query: string): Promise { + try { + const result = await prisma.$queryRawUnsafe(query); + return result; + } catch (err) { + logError(err, { query: query.slice(0, 100) }); + } + + return null; +} diff --git a/src/lib/rolesTask.ts b/src/lib/rolesTask.ts index 2145740d7d..eb76b1d692 100644 --- a/src/lib/rolesTask.ts +++ b/src/lib/rolesTask.ts @@ -1,4 +1,4 @@ -import { noOp, uniqueArr } from 'e'; +import { noOp, notEmpty, uniqueArr } from 'e'; import { SupportServer } from '../config'; import { BadgesEnum, Roles } from '../lib/constants'; @@ -11,6 +11,7 @@ import { partition } from 'remeda'; import z from 'zod'; import { type CommandResponse, Stopwatch, convertXPtoLVL, getUsernameSync, returnStringOrFile } from '../lib/util'; import { ClueTiers } from './clues/clueTiers'; +import { loggedRawPrismaQuery } from './rawSql'; import { TeamLoot } from './simulation/TeamLoot'; import { SkillsArray } from './skilling/types'; import type { ItemBank } from './types'; @@ -385,8 +386,8 @@ export async function runRolesTask(dryRun: boolean): Promise { debugLog(`Finished role functions, ${results.length} results`); - const allBadgeIDs = uniqueArr(results.map(i => i.badge)); - const allRoleIDs = uniqueArr(results.map(i => i.roleID)); + const allBadgeIDs = uniqueArr(results.map(i => i.badge)).filter(notEmpty); + const allRoleIDs = uniqueArr(results.map(i => i.roleID)).filter(notEmpty); if (!dryRun) { const roleNames = new Map(); @@ -396,7 +397,7 @@ export async function runRolesTask(dryRun: boolean): Promise { // Remove all top badges from all users (and add back later) debugLog('Removing badges...'); const badgeIDs = `ARRAY[${allBadgeIDs.join(',')}]`; - await prisma.$queryRawUnsafe(` + await loggedRawPrismaQuery(` UPDATE users SET badges = badges - ${badgeIDs} WHERE badges && ${badgeIDs} From 573a6080e4813ca003e576a0f14a9efbe936a022 Mon Sep 17 00:00:00 2001 From: Badora II <79532596+Arodab@users.noreply.github.com> Date: Fri, 16 Aug 2024 01:26:17 -0300 Subject: [PATCH 145/145] Add files via upload --- fish.test.ts | 94 ++++++ fish.ts | 398 ++++++++++++++++++++++++ fishing.ts | 368 +++++++++++++++++++++++ fishingActivity.ts | 211 +++++++++++++ minionStatus.ts | 668 +++++++++++++++++++++++++++++++++++++++++ minions.ts | 622 ++++++++++++++++++++++++++++++++++++++ repeatStoredTrip.ts | 716 ++++++++++++++++++++++++++++++++++++++++++++ types.ts | 362 ++++++++++++++++++++++ 8 files changed, 3439 insertions(+) create mode 100644 fish.test.ts create mode 100644 fish.ts create mode 100644 fishing.ts create mode 100644 fishingActivity.ts create mode 100644 minionStatus.ts create mode 100644 minions.ts create mode 100644 repeatStoredTrip.ts create mode 100644 types.ts diff --git a/fish.test.ts b/fish.test.ts new file mode 100644 index 0000000000..58984166d7 --- /dev/null +++ b/fish.test.ts @@ -0,0 +1,94 @@ +import { Bank } from 'oldschooljs'; +import { describe, it } from 'vitest'; + +import { Gear } from '../../../src/lib/structures/Gear'; +import { fishCommand } from '../../../src/mahoji/commands/fish'; +import { testRunCmd } from '../utils'; + +describe('Fish Command', () => { + it('should handle insufficient fishing level', () => { + testRunCmd({ + cmd: fishCommand, + opts: { name: 'Trout/Salmon', quantity: 1 }, + result: '<:minion:778418736180494347> Your minion needs 20 Fishing to fish Trout/Salmon.' + }); + }); + + it('should handle insufficient QP', () => { + testRunCmd({ + cmd: fishCommand, + opts: { name: 'karambwanji', quantity: 1 }, + user: { skills_fishing: 9_999_999, QP: 0 }, + result: 'You need 15 qp to catch those!' + }); + }); + + it('should handle invalid fish', () => { + testRunCmd({ + cmd: fishCommand, + opts: { name: 'asdf' }, + result: 'Thats not a valid fish to catch.' + }); + }); + + it('should handle insufficient barb fishing levels', () => { + testRunCmd({ + cmd: fishCommand, + opts: { name: 'Barbarian fishing' }, + user: { skills_fishing: 1 }, + result: '<:minion:778418736180494347> Your minion needs 48 Fishing to fish Barbarian fishing.' + }); + }); + + it('should fish', () => { + testRunCmd({ + cmd: fishCommand, + opts: { name: 'Shrimps/Anchovies' }, + result: "<:minion:778418736180494347> Your minion is now fishing Shrimps/Anchovies, it'll take around 30 minutes to finish" + }); + }); + + it('should catch insufficient feathers', () => { + testRunCmd({ + cmd: fishCommand, + opts: { name: 'Barbarian fishing' }, + user: { + skills_fishing: 999_999, + skills_agility: 999_999, + skills_strength: 999_999, + meleeGear: new Gear({ weapon: 'Pearl barbarian rod' }) + }, + result: 'You need Feather to fish Barbarian fishing!' + }); + }); + + it('should boost', () => { + testRunCmd({ + cmd: fishCommand, + opts: { name: 'Barbarian fishing' }, + user: { + skills_fishing: 999_999, + skills_agility: 999_999, + skills_strength: 999_999, + bank: new Bank().add('Feather', 1000) + }, + result: `<:minion:778418736180494347> Your minion is now fishing 100x Barbarian fishing, it'll take around 6 minutes, 1 second to finish. + +**Boosts:** 5% for Pearl barbarian rod.` + }); + }); + + it('should fish barrel boost', () => { + testRunCmd({ + cmd: fishCommand, + opts: { name: 'shrimps' }, + user: { + skills_fishing: 999_999, + meleeGear: new Gear({ cape: 'Fish sack barrel' }) + }, + result: `<:minion:778418736180494347> Your minion is now fishing Shrimps/Anchovies, it'll take around 39 minutes, 1 second to finish. + + **Boosts:** +9 trip minutes and +28 inventory slots for having a Fish sack barrel.` + }); + }); +}); diff --git a/fish.ts b/fish.ts new file mode 100644 index 0000000000..552dfa698b --- /dev/null +++ b/fish.ts @@ -0,0 +1,398 @@ +import { stringMatches } from '@oldschoolgg/toolkit'; +import type { CommandRunOptions } from '@oldschoolgg/toolkit'; +import { ApplicationCommandOptionType } from 'discord.js'; +import { Time } from 'e'; +import { Bank } from 'oldschooljs'; +import TzTokJad from 'oldschooljs/dist/simulation/monsters/special/TzTokJad'; +import { WildernessDiary, userhasDiaryTier } from '../../lib/diaries'; + +import Fishing from '../../lib/skilling/skills/fishing'; +import { Fish, SkillsEnum } from '../../lib/skilling/types'; +import type { FishingActivityTaskOptions } from '../../lib/types/minions'; +//import { formatDuration, itemID, itemNameFromID } from '../../lib/util'; +import { formatDuration, itemNameFromID } from '../../lib/util'; +import addSubTaskToActivityTask from '../../lib/util/addSubTaskToActivityTask'; +import { calcMaxTripLength } from '../../lib/util/calcMaxTripLength'; +import type { OSBMahojiCommand } from '../lib/util'; +import { MUserClass } from '../../lib/MUser'; + + + +function radasBlessing(user: MUser) { + const blessingBoosts = [ + ["Rada's blessing 4", 8], + ["Rada's blessing 3", 6], + ["Rada's blessing 2", 4], + ["Rada's blessing 1", 2] + ]; + + for (const [itemName, boostPercent] of blessingBoosts) { + if (user.hasEquipped(itemName)) { + return { blessingEquipped: true, blessingChance: boostPercent as number }; + } + } + return { blessingEquipped: false, blessingChance: 0 }; +} + + + +function rollExtraLoot( + lootAmount: number, + flakesUsed: number, + currentInv: number, + blessingChance: number, + spirit_flakes: boolean, + flakesQuantity: number +): [number, number, number] { + currentInv++; + if (Math.random() < blessingChance / 100) { + lootAmount++; + currentInv++; + } + if (spirit_flakes && flakesUsed < flakesQuantity && Math.random() < 0.5) { + lootAmount++; + flakesUsed++; + currentInv++; + } + // Return updated values + return [lootAmount, flakesUsed, currentInv]; +} + + +function determineFishingTime( + quantity: number, + tripTicks: number, + powerfish: boolean, + spirit_flakes: boolean, + fish: Fish, + user: MUserClass, + invSlots: number, + blessingChance: number, + flakesQuantity: number, + harpoonBoost: number +) { + let ticksElapsed = 0; + let catches1 = 0, catches2 = 0, catches3 = 0; + let lootAmount1 = 0, lootAmount2 = 0, lootAmount3 = 0; + let flakesUsed = 0; + let currentInv = 0; + + let fishLvl = user.skillLevel(SkillsEnum.Fishing); + let effFishLvl = fishLvl; + if (fishLvl > 68) { + if (fish.name === 'Shark' || fish.name === 'Mackerel/Cod/Bass' || fish.name === 'Lobster') { + effFishLvl += 7; // fishing guild boost + } else if (fish.name === 'Tuna/Swordfish' && !powerfish) { + effFishLvl += 7; // can't 2t in the guild + } + } + + // probabilities of catching a fish at the user's fishing lvl + let p1 = harpoonBoost * (fish.chance1Lvl99! - (99 - effFishLvl) * (fish.chance1Lvl99! - fish.chance1Lvl1!) / (99 - 1)); + let p2 = fish.id2 === undefined ? 0 : harpoonBoost * (fish.chance2Lvl99! - (99 - effFishLvl) * (fish.chance2Lvl99! - fish.chance2Lvl1!) / (99 - 1)); + let p3 = fish.id3 === undefined ? 0 : harpoonBoost * (fish.chance3Lvl99! - (99 - effFishLvl) * (fish.chance3Lvl99! - fish.chance2Lvl1!) / (99 - 1)); + + let ticksPerRoll = fish.ticksPerRoll!; + let lostTicks = fish.lostTicks!; + let bankingTime = fish.bankingTime; + + if (fish.name === 'Barbarian fishing') { + if (powerfish) { + ticksPerRoll = 3; + lostTicks = 0.02; // more focused + } + if (user.allItemsOwned.has('Fishing cape') || user.allItemsOwned.has('Fishing cape (t)') || user.allItemsOwned.has('Max cape')) { + bankingTime = 20; + } + } else if (fish.name === 'Trout/Salmon') { + if (powerfish) { + ticksPerRoll = 3; + lostTicks = 0.03; + } + } else if (fish.name === 'Tuna/Swordfish') { + if (powerfish) { + ticksPerRoll = 2; + lostTicks = 0.01; + } + } + + if (powerfish) { + while (ticksElapsed < tripTicks) { + if (p3 != 0 && Math.random() < p3) { + catches3++; // roll for the highest lvl fish first + } else if (p2 != 0 && Math.random() < p2) { + catches2++; // then the second only if first one wasn't caught + } else if (Math.random() < p1) { + catches1++; + } + ticksElapsed += ticksPerRoll! * (1 + lostTicks!); // only part of the code that's not exactly how it works in osrs approximate + + if (catches1 + catches2 + catches3 >= quantity) { + break; + } + } + } else { + while (ticksElapsed < tripTicks) { + if (p3 != 0 && Math.random() < p3) { + catches3++; + lootAmount3++; + [lootAmount3, flakesUsed, currentInv] = rollExtraLoot(lootAmount3, flakesUsed, currentInv, blessingChance, spirit_flakes, flakesQuantity); + } else if (p2 != 0 && Math.random() < p2) { + catches2++; + lootAmount2++; + [lootAmount2, flakesUsed, currentInv] = rollExtraLoot(lootAmount2, flakesUsed, currentInv, blessingChance, spirit_flakes, flakesQuantity); + } else if (Math.random() < p1) { + catches1++; + lootAmount1++; + [lootAmount1, flakesUsed, currentInv] = rollExtraLoot(lootAmount1, flakesUsed, currentInv, blessingChance, spirit_flakes, flakesQuantity); + } + + ticksElapsed += ticksPerRoll! * (1 + lostTicks!); + + if (catches1 + catches2 + catches3 >= quantity) { + break; + } + + if (currentInv >= invSlots) { + ticksElapsed += bankingTime!; + currentInv = 0; + } + + } + } + + return { catches1, catches2, catches3, lootAmount1, lootAmount2, lootAmount3, ticksElapsed, flakesUsed }; +} + + + + +export const fishCommand: OSBMahojiCommand = { + name: 'fish', + description: 'Send your minion to fish fish.', + attributes: { + requiresMinion: true, + requiresMinionNotBusy: true, + examples: ['/fish name:Shrimp'] + }, + options: [ + { + type: ApplicationCommandOptionType.String, + name: 'name', + description: 'The thing you want to fish.', + required: true, + autocomplete: async (value: string) => { + return Fishing.Fishes.filter(i => + !value ? true : i.name.toLowerCase().includes(value.toLowerCase()) + ).map(i => ({ + name: i.name, + value: i.name + })); + } + }, + { + type: ApplicationCommandOptionType.Integer, + name: 'minutes', + description: 'Trip length in minutes (optional).', + required: false, + min_value: 1 + }, + { + type: ApplicationCommandOptionType.Boolean, + name: 'powerfish', + description: 'Set this to true to powerfish. Higher xp/hour, no loot (default false, optional).', + required: false + }, + { + type: ApplicationCommandOptionType.Boolean, + name: 'spirit_flakes', + description: 'Set this to true to use spirit flakes (default false, optional).', + required: false + } + ], + run: async ({ + options, + userID, + channelID + }: CommandRunOptions<{ + name: string; + quantity?: number, + powerfish?: boolean, + spirit_flakes?: boolean + }>) => { + const user = await mUserFetch(userID); + const fish = Fishing.Fishes.find( + fish => + stringMatches(fish.id, options.name) || + stringMatches(fish.name, options.name) || + fish.alias?.some(alias => stringMatches(alias, options.name)) + ); + if (!fish) return 'Thats not a valid fish to catch.'; + + let { quantity, powerfish, spirit_flakes } = options; + + quantity = quantity ?? 3000; + powerfish = powerfish ?? false; + if (powerfish) { + spirit_flakes = false; // don't use flakes if power fishing + } + spirit_flakes = spirit_flakes ?? false; + + // requirement checks + if (user.skillLevel(SkillsEnum.Fishing) < fish.level) { + return `${user.minionName} needs ${fish.level} Fishing to fish ${fish.name}.`; + } + + if (fish.qpRequired) { + if (user.QP < fish.qpRequired) { + return `You need ${fish.qpRequired} qp to catch those!`; + } + } + + if ( + fish.name === 'Barbarian fishing' && + (user.skillLevel(SkillsEnum.Agility) < 15 || user.skillLevel(SkillsEnum.Strength) < 15) + ) { + return 'You need at least 15 Agility and Strength to do Barbarian Fishing.'; + } + + if (fish.name === 'Infernal eel') { + const jadKC = await user.getKC(TzTokJad.id); + if (jadKC === 0) { + return 'You are not worthy JalYt. Before you can fish Infernal Eels, you need to have defeated the mighty TzTok-Jad!'; + } + } + + const anglerOutfit = Object.keys(Fishing.anglerItems).map(i => itemNameFromID(Number.parseInt(i))); + if (fish.name === 'Minnow' && anglerOutfit.some(test => !user.hasEquippedOrInBank(test!))) { + return 'You need to own the Angler Outfit to fish for Minnows.'; + } + + // boosts + const boosts = []; + if (fish.name === 'Tuna/Swordfish' || fish.name === 'Shark') { + if (user.hasEquipped('Crystal harpoon')) { + boosts.push('35% for Crystal harpoon'); + } else if (user.hasEquipped('Dragon harpoon')) { + boosts.push('20% for Dragon harpoon'); + } else if (user.hasEquipped('Infernal harpoon')) { + boosts.push('20% for Infernal harpoon'); + } + } + + if (powerfish) { + boosts.push('**Powerfishing**'); + } + + if (!powerfish) { + if (user.allItemsOwned.has('Fish sack barrel') || user.allItemsOwned.has('Fish barrel')) { + if (fish.name === 'Minnow' || fish.name === 'Karambwanji' || fish.name === 'Infernal eel') { + boosts.push(`+9 trip minutes for having a ${user.allItemsOwned.has('Fish sack barrel') ? 'Fish sack barrel' : 'Fish barrel'}`); + } else { + boosts.push(`+9 trip minutes and +28 inventory slots for having a ${user.allItemsOwned.has('Fish sack barrel') ? 'Fish sack barrel' : 'Fish barrel'}`); + } + } + } + + if (fish.name === 'Dark crab') { + const [hasWildyElite] = await userhasDiaryTier(user, WildernessDiary.elite); + if (hasWildyElite) { + fish.chance1Lvl1 = 0.0961; + fish.chance1Lvl99 = 0.3439; + boosts.push('Increased dark crab catch rate from having the Elite Wilderness Diary'); + } + } + + if (spirit_flakes) { + if (!user.bank.has('Spirit flakes')) { + return 'You need to have at least one spirit flake!'; + } + + boosts.push(`\n50% more fish from using spirit flakes`); + } + + const { blessingEquipped, blessingChance } = radasBlessing(user); + if (blessingEquipped) { + boosts.push(`\nYour Rada's Blessing gives ${blessingChance}% chance of extra fish`); + } + + + let harpoonBoost = 1.0; + if (fish.name === 'Tuna/Swordfish' || fish.name === 'Shark') { + if (user.hasEquipped("Dragon harpoon") || user.hasEquipped("Infernal harpoon")) { + harpoonBoost = 1.2; + } else if (user.hasEquipped("Crystal harpoon")) { + harpoonBoost = 1.35; + } + } + + let invSlots = 26; + if (user.allItemsOwned.has('Fish sack barrel') || user.allItemsOwned.has('Fish barrel')) { + invSlots += 28; + } + + let maxTripLength = calcMaxTripLength(user, 'Fishing'); + if (!powerfish && (user.allItemsOwned.has('Fish sack barrel') || user.allItemsOwned.has('Fish barrel'))) { + maxTripLength += Time.Minute * 9; + } + let tripTicks = maxTripLength / (Time.Second * 0.6); + + let flakesQuantity = user.bank.amount('Spirit flakes'); + + if (fish.bait) { + const baseCost = new Bank().add(fish.bait); + const maxCanDo = user.bank.fits(baseCost); + if (maxCanDo === 0) { + return `You need ${itemNameFromID(fish.bait)} to fish ${fish.name}!`; + } + + if (maxCanDo < quantity) { + quantity = maxCanDo; + } + } + + // determining fish time and quantities + const { catches1: Qty1, catches2: Qty2, catches3: Qty3, lootAmount1: loot1, lootAmount2: loot2, lootAmount3: loot3, ticksElapsed: tripLength, flakesUsed: flakesToRemove } = determineFishingTime( + quantity, + tripTicks, + powerfish, + spirit_flakes, + fish, + user, + invSlots, + blessingChance, + flakesQuantity, + harpoonBoost + ); + + + let duration = Time.Second * 0.6 * tripLength; + + await addSubTaskToActivityTask({ + fishID: fish.id, + userID: user.id, + channelID: channelID.toString(), + duration: duration, + quantity: quantity, + Qty1: Qty1, + Qty2: Qty2, + Qty3: Qty3, + loot1: loot1, + loot2: loot2, + loot3: loot3, + flakesToRemove: flakesToRemove, + powerfish: powerfish, + spirit_flakes: spirit_flakes, + type: 'Fishing' + }); + + let response = `${user.minionName} is now fishing ${fish.name}, it'll take around ${formatDuration(duration)} to finish.`; + + if (boosts.length > 0) { + response += `\n\n**Boosts:** ${boosts.join(', ')}.`; + } + + return response; + } +}; + diff --git a/fishing.ts b/fishing.ts new file mode 100644 index 0000000000..28b0d1cfbd --- /dev/null +++ b/fishing.ts @@ -0,0 +1,368 @@ +import { Emoji } from '../../constants'; +import itemID from '../../util/itemID'; +import type { Fish } from '../types'; +import { SkillsEnum } from '../types'; + +const fishes: Fish[] = [ + { + name: 'Shrimps/Anchovies', + level: 1, + xp: 10, + id: itemID('Raw shrimps'), + chance1Lvl1: 0.1373, // catch chance for fish 1 at lvl 1 + chance1Lvl99: 1.0000, + + level2: 15, + xp2: 40, + id2: itemID('Raw anchovies'), + chance2Lvl1: 0.0937, + chance2Lvl99: 0.5039, + + petChance: 435_165, + clueScrollChance: 870_330, + lostTicks: 0.05, // percentage of ticks spent moving/dropping, + bankingTime: 30, + ticksPerRoll: 6 + }, + { + name: 'Sardine/Herring', + level: 5, + xp: 20, + id: itemID('Raw sardine'), + chance1Lvl1: 0.1267, + chance1Lvl99: 0.7539, + + level2: 10, + xp2: 30, + id2: itemID('Raw herring'), + chance2Lvl1: 0.1273, + chance2Lvl99: 0.5039, + + bait: itemID('Fishing bait'), + petChance: 528_000, + clueScrollChance: 1_056_000, + lostTicks: 0.05, + bankingTime: 30, + ticksPerRoll: 5 + }, + { + name: 'Karambwanji', + level: 5, + xp: 20, + id: itemID('Raw karambwanji'), + chance1Lvl1: 0.3945, + chance1Lvl99: 0.9805, + + petChance: 443_697, + qpRequired: 15, + clueScrollChance: 443_697, + lostTicks: 0.01, + bankingTime: 0, + ticksPerRoll: 6 + }, + { + name: 'Mackerel/Cod/Bass', + level: 16, + xp: 20, + id: itemID('Raw mackerel'), + chance1Lvl1: 0.0645, + chance1Lvl99: 0.2897, + + level2: 23, + xp2: 45, + id2: itemID('Raw cod'), + chance2Lvl1: 0.0173, + chance2Lvl99: 0.2188, + + level3: 46, + xp3: 100, + id3: itemID('Raw bass'), + bigFish: itemID('Big bass'), + bigFishRate: 1000, + chance3Lvl1: 0.0156, + chance3Lvl99: 0.1602, + + petChance: 382_609, + clueScrollChance: 1_147_827, + lostTicks: 0.05, + bankingTime: 25, + ticksPerRoll: 6 + }, + { + name: 'Trout/Salmon', + level: 20, + xp: 50, + id: itemID('Raw trout'), + chance1Lvl1: 0.0174, + chance1Lvl99: 0.7538, + + level2: 30, + xp2: 70, + id2: itemID('Raw salmon'), + chance2Lvl1: 0.0683, + chance2Lvl99: 0.3789, + + petChance: 461_808, + bait: itemID('Feather'), + clueScrollChance: 923_616, + lostTicks: 0.05, + bankingTime: 30, + ticksPerRoll: 5 + }, + { + name: 'Pike', + level: 25, + xp: 60, + id: itemID('Raw pike'), + chance1Lvl1: 0.0685, + chance1Lvl99: 0.3789, + + petChance: 305_792, + bait: itemID('Fishing bait'), + clueScrollChance: 305_792, + lostTicks: 0.05, + bankingTime: 30, + ticksPerRoll: 5 + }, + { + name: 'Tuna/Swordfish', + alias: ['sword, sf'], + level: 35, + xp: 80, + id: itemID('Raw tuna'), + chance1Lvl1: 0.0326, + chance1Lvl99: 0.2539, + + level2: 50, + xp2: 100, + id2: itemID('Raw swordfish'), + bigFish: itemID('Big swordfish'), + bigFishRate: 2500, + chance2Lvl1: 0.0196, + chance2Lvl99: 0.1914, + + petChance: 128_885, + clueScrollChance: 257_770, + lostTicks: 0.05, + bankingTime: 25, + ticksPerRoll: 6 + }, + { + name: 'Cave eel', + level: 38, + xp: 80, + id: itemID('Raw cave eel'), + chance1Lvl1: 0.1900, + chance1Lvl99: 0.3164, + lostTicks: 0.05, + bankingTime: 40, + ticksPerRoll: 5 + }, + { + name: 'Lobster', + alias: ['lobs'], + level: 40, + xp: 90, + id: itemID('Raw lobster'), + chance1Lvl1: 0.0247, + chance1Lvl99: 0.3750, + petChance: 116_129, + clueScrollChance: 116_129, + lostTicks: 0.05, + bankingTime: 25, + ticksPerRoll: 6 + }, + { + name: 'Monkfish', + alias: ['monk'], + level: 62, + xp: 120, + id: itemID('Raw monkfish'), + chance1Lvl1: 0.1900, + chance1Lvl99: 0.3555, + petChance: 138_583, + qpRequired: 100, + clueScrollChance: 138_583, + lostTicks: 0.10, + bankingTime: 20, + ticksPerRoll: 6 + }, + { + name: 'Karambwan', + alias: ['karam'], + level: 65, + xp: 50, + id: itemID('Raw karambwan'), + chance1Lvl1: 0.0210, + chance1Lvl99: 0.6289, + petChance: 170_874, + bait: itemID('Raw karambwanji'), + clueScrollChance: 170_874, + lostTicks: 0.00, // fishing spots never moves + bankingTime: 25, + ticksPerRoll: 4 + }, + { + name: 'Shark', + alias: ['shark'], + level: 76, + xp: 110, + id: itemID('Raw shark'), + chance1Lvl1: 0.0102, + chance1Lvl99: 0.1602, + petChance: 82_243, + bigFish: itemID('Big shark'), + bigFishRate: 5000, + clueScrollChance: 82_243, + lostTicks: 0.05, + bankingTime: 25, + ticksPerRoll: 6 + }, + { + name: 'Anglerfish', + alias: ['angler'], + level: 82, + xp: 120, + id: itemID('Raw anglerfish'), + chance1Lvl1: 0.0096, + chance1Lvl99: 0.1445, + petChance: 78_649, + bait: itemID('Sandworms'), + qpRequired: 40, + clueScrollChance: 78_649, + lostTicks: 0.05, + bankingTime: 30, + ticksPerRoll: 5 + }, + { + name: 'Minnow', + alias: ['minnows'], + level: 82, + xp: 26.1, + id: itemID('Minnow'), + chance1Lvl1: 0.6666, // no info on catch chance + chance1Lvl99: 0.9259, // handpicked to match wiki rates + petChance: 977_778, + qpRequired: 1, + clueScrollChance: 977_778, + lostTicks: 0.25, + bankingTime: 0, // stackable + ticksPerRoll: 2 + }, + { + name: 'Dark crab', + alias: ['crab', 'dark'], + level: 85, + xp: 130, + id: itemID('Raw dark crab'), + chance1Lvl1: 0.0230, + chance1Lvl99: 0.1602, + petChance: 149_434, + bait: itemID('Dark fishing bait'), + clueScrollChance: 149_434, + lostTicks: 0.05, + bankingTime: 0, + ticksPerRoll: 6 + }, + { + name: 'Barbarian fishing', + alias: ['barb', 'barbarian'], + level: 48, + xp: 50, + id: itemID('Leaping trout'), + chance1Lvl1: 32 / 255, + chance1Lvl99: 192 / 255, + + level2: 58, + xp2: 70, + id2: itemID('Leaping salmon'), + chance2Lvl1: 16 / 255, + chance2Lvl99: 96 / 255, + + level3: 70, + xp3: 80, + id3: itemID('Leaping sturgeon'), + chance3Lvl1: 8 / 255, + chance3Lvl99: 64 / 255, + + petChance: 426_954, + bait: itemID('Feather'), + clueScrollChance: 1_280_862, + lostTicks: 0.05, + bankingTime: 40, + ticksPerRoll: 5 + }, + { + name: 'Infernal eel', + level: 80, + xp: 95, + id: itemID('Infernal eel'), + petChance: 160_000, + bait: itemID('Fishing bait'), + clueScrollChance: 165_000, + chance1Lvl1: 0.1253, + chance1Lvl99: 0.3672, + lostTicks: 0.10, + bankingTime: 0, + ticksPerRoll: 5 + } +]; + +// Types of fish in camdozaal +const camdozaalFishes: Fish[] = [ + { + level: 7, + xp: 8, + id: itemID('Raw guppy'), + name: 'Raw guppy', + petChance: 257_770, + timePerFish: 5.5, + clueScrollChance: 257_770 + }, + { + level: 20, + xp: 16, + id: itemID('Raw cavefish'), + name: 'Raw cavefish', + petChance: 257_770, + timePerFish: 5.5, + clueScrollChance: 257_770 + }, + { + level: 33, + xp: 24, + id: itemID('Raw tetra'), + name: 'Raw tetra', + petChance: 257_770, + timePerFish: 5.5, + clueScrollChance: 257_770 + }, + { + level: 46, + xp: 33, + id: itemID('Raw catfish'), + name: 'Raw catfish', + petChance: 257_770, + timePerFish: 5.5, + clueScrollChance: 257_770 + } +]; + +const anglerItems: { [key: number]: number } = { + [itemID('Angler hat')]: 0.4, + [itemID('Angler top')]: 0.8, + [itemID('Angler waders ')]: 0.6, + [itemID('Angler boots')]: 0.2 +}; + +const Fishing = { + aliases: ['fishing'], + Fishes: fishes, + camdozaalFishes, + id: SkillsEnum.Fishing, + emoji: Emoji.Fishing, + anglerItems, + name: 'Fishing' +}; + +export default Fishing; diff --git a/fishingActivity.ts b/fishingActivity.ts new file mode 100644 index 0000000000..37ee34fd82 --- /dev/null +++ b/fishingActivity.ts @@ -0,0 +1,211 @@ +//import { calcPercentOfNum, percentChance, randInt } from 'e'; +import { calcPercentOfNum } from 'e'; +import { Bank } from 'oldschooljs'; +import { z } from 'zod'; +//import { Time } from 'e'; +import { Emoji, Events } from '../../lib/constants'; +import addSkillingClueToLoot from '../../lib/minions/functions/addSkillingClueToLoot'; +import Fishing from '../../lib/skilling/skills/fishing'; +import { SkillsEnum } from '../../lib/skilling/types'; +import type { FishingActivityTaskOptions } from '../../lib/types/minions'; +import { roll, skillingPetDropRate } from '../../lib/util'; +import { handleTripFinish } from '../../lib/util/handleTripFinish'; +// itemID from '../../lib/util/itemID'; +import { anglerBoostPercent } from '../../mahoji/mahojiSettings'; + +const allFishIDs = Fishing.Fishes.flatMap(fish => [fish.id, fish.id2, fish.id3]); + +export const fishingTask: MinionTask = { + type: 'Fishing', + dataSchema: z.object({ + type: z.literal('Fishing'), + fishID: z.number().refine(fishID => allFishIDs.includes(fishID), { + message: 'Invalid fish ID' + }), + quantity: z.number().min(1) + }), + async run(data: FishingActivityTaskOptions) { + let { fishID, userID, channelID, duration, spirit_flakes, + Qty1, Qty2 = 0, Qty3 = 0, loot1 = 0, loot2 = 0, loot3 = 0, flakesToRemove } = data; + + spirit_flakes = spirit_flakes ?? false; + + const user = await mUserFetch(userID); + const fishLvl = user.skillLevel(SkillsEnum.Fishing); + + const minnowQuantity: { [key: number]: number[] } = { + 99: [10, 14], + 95: [11, 13], + 90: [10, 13], + 85: [10, 11], + 1: [10, 10] + }; + + let baseMinnow = [10, 10]; + for (const [level, quantities] of Object.entries(minnowQuantity).reverse()) { + if (fishLvl >= Number.parseInt(level)) { + baseMinnow = quantities; + break; + } + } + + const baseKarambwanji = 1 + Math.floor(fishLvl / 5); + + let xpReceived = 0; + let agilityXpReceived = 0; + let strengthXpReceived = 0; + + const fish = Fishing.Fishes.find(fish => fish.id === fishID)!; + + // adding xp and loot + + xpReceived += fish.xp * Qty1; + if (Qty2 != 0) xpReceived += fish.xp2! * Qty2; + if (Qty3 != 0) xpReceived += fish.xp3! * Qty3; + + if (fish.name === 'Barbarian fishing') { + agilityXpReceived += 7 * Qty3 + 6 * Qty2 + 5 * Qty1; + strengthXpReceived += 7 * Qty3 + 6 * Qty2 + 5 * Qty1; + } + + + // If they have the entire angler outfit, give an extra 0.5% xp bonus + let bonusXP = 0; + if ( + user.gear.skilling.hasEquipped( + Object.keys(Fishing.anglerItems).map(i => Number.parseInt(i)), + true + ) + ) { + const amountToAdd = Math.floor(xpReceived * (2.5 / 100)); + xpReceived += amountToAdd; + bonusXP += amountToAdd; + } else { + // For each angler item, check if they have it, give its' XP boost if so. + for (const [itemID, bonus] of Object.entries(Fishing.anglerItems)) { + if (user.hasEquipped(Number.parseInt(itemID))) { + const amountToAdd = Math.floor(xpReceived * (bonus / 100)); + xpReceived += amountToAdd; + bonusXP += amountToAdd; + } + } + } + + + let xpRes = await user.addXP({ + skillName: SkillsEnum.Fishing, + amount: xpReceived, + duration + }); + xpRes += + agilityXpReceived > 0 + ? await user.addXP({ + skillName: SkillsEnum.Agility, + amount: agilityXpReceived, + duration + }) + : ''; + xpRes += + strengthXpReceived > 0 + ? await user.addXP({ + skillName: SkillsEnum.Strength, + amount: strengthXpReceived, + duration + }) + : ''; + + + const loot = new Bank(); + loot.add(fish.id3!, loot3); + loot.add(fish.id2!, loot2); + + // handling stackable fish + if (fish.name === 'Minnow') { + let sum = 0; + for (let i = 0; i < loot1; i++) { + sum += Math.floor(Math.random() * (baseMinnow[1] - baseMinnow[0] + 1)) + baseMinnow[0]; + } + loot1 = sum; + } else if (fish.name === 'Karambwanji') { + loot1 *= baseKarambwanji; + } + loot.add(fish!.id, loot1); + + let str = '' + + const totalCatches = Qty1 + Qty2 + Qty3; + str = `${user}, ${user.minionName} finished fishing ${totalCatches} ${fish.name}. ${xpRes}`; + + + const cost = new Bank(); + if (spirit_flakes) { + cost.add('Spirit flakes', flakesToRemove); + } + + if (fish.bait) { + cost.add(fish.bait, totalCatches); + } + + await user.removeItemsFromBank(cost); + + // Add clue scrolls + if (fish.clueScrollChance) { + addSkillingClueToLoot(user, SkillsEnum.Fishing, totalCatches, fish.clueScrollChance, loot); + } + + const xpBonusPercent = anglerBoostPercent(user); + if (xpBonusPercent > 0) { + bonusXP += Math.ceil(calcPercentOfNum(xpBonusPercent, xpReceived)); + } + + if (bonusXP > 0) { + str += `\n\n**Bonus XP:** ${bonusXP.toLocaleString()}`; + } + + // Roll for pet + if (fish.petChance) { + const { petDropRate } = skillingPetDropRate(user, SkillsEnum.Fishing, fish.petChance); + for (let i = 0; i < totalCatches; i++) { + if (roll(petDropRate)) { + loot.add('Heron'); + str += "\nYou have a funny feeling you're being followed..."; + globalClient.emit( + Events.ServerNotification, + `${Emoji.Fishing} **${user.badgedUsername}'s** minion, ${user.minionName}, just received a Heron while fishing ${fish.name} at level ${fishLvl} Fishing!` + ); + } + } + } + + // bigFishQuantity add this + if (fish.bigFishRate && fish.bigFish) { + let bigFishQuantity = 0; + if (fish.name === 'Shark') { + bigFishQuantity = Qty1; + } + if (fish.name === 'Tuna/Swordfish') { + bigFishQuantity = Qty2; + } + if (fish.name === 'Mackerel/Cod/Bass') { + bigFishQuantity = Qty3; + } + for (let i = 0; i < bigFishQuantity!; i++) { + if (roll(fish.bigFishRate)) { + loot.add(fish.bigFish); + } + } + } + + + await transactItems({ + userID: user.id, + collectionLog: true, + itemsToAdd: loot + }); + + str += `\n\nYou received: ${loot}.`; + + handleTripFinish(user, channelID, str, undefined, data, loot); + } +} + diff --git a/minionStatus.ts b/minionStatus.ts new file mode 100644 index 0000000000..0782428c44 --- /dev/null +++ b/minionStatus.ts @@ -0,0 +1,668 @@ +import { toTitleCase } from '@oldschoolgg/toolkit'; +import { increaseNumByPercent, reduceNumByPercent } from 'e'; +import { SkillsEnum } from 'oldschooljs/dist/constants'; + +import { collectables } from '../../mahoji/lib/abstracted_commands/collectCommand'; +import { shades, shadesLogs } from '../../mahoji/lib/abstracted_commands/shadesOfMortonCommand'; +import { ClueTiers } from '../clues/clueTiers'; +import { Emoji } from '../constants'; +import killableMonsters from '../minions/data/killableMonsters'; +import { Planks } from '../minions/data/planks'; +import { quests } from '../minions/data/quests'; +import Agility from '../skilling/skills/agility'; +import Constructables from '../skilling/skills/construction/constructables'; +import Cooking from '../skilling/skills/cooking/cooking'; +import LeapingFish from '../skilling/skills/cooking/leapingFish'; +import Crafting from '../skilling/skills/crafting'; +import Farming from '../skilling/skills/farming'; +import Firemaking from '../skilling/skills/firemaking'; +import Fishing from '../skilling/skills/fishing'; +import Herblore from '../skilling/skills/herblore/herblore'; +import Hunter from '../skilling/skills/hunter/hunter'; +import { Castables } from '../skilling/skills/magic/castables'; +import { Enchantables } from '../skilling/skills/magic/enchantables'; +import Mining from '../skilling/skills/mining'; +import Prayer from '../skilling/skills/prayer'; +import Runecraft from '../skilling/skills/runecraft'; +import Smithing from '../skilling/skills/smithing'; +import { stealables } from '../skilling/skills/thieving/stealables'; +import Woodcutting from '../skilling/skills/woodcutting/woodcutting'; +import type { + ActivityTaskOptionsWithQuantity, + AgilityActivityTaskOptions, + AlchingActivityTaskOptions, + BuryingActivityTaskOptions, + ButlerActivityTaskOptions, + CastingActivityTaskOptions, + ClueActivityTaskOptions, + CollectingOptions, + ColoTaskOptions, + ConstructionActivityTaskOptions, + CookingActivityTaskOptions, + CraftingActivityTaskOptions, + CutLeapingFishActivityTaskOptions, + DarkAltarOptions, + EnchantingActivityTaskOptions, + FarmingActivityTaskOptions, + FightCavesActivityTaskOptions, + FiremakingActivityTaskOptions, + FishingActivityTaskOptions, + FletchingActivityTaskOptions, + GauntletOptions, + GroupMonsterActivityTaskOptions, + HerbloreActivityTaskOptions, + HunterActivityTaskOptions, + InfernoOptions, + KourendFavourActivityTaskOptions, + MinigameActivityTaskOptionsWithNoChanges, + MiningActivityTaskOptions, + MonsterActivityTaskOptions, + MotherlodeMiningActivityTaskOptions, + NexTaskOptions, + NightmareActivityTaskOptions, + OfferingActivityTaskOptions, + PickpocketActivityTaskOptions, + PlunderActivityTaskOptions, + RaidsOptions, + RunecraftActivityTaskOptions, + SawmillActivityTaskOptions, + ScatteringActivityTaskOptions, + SepulchreActivityTaskOptions, + ShadesOfMortonOptions, + SmeltingActivityTaskOptions, + SmithingActivityTaskOptions, + SpecificQuestOptions, + TOAOptions, + TheatreOfBloodTaskOptions, + TiaraRunecraftActivityTaskOptions, + WoodcuttingActivityTaskOptions, + ZalcanoActivityTaskOptions +} from '../types/minions'; +import { formatDuration, itemNameFromID, randomVariation, stringMatches } from '../util'; +import { getActivityOfUser } from './minionIsBusy'; + +export function minionStatus(user: MUser) { + const currentTask = getActivityOfUser(user.id); + const name = user.minionName; + if (!currentTask) { + return `${name} is currently doing nothing.`; + } + + const durationRemaining = currentTask.finishDate - Date.now(); + const formattedDuration = `${formatDuration(durationRemaining)} remaining.`; + + switch (currentTask.type) { + case 'MonsterKilling': { + const data = currentTask as MonsterActivityTaskOptions; + const monster = killableMonsters.find(mon => mon.id === data.monsterID); + + return `${name} is currently killing ${data.quantity}x ${monster?.name}. ${formattedDuration}`; + } + + case 'GroupMonsterKilling': { + const data = currentTask as GroupMonsterActivityTaskOptions; + const monster = killableMonsters.find(mon => mon.id === data.monsterID); + + return `${name} is currently killing ${data.quantity}x ${monster?.name} with a party of ${data.users.length + }. ${formattedDuration}`; + } + + case 'ClueCompletion': { + const data = currentTask as ClueActivityTaskOptions; + + const clueTier = ClueTiers.find(tier => tier.id === data.clueID); + + return `${name} is currently completing ${data.quantity}x ${clueTier?.name} clues. ${formattedDuration}`; + } + + case 'Crafting': { + const data = currentTask as CraftingActivityTaskOptions; + const craftable = Crafting.Craftables.find(item => item.id === data.craftableID); + + return `${name} is currently crafting ${data.quantity}x ${craftable?.name}. ${formattedDuration} Your ${Emoji.Crafting + } Crafting level is ${user.skillLevel(SkillsEnum.Crafting)}`; + } + + case 'Agility': { + const data = currentTask as AgilityActivityTaskOptions; + + const course = Agility.Courses.find(course => course.name === data.courseID); + + return `${name} is currently running ${data.quantity}x ${course?.name} laps. ${formattedDuration} Your ${Emoji.Agility + } Agility level is ${user.skillLevel(SkillsEnum.Agility)}`; + } + + case 'Cooking': { + const data = currentTask as CookingActivityTaskOptions; + + const cookable = Cooking.Cookables.find(cookable => cookable.id === data.cookableID); + + return `${name} is currently cooking ${data.quantity}x ${cookable?.name}. ${formattedDuration} Your ${Emoji.Cooking + } Cooking level is ${user.skillLevel(SkillsEnum.Cooking)}`; + } + + case 'Fishing': { + const data = currentTask as FishingActivityTaskOptions; + + const fish = Fishing.Fishes.find(fish => fish.id === data.fishID); + + return `${name} is currently fishing ${fish?.name}. ${formattedDuration} Your ${Emoji.Fishing + } Fishing level is ${user.skillLevel(SkillsEnum.Fishing)}`; + } + + case 'Mining': { + const data = currentTask as MiningActivityTaskOptions; + + const ore = Mining.Ores.find(ore => ore.id === data.oreID); + + return `${name} is currently mining ${ore?.name}. ${data.fakeDurationMax === data.fakeDurationMin + ? formattedDuration + : `approximately ${formatDuration( + randomVariation(reduceNumByPercent(durationRemaining, 25), 20) + )} **to** ${formatDuration( + randomVariation(increaseNumByPercent(durationRemaining, 25), 20) + )} remaining.` + } Your ${Emoji.Mining} Mining level is ${user.skillLevel(SkillsEnum.Mining)}`; + } + + case 'MotherlodeMining': { + const data = currentTask as MotherlodeMiningActivityTaskOptions; + + return `${name} is currently mining at the Motherlode Mine. ${data.fakeDurationMax === data.fakeDurationMin + ? formattedDuration + : `approximately ${formatDuration( + randomVariation(reduceNumByPercent(durationRemaining, 25), 20) + )} **to** ${formatDuration( + randomVariation(increaseNumByPercent(durationRemaining, 25), 20) + )} remaining.` + } Your ${Emoji.Mining} Mining level is ${user.skillLevel(SkillsEnum.Mining)}`; + } + + case 'Smelting': { + const data = currentTask as SmeltingActivityTaskOptions; + + const bar = Smithing.Bars.find(bar => bar.id === data.barID); + + return `${name} is currently smelting ${data.quantity}x ${bar?.name}. ${formattedDuration} Your ${Emoji.Smithing + } Smithing level is ${user.skillLevel(SkillsEnum.Smithing)}`; + } + + case 'Smithing': { + const data = currentTask as SmithingActivityTaskOptions; + + const SmithableItem = Smithing.SmithableItems.find(item => item.id === data.smithedBarID); + + return `${name} is currently smithing ${data.quantity}x ${SmithableItem?.name}. ${formattedDuration} Your ${Emoji.Smithing + } Smithing level is ${user.skillLevel(SkillsEnum.Smithing)}`; + } + + case 'Offering': { + const data = currentTask as OfferingActivityTaskOptions; + + const bones = Prayer.Bones.find(bones => bones.inputId === data.boneID); + + return `${name} is currently offering ${data.quantity}x ${bones?.name}. ${formattedDuration} Your ${Emoji.Prayer + } Prayer level is ${user.skillLevel(SkillsEnum.Prayer)}`; + } + + case 'Burying': { + const data = currentTask as BuryingActivityTaskOptions; + + const bones = Prayer.Bones.find(bones => bones.inputId === data.boneID); + + return `${name} is currently burying ${data.quantity}x ${bones?.name}. ${formattedDuration} Your ${Emoji.Prayer + } Prayer level is ${user.skillLevel(SkillsEnum.Prayer)}`; + } + + case 'Scattering': { + const data = currentTask as ScatteringActivityTaskOptions; + + const ashes = Prayer.Ashes.find(ashes => ashes.inputId === data.ashID); + + return `${name} is currently scattering ${data.quantity}x ${ashes?.name}. ${formattedDuration} Your ${Emoji.Prayer + } Prayer level is ${user.skillLevel(SkillsEnum.Prayer)}`; + } + + case 'Firemaking': { + const data = currentTask as FiremakingActivityTaskOptions; + + const burn = Firemaking.Burnables.find(burn => burn.inputLogs === data.burnableID); + + return `${name} is currently lighting ${data.quantity}x ${burn?.name}. ${formattedDuration} Your ${Emoji.Firemaking + } Firemaking level is ${user.skillLevel(SkillsEnum.Firemaking)}`; + } + + case 'Questing': { + return `${name} is currently Questing. ${formattedDuration} Your current Quest Point count is: ${user.QP}.`; + } + + case 'Woodcutting': { + const data = currentTask as WoodcuttingActivityTaskOptions; + + const log = Woodcutting.Logs.find(log => log.id === data.logID); + + return `${name} is currently chopping ${log?.name}. ${data.fakeDurationMax === data.fakeDurationMin + ? formattedDuration + : `approximately ${formatDuration( + randomVariation(reduceNumByPercent(durationRemaining, 25), 20) + )} **to** ${formatDuration( + randomVariation(increaseNumByPercent(durationRemaining, 25), 20) + )} remaining.` + } Your ${Emoji.Woodcutting} Woodcutting level is ${user.skillLevel(SkillsEnum.Woodcutting)}`; + } + case 'Runecraft': { + const data = currentTask as RunecraftActivityTaskOptions; + + const rune = Runecraft.Runes.find(_rune => _rune.id === data.runeID); + + return `${name} is currently turning ${data.essenceQuantity}x Essence into ${rune?.name + }. ${formattedDuration} Your ${Emoji.Runecraft} Runecraft level is ${user.skillLevel( + SkillsEnum.Runecraft + )}`; + } + + case 'TiaraRunecraft': { + const data = currentTask as TiaraRunecraftActivityTaskOptions; + const tiara = Runecraft.Tiaras.find(_tiara => _tiara.id === data.tiaraID); + + return `${name} is currently crafting ${data.tiaraQuantity} ${tiara?.name}. ${formattedDuration} Your ${Emoji.Runecraft + } Runecraft level is ${user.skillLevel(SkillsEnum.Runecraft)}`; + } + + case 'FightCaves': { + const data = currentTask as FightCavesActivityTaskOptions; + const durationRemaining = data.finishDate - data.duration + data.fakeDuration - Date.now(); + return `${name} is currently attempting the ${Emoji.AnimatedFireCape} **Fight caves** ${Emoji.TzRekJad + }. If they're successful and don't die, the trip should take ${formatDuration(durationRemaining)}.`; + } + case 'TitheFarm': { + return `${name} is currently farming at the **Tithe Farm**. ${formattedDuration}`; + } + + case 'Fletching': { + const data = currentTask as FletchingActivityTaskOptions; + + return `${name} is currently fletching ${data.quantity}x ${data.fletchableName + }. ${formattedDuration} Your ${Emoji.Fletching} Fletching level is ${user.skillLevel( + SkillsEnum.Fletching + )}`; + } + case 'Herblore': { + const data = currentTask as HerbloreActivityTaskOptions; + const mixable = Herblore.Mixables.find(i => i.item.id === data.mixableID); + + return `${name} is currently mixing ${data.quantity}x ${mixable?.item.name}. ${formattedDuration} Your ${Emoji.Herblore + } Herblore level is ${user.skillLevel(SkillsEnum.Herblore)}`; + } + case 'CutLeapingFish': { + const data = currentTask as CutLeapingFishActivityTaskOptions; + const barbarianFish = LeapingFish.find(item => item.item.id === data.id); + + return `${name} is currently cutting ${data.quantity}x ${barbarianFish?.item.name + }. ${formattedDuration} Your ${Emoji.Cooking} Cooking level is ${user.skillLevel(SkillsEnum.Cooking)}`; + } + case 'Wintertodt': { + const data = currentTask as ActivityTaskOptionsWithQuantity; + return `${name} is currently fighting Wintertodt ${data.quantity}x times. ${formattedDuration}`; + } + case 'Tempoross': { + return `${name} is currently fighting Tempoross. ${formattedDuration}`; + } + + case 'Alching': { + const data = currentTask as AlchingActivityTaskOptions; + + return `${name} is currently alching ${data.quantity}x ${itemNameFromID( + data.itemID + )}. ${formattedDuration}`; + } + + case 'Farming': { + const data = currentTask as FarmingActivityTaskOptions; + + const plants = Farming.Plants.find(plants => plants.name === data.plantsName); + + return `${name} is currently farming ${data.quantity}x ${plants?.name}. ${formattedDuration} Your ${Emoji.Farming + } Farming level is ${user.skillLevel(SkillsEnum.Farming)}.`; + } + + case 'Sawmill': { + const data = currentTask as SawmillActivityTaskOptions; + const plank = Planks.find(_plank => _plank.outputItem === data.plankID)!; + return `${name} is currently creating ${data.plankQuantity}x ${itemNameFromID( + plank.outputItem + )}s. ${formattedDuration}`; + } + + case 'Nightmare': { + const data = currentTask as NightmareActivityTaskOptions; + return `${name} is currently killing The Nightmare ${data.method === 'solo' ? 'solo' : 'in a team' + }. ${formattedDuration}`; + } + + case 'AnimatedArmour': { + return `${name} is currently fighting animated armour in the Warriors' Guild. ${formattedDuration}`; + } + + case 'Cyclops': { + return `${name} is currently fighting cyclopes in the Warriors' Guild. ${formattedDuration}`; + } + + case 'CamdozaalFishing': { + return `${name} is currently Fishing in the Ruins of Camdozaal. ${formattedDuration}`; + } + + case 'CamdozaalMining': { + return `${name} is currently Mining in the Ruins of Camdozaal. ${formattedDuration}`; + } + + case 'CamdozaalSmithing': { + return `${name} is currently Smithing in the Ruins of Camdozaal. ${formattedDuration}`; + } + + case 'Sepulchre': { + const data = currentTask as SepulchreActivityTaskOptions; + + return `${name} is currently doing ${data.quantity}x laps of the Hallowed Sepulchre. ${formattedDuration}`; + } + + case 'Plunder': { + const data = currentTask as PlunderActivityTaskOptions; + + return `${name} is currently doing Pyramid Plunder x ${data.quantity}x times. ${formattedDuration}`; + } + + case 'FishingTrawler': { + const data = currentTask as ActivityTaskOptionsWithQuantity; + return `${name} is currently aboard the Fishing Trawler, doing ${data.quantity}x trips. ${formattedDuration}`; + } + + case 'Zalcano': { + const data = currentTask as ZalcanoActivityTaskOptions; + return `${name} is currently killing Zalcano ${data.quantity}x times. ${formattedDuration}`; + } + + case 'Pickpocket': { + const data = currentTask as PickpocketActivityTaskOptions; + const obj = stealables.find(_obj => _obj.id === data.monsterID); + return `${name} is currently ${obj?.type === 'pickpockable' ? 'pickpocketing' : 'stealing'} from ${obj?.name + } ${data.quantity}x times. ${formattedDuration}`; + } + + case 'BarbarianAssault': { + const data = currentTask as MinigameActivityTaskOptionsWithNoChanges; + return `${name} is currently doing ${data.quantity} waves of Barbarian Assault. ${formattedDuration}`; + } + + case 'AgilityArena': { + return `${name} is currently doing the Brimhaven Agility Arena. ${formattedDuration}`; + } + + case 'ChampionsChallenge': { + return `${name} is currently doing the **Champion's Challenge**. ${formattedDuration}`; + } + + case 'Hunter': { + const data = currentTask as HunterActivityTaskOptions; + + const creature = Hunter.Creatures.find(creature => + creature.aliases.some( + alias => + stringMatches(alias, data.creatureName) || stringMatches(alias.split(' ')[0], data.creatureName) + ) + ); + const crystalImpling = creature?.name === 'Crystal impling'; + return `${name} is currently hunting ${crystalImpling ? creature?.name : `${data.quantity}x ${creature?.name}` + }. ${formattedDuration}`; + } + + case 'Birdhouse': { + return `${name} is currently doing a bird house run. ${formattedDuration}`; + } + + case 'AerialFishing': { + return `${name} is currently aerial fishing. ${formattedDuration}`; + } + + case 'DriftNet': { + return `${name} is currently drift net fishing. ${formattedDuration}`; + } + + case 'Construction': { + const data = currentTask as ConstructionActivityTaskOptions; + const pohObject = Constructables.find(i => i.id === data.objectID); + if (!pohObject) throw new Error(`No POH object found with ID ${data.objectID}.`); + return `${name} is currently building ${data.quantity}x ${pohObject.name}. ${formattedDuration}`; + } + + case 'Butler': { + const data = currentTask as ButlerActivityTaskOptions; + const plank = Planks.find(_plank => _plank.outputItem === data.plankID)!; + return `${name} is currently creating ${data.plankQuantity}x ${itemNameFromID( + plank.outputItem + )}s. ${formattedDuration}`; + } + + case 'MahoganyHomes': { + return `${name} is currently doing Mahogany Homes. ${formattedDuration}`; + } + + case 'Enchanting': { + const data = currentTask as EnchantingActivityTaskOptions; + const enchantable = Enchantables.find(i => i.id === data.itemID); + return `${name} is currently enchanting ${data.quantity}x ${enchantable?.name}. ${formattedDuration}`; + } + + case 'Casting': { + const data = currentTask as CastingActivityTaskOptions; + const spell = Castables.find(i => i.id === data.spellID); + return `${name} is currently casting ${data.quantity}x ${spell?.name}. ${formattedDuration}`; + } + + case 'GloryCharging': { + const data = currentTask as ActivityTaskOptionsWithQuantity; + return `${name} is currently charging ${data.quantity}x inventories of glories at the Fountain of Rune. ${formattedDuration}`; + } + + case 'WealthCharging': { + const data = currentTask as ActivityTaskOptionsWithQuantity; + return `${name} is currently charging ${data.quantity}x inventories of rings of wealth at the Fountain of Rune. ${formattedDuration}`; + } + + case 'GnomeRestaurant': { + return `${name} is currently doing Gnome Restaurant deliveries. ${formattedDuration}`; + } + + case 'SoulWars': { + const data = currentTask as MinigameActivityTaskOptionsWithNoChanges; + return `${name} is currently doing ${data.quantity}x games of Soul Wars. ${formattedDuration}`; + } + + case 'RoguesDenMaze': { + return `${name} is currently attempting the Rogues' Den maze. ${formattedDuration}`; + } + + case 'Gauntlet': { + const data = currentTask as GauntletOptions; + return `${name} is currently doing ${data.quantity}x ${data.corrupted ? 'Corrupted' : 'Normal' + } Gauntlet. ${formattedDuration}`; + } + + case 'CastleWars': { + const data = currentTask as MinigameActivityTaskOptionsWithNoChanges; + return `${name} is currently doing ${data.quantity}x Castle Wars games. ${formattedDuration}`; + } + + case 'MageArena': { + return `${name} is currently doing the Mage Arena. ${formattedDuration}`; + } + + case 'Raids': { + const data = currentTask as RaidsOptions; + return `${name} is currently doing the Chambers of Xeric${data.challengeMode ? ' in Challenge Mode' : '' + }, ${data.users.length === 1 ? 'as a solo.' : `with a team of ${data.users.length} minions.` + } ${formattedDuration}`; + } + + case 'Collecting': { + const data = currentTask as CollectingOptions; + const collectable = collectables.find(c => c.item.id === data.collectableID)!; + return `${name} is currently collecting ${data.quantity * collectable.quantity}x ${collectable.item.name + }. ${formattedDuration}`; + } + + case 'MageTrainingArena': { + return `${name} is currently training at the Mage Training Arena. ${formattedDuration}`; + } + + case 'MageArena2': { + return `${name} is currently attempting the Mage Arena II. ${formattedDuration}`; + } + + case 'BigChompyBirdHunting': { + return `${name} is currently hunting Chompy Birds! ${formattedDuration}`; + } + + case 'DarkAltar': { + const data = currentTask as DarkAltarOptions; + return `${name} is currently runecrafting ${toTitleCase( + data.rune + )} runes at the Dark Altar. ${formattedDuration}`; + } + case 'Trekking': { + return `${name} is currently Temple Trekking. ${formattedDuration}`; + } + case 'PestControl': { + const data = currentTask as MinigameActivityTaskOptionsWithNoChanges; + return `${name} is currently doing ${data.quantity} games of Pest Control. ${formattedDuration}`; + } + case 'VolcanicMine': { + const data = currentTask as ActivityTaskOptionsWithQuantity; + return `${name} is currently doing ${data.quantity} games of Volcanic Mine. ${formattedDuration}`; + } + case 'TearsOfGuthix': { + return `${name} is currently doing Tears Of Guthix. ${formattedDuration}`; + } + case 'KourendFavour': { + const data = currentTask as KourendFavourActivityTaskOptions; + return `${name} is currently doing ${data.favour} Favour tasks. ${formattedDuration}`; + } + case 'Inferno': { + const data = currentTask as InfernoOptions; + const durationRemaining = data.finishDate - data.duration + data.fakeDuration - Date.now(); + return `${name} is currently attempting the Inferno, if they're successful and don't die, the trip should take ${formatDuration( + durationRemaining + )}.`; + } + case 'TheatreOfBlood': { + const data = currentTask as TheatreOfBloodTaskOptions; + const durationRemaining = data.finishDate - data.duration + data.fakeDuration - Date.now(); + + return `${name} is currently attempting the Theatre of Blood, if your team is successful and doesn't die, the trip should take ${formatDuration( + durationRemaining + )}.`; + } + case 'LastManStanding': { + const data = currentTask as MinigameActivityTaskOptionsWithNoChanges; + + return `${name} is currently doing ${data.quantity + } Last Man Standing matches, the trip should take ${formatDuration(durationRemaining)}.`; + } + case 'BirthdayEvent': { + return `${name} is currently doing the Birthday Event! The trip should take ${formatDuration( + durationRemaining + )}.`; + } + case 'TokkulShop': { + return `${name} is currently shopping at Tzhaar stores. The trip should take ${formatDuration( + durationRemaining + )}.`; + } + case 'Nex': { + const data = currentTask as NexTaskOptions; + const durationRemaining = data.finishDate - data.duration + data.fakeDuration - Date.now(); + return `${name} is currently killing Nex ${data.quantity} times with a team of ${data.users.length + }. The trip should take ${formatDuration(durationRemaining)}.`; + } + case 'TroubleBrewing': { + const data = currentTask as MinigameActivityTaskOptionsWithNoChanges; + return `${name} is currently doing ${data.quantity + }x games of Trouble Brewing. The trip should take ${formatDuration(durationRemaining)}.`; + } + case 'PuroPuro': { + return `${name} is currently hunting in Puro-Puro. The trip should take ${formatDuration( + durationRemaining + )}.`; + } + case 'ShootingStars': { + return `${name} is currently mining a Crashed Star. The trip should take ${formatDuration( + durationRemaining + )}.`; + } + case 'GiantsFoundry': { + const data = currentTask as MinigameActivityTaskOptionsWithNoChanges; + return `${name} is currently creating ${data.quantity + }x giant weapons for Kovac in the Giants' Foundry minigame. The trip should take ${formatDuration( + durationRemaining + )}.`; + } + case 'GuardiansOfTheRift': { + return `${name} is currently helping the Great Guardian to close the rift. The trip should take ${formatDuration( + durationRemaining + )}.`; + } + case 'NightmareZone': { + return `${name} is currently killing Monsters in the Nightmare Zone. The trip should take ${formatDuration( + durationRemaining + )}.`; + } + case 'ShadesOfMorton': { + const data = currentTask as ShadesOfMortonOptions; + const log = shadesLogs.find(i => i.normalLog.id === data.logID)!; + const shade = shades.find(i => i.shadeName === data.shadeID)!; + return `${name} is currently doing ${data.quantity} trips of Shades of Mort'ton, cremating ${shade.shadeName + } remains with ${log.oiledLog.name}! The trip should take ${formatDuration(durationRemaining)}.`; + } + case 'TombsOfAmascut': { + const data = currentTask as TOAOptions; + const durationRemaining = data.finishDate - data.duration + data.fakeDuration - Date.now(); + + return `${name} is currently attempting the Tombs of Amascut, if your team is successful and doesn't die, the trip should take ${formatDuration( + durationRemaining + )}.`; + } + case 'UnderwaterAgilityThieving': { + return `${name} is currently doing Underwater Agility and Thieving. ${formattedDuration}`; + } + case 'StrongholdOfSecurity': { + return `${name} is currently doing the Stronghold of Security! The trip should take ${formatDuration( + durationRemaining + )}.`; + } + case 'CombatRing': { + return `${name} is currently fighting in the Combat Ring! The trip should take ${formatDuration( + durationRemaining + )}.`; + } + case 'SpecificQuest': { + const data = currentTask as SpecificQuestOptions; + return `${name} is currently doing the ${quests.find(i => i.id === data.questID)?.name + }! The trip should take ${formatDuration(durationRemaining)}.`; + } + case 'Colosseum': { + const data = currentTask as ColoTaskOptions; + const durationRemaining = data.finishDate - data.duration + data.fakeDuration - Date.now(); + + return `${name} is currently attempting the Colosseum, if they are successful, the trip should take ${formatDuration( + durationRemaining + )}.`; + } + case 'HalloweenEvent': { + return `${name} is doing the Halloween event! The trip should take ${formatDuration(durationRemaining)}.`; + } + case 'Easter': + case 'BlastFurnace': { + throw new Error('Removed'); + } + } +} diff --git a/minions.ts b/minions.ts new file mode 100644 index 0000000000..edeb891466 --- /dev/null +++ b/minions.ts @@ -0,0 +1,622 @@ +import type { CropUpgradeType } from '@prisma/client'; + +import type { ItemBank } from '.'; +import type { NMZStrategy, TwitcherGloves, UnderwaterAgilityThievingTrainingSkill } from '../constants'; +import type { IPatchData } from '../minions/farming/types'; +import type { MinigameName } from '../settings/minigames'; +import type { RaidLevel } from '../simulation/toa'; +import type { Peak } from '../tickers'; +import type { BirdhouseData } from './../skilling/skills/hunter/defaultBirdHouseTrap'; + +export interface ActivityTaskOptions { + userID: string; + duration: number; + id: number; + finishDate: number; + channelID: string; +} + +export interface ActivityTaskOptionsWithNoChanges extends ActivityTaskOptions { + type: + | 'Questing' + | 'Wintertodt' + | 'Cyclops' + | 'GloryCharging' + | 'WealthCharging' + | 'BarbarianAssault' + | 'AgilityArena' + | 'ChampionsChallenge' + | 'AerialFishing' + | 'DriftNet' + | 'SoulWars' + | 'RoguesDenMaze' + | 'CastleWars' + | 'MageArena' + | 'MageTrainingArena' + | 'BlastFurnace' + | 'MageArena2' + | 'BigChompyBirdHunting' + | 'PestControl' + | 'VolcanicMine' + | 'TearsOfGuthix' + | 'LastManStanding' + | 'BirthdayEvent' + | 'TroubleBrewing' + | 'Easter' + | 'ShootingStars' + | 'HalloweenEvent' + | 'StrongholdOfSecurity' + | 'CombatRing'; +} + +export interface ActivityTaskOptionsWithQuantity extends ActivityTaskOptions { + type: + | 'VolcanicMine' + | 'Cyclops' + | 'ShootingStars' + | 'DriftNet' + | 'WealthCharging' + | 'GloryCharging' + | 'AerialFishing' + | 'FishingTrawler' + | 'CamdozaalFishing' + | 'CamdozaalMining' + | 'CamdozaalSmithing'; + quantity: number; + // iQty is 'input quantity.' This is the number specified at command time, so we can accurately repeat such trips. + iQty?: number; +} + +export interface ShootingStarsOptions extends ActivityTaskOptions { + type: 'ShootingStars'; + size: number; + usersWith: number; + totalXp: number; + lootItems: ItemBank; +} +interface ActivityTaskOptionsWithUsers extends ActivityTaskOptions { + users: string[]; +} + +export interface RunecraftActivityTaskOptions extends ActivityTaskOptions { + type: 'Runecraft'; + runeID: number; + essenceQuantity: number; + imbueCasts: number; + useStaminas?: boolean; + daeyaltEssence?: boolean; +} + +export interface TiaraRunecraftActivityTaskOptions extends ActivityTaskOptions { + type: 'TiaraRunecraft'; + tiaraID: number; + tiaraQuantity: number; +} + +export interface DarkAltarOptions extends ActivityTaskOptions { + type: 'DarkAltar'; + quantity: number; + hasElite: boolean; + rune: 'blood' | 'soul'; +} + +export interface AgilityActivityTaskOptions extends ActivityTaskOptions { + type: 'Agility'; + courseID: string; + quantity: number; + alch: { + itemID: number; + quantity: number; + } | null; +} + +export interface CookingActivityTaskOptions extends ActivityTaskOptions { + type: 'Cooking'; + cookableID: number; + quantity: number; +} + +export interface ConstructionActivityTaskOptions extends ActivityTaskOptions { + type: 'Construction'; + objectID: number; + quantity: number; +} + +export interface MonsterActivityTaskOptions extends ActivityTaskOptions { + type: 'MonsterKilling'; + monsterID: number; + quantity: number; + iQty?: number; + usingCannon?: boolean; + cannonMulti?: boolean; + chinning?: boolean; + burstOrBarrage?: number; + died?: boolean; + pkEncounters?: number; + hasWildySupplies?: boolean; + isInWilderness?: boolean; +} + +export interface ClueActivityTaskOptions extends ActivityTaskOptions { + type: 'ClueCompletion'; + + clueID: number; + quantity: number; + implingID?: number; + implingClues?: number; +} + +export interface FishingActivityTaskOptions extends ActivityTaskOptions { + type: 'Fishing'; + fishID: number; + quantity?: number; + Qty1: number; + Qty2?: number; + Qty3?: number; + loot1?: number; + loot2?: number; + loot3?: number; + flakesToRemove?: number; + powerfish?: boolean; + spirit_flakes?: boolean; +} + + +export interface MiningActivityTaskOptions extends ActivityTaskOptions { + type: 'Mining'; + fakeDurationMax: number; + fakeDurationMin: number; + oreID: number; + quantity: number; + powermine: boolean; + iQty?: number; +} + +export interface MotherlodeMiningActivityTaskOptions extends ActivityTaskOptions { + type: 'MotherlodeMining'; + fakeDurationMax: number; + fakeDurationMin: number; + quantity: number; + iQty?: number; +} + +export interface SmeltingActivityTaskOptions extends ActivityTaskOptions { + type: 'Smelting'; + barID: number; + quantity: number; + blastf: boolean; +} + +export interface SmithingActivityTaskOptions extends ActivityTaskOptions { + type: 'Smithing'; + smithedBarID: number; + quantity: number; +} + +export interface FiremakingActivityTaskOptions extends ActivityTaskOptions { + type: 'Firemaking'; + burnableID: number; + quantity: number; +} + +export interface WoodcuttingActivityTaskOptions extends ActivityTaskOptions { + type: 'Woodcutting'; + fakeDurationMax: number; + fakeDurationMin: number; + powerchopping: boolean; + forestry?: boolean; + twitchers?: TwitcherGloves; + logID: number; + quantity: number; + iQty?: number; +} + +export interface CraftingActivityTaskOptions extends ActivityTaskOptions { + type: 'Crafting'; + craftableID: number; + quantity: number; +} + +export interface FletchingActivityTaskOptions extends ActivityTaskOptions { + type: 'Fletching'; + fletchableName: string; + quantity: number; +} + +export interface EnchantingActivityTaskOptions extends ActivityTaskOptions { + type: 'Enchanting'; + itemID: number; + quantity: number; +} + +export interface CastingActivityTaskOptions extends ActivityTaskOptions { + type: 'Casting'; + spellID: number; + quantity: number; +} +export interface PickpocketActivityTaskOptions extends ActivityTaskOptions { + type: 'Pickpocket'; + monsterID: number; + quantity: number; + xpReceived: number; + successfulQuantity: number; + damageTaken: number; +} + +export interface BuryingActivityTaskOptions extends ActivityTaskOptions { + type: 'Burying'; + boneID: number; + quantity: number; +} + +export interface ScatteringActivityTaskOptions extends ActivityTaskOptions { + type: 'Scattering'; + ashID: number; + quantity: number; +} + +export interface OfferingActivityTaskOptions extends ActivityTaskOptions { + type: 'Offering'; + boneID: number; + quantity: number; +} + +export interface AnimatedArmourActivityTaskOptions extends ActivityTaskOptions { + type: 'AnimatedArmour'; + armourID: string; + quantity: number; +} + +export interface HerbloreActivityTaskOptions extends ActivityTaskOptions { + type: 'Herblore'; + mixableID: number; + quantity: number; + zahur: boolean; + wesley: boolean; +} + +export interface CutLeapingFishActivityTaskOptions extends ActivityTaskOptions { + type: 'CutLeapingFish'; + fishID: number; + quantity: number; +} + +export interface HunterActivityTaskOptions extends ActivityTaskOptions { + type: 'Hunter'; + creatureName: string; + quantity: number; + usingHuntPotion: boolean; + wildyPeak: Peak | null; + usingStaminaPotion: boolean; +} + +export interface AlchingActivityTaskOptions extends ActivityTaskOptions { + type: 'Alching'; + itemID: number; + quantity: number; + alchValue: number; +} + +export interface FightCavesActivityTaskOptions extends ActivityTaskOptions { + type: 'FightCaves'; + jadDeathChance: number; + preJadDeathChance: number; + preJadDeathTime: number | null; + fakeDuration: number; + quantity: number; +} +export interface InfernoOptions extends ActivityTaskOptions { + type: 'Inferno'; + zukDeathChance: number; + preZukDeathChance: number; + deathTime: number | null; + fakeDuration: number; + diedZuk: boolean; + diedPreZuk: boolean; + cost: ItemBank; +} + +export interface FarmingActivityTaskOptions extends ActivityTaskOptions { + type: 'Farming'; + pid?: number; + plantsName: string | null; + quantity: number; + upgradeType: CropUpgradeType | null; + payment?: boolean; + patchType: IPatchData; + planting: boolean; + currentDate: number; + autoFarmed: boolean; +} + +export interface BirdhouseActivityTaskOptions extends ActivityTaskOptions { + type: 'Birdhouse'; + birdhouseName: string | null; + placing: boolean; + gotCraft: boolean; + birdhouseData: BirdhouseData; + currentDate: number; +} + +interface MinigameActivityTaskOptions extends ActivityTaskOptions { + minigameID: MinigameName; + quantity: number; +} + +export interface MinigameActivityTaskOptionsWithNoChanges extends MinigameActivityTaskOptions { + type: + | 'Wintertodt' + | 'TroubleBrewing' + | 'TearsOfGuthix' + | 'SoulWars' + | 'RoguesDenMaze' + | 'MageTrainingArena' + | 'LastManStanding' + | 'BigChompyBirdHunting' + | 'FishingTrawler' + | 'PestControl' + | 'BarbarianAssault' + | 'ChampionsChallenge' + | 'CastleWars' + | 'AgilityArena' + | 'GiantsFoundry'; +} + +export interface MahoganyHomesActivityTaskOptions extends MinigameActivityTaskOptions { + type: 'MahoganyHomes'; + xp: number; + quantity: number; + points: number; + tier: number; +} + +export interface NightmareActivityTaskOptions extends ActivityTaskOptions { + type: 'Nightmare'; + method: 'solo' | 'mass'; + quantity: number; + isPhosani?: boolean; +} + +export interface TemporossActivityTaskOptions extends MinigameActivityTaskOptions { + type: 'Tempoross'; + quantity: number; + rewardBoost: number; +} + +export interface TitheFarmActivityTaskOptions extends MinigameActivityTaskOptions { + type: 'TitheFarm'; +} + +export interface SepulchreActivityTaskOptions extends MinigameActivityTaskOptions { + type: 'Sepulchre'; + floors: number[]; +} + +export interface PlunderActivityTaskOptions extends MinigameActivityTaskOptions { + type: 'Plunder'; + rooms: number[]; +} + +export interface ZalcanoActivityTaskOptions extends ActivityTaskOptions { + type: 'Zalcano'; + isMVP: boolean; + performance: number; + quantity: number; +} + +export interface TempleTrekkingActivityTaskOptions extends MinigameActivityTaskOptions { + type: 'Trekking'; + difficulty: string; +} + +export interface SawmillActivityTaskOptions extends ActivityTaskOptions { + type: 'Sawmill'; + plankID: number; + plankQuantity: number; +} + +export interface ButlerActivityTaskOptions extends ActivityTaskOptions { + type: 'Butler'; + plankID: number; + plankQuantity: number; +} + +export interface GnomeRestaurantActivityTaskOptions extends MinigameActivityTaskOptions { + type: 'GnomeRestaurant'; + gloriesRemoved: number; +} + +export interface GauntletOptions extends ActivityTaskOptions { + type: 'Gauntlet'; + corrupted: boolean; + quantity: number; +} + +export interface GroupMonsterActivityTaskOptions extends Omit { + type: 'GroupMonsterKilling'; + leader: string; + users: string[]; +} + +export interface RaidsOptions extends ActivityTaskOptionsWithUsers { + type: 'Raids'; + leader: string; + users: string[]; + challengeMode: boolean; + quantity?: number; +} + +export interface TheatreOfBloodTaskOptions extends ActivityTaskOptionsWithUsers { + type: 'TheatreOfBlood'; + leader: string; + users: string[]; + hardMode: boolean; + fakeDuration: number; + wipedRooms: (null | number)[]; + deaths: number[][][]; + quantity: number; + solo?: boolean; +} + +export interface ColoTaskOptions extends ActivityTaskOptions { + type: 'Colosseum'; + fakeDuration: number; + diedAt?: number; + loot?: ItemBank; + maxGlory: number; +} + +type UserID = string; +type Points = number; +type RoomIDsDiedAt = number[]; + +type TOAUser = [UserID, Points[], RoomIDsDiedAt[]]; +export interface TOAOptions extends ActivityTaskOptionsWithUsers { + type: 'TombsOfAmascut'; + leader: string; + detailedUsers: TOAUser[] | [UserID, Points, RoomIDsDiedAt][][]; + raidLevel: RaidLevel; + fakeDuration: number; + wipedRoom: null | number | (number | null)[]; + quantity: number; +} + +export interface NexTaskOptions extends ActivityTaskOptionsWithUsers { + type: 'Nex'; + quantity: number; + leader: string; + userDetails: [string, number, number[]][]; + fakeDuration: number; + wipedKill: number | null; +} + +export interface CollectingOptions extends ActivityTaskOptions { + type: 'Collecting'; + collectableID: number; + quantity: number; + noStaminas?: boolean; +} + +export interface KourendFavourActivityTaskOptions extends ActivityTaskOptions { + type: 'KourendFavour'; + favour: string; + quantity: number; +} + +export interface TokkulShopOptions extends ActivityTaskOptions { + type: 'TokkulShop'; + itemID: number; + quantity: number; +} + +export interface UnderwaterAgilityThievingTaskOptions extends ActivityTaskOptions { + type: 'UnderwaterAgilityThieving'; + trainingSkill: UnderwaterAgilityThievingTrainingSkill; + quantity: number; + noStams: boolean; +} + +export interface PuroPuroActivityTaskOptions extends MinigameActivityTaskOptions { + type: 'PuroPuro'; + quantity: number; + darkLure: boolean; + implingTier: number | null; +} + +export interface GiantsFoundryActivityTaskOptions extends MinigameActivityTaskOptions { + type: 'GiantsFoundry'; + alloyID: number; + quantity: number; + metalScore: number; +} + +export interface GuardiansOfTheRiftActivityTaskOptions extends MinigameActivityTaskOptions { + type: 'GuardiansOfTheRift'; + minedFragments: number; + barrierAndGuardian: number; + rolls: number; + combinationRunes: boolean; +} + +export interface NightmareZoneActivityTaskOptions extends MinigameActivityTaskOptions { + type: 'NightmareZone'; + strategy: NMZStrategy; + quantity: number; +} + +export interface ShadesOfMortonOptions extends MinigameActivityTaskOptions { + type: 'ShadesOfMorton'; + shadeID: string; + logID: number; +} +export interface SpecificQuestOptions extends ActivityTaskOptions { + type: 'SpecificQuest'; + questID: number; +} + +export type ActivityTaskData = + | MonsterActivityTaskOptions + | WoodcuttingActivityTaskOptions + | CollectingOptions + | RaidsOptions + | GauntletOptions + | CastingActivityTaskOptions + | EnchantingActivityTaskOptions + | ConstructionActivityTaskOptions + | HunterActivityTaskOptions + | ZalcanoActivityTaskOptions + | SawmillActivityTaskOptions + | ButlerActivityTaskOptions + | FarmingActivityTaskOptions + | HerbloreActivityTaskOptions + | FletchingActivityTaskOptions + | RunecraftActivityTaskOptions + | TempleTrekkingActivityTaskOptions + | TemporossActivityTaskOptions + | PuroPuroActivityTaskOptions + | KourendFavourActivityTaskOptions + | AgilityActivityTaskOptions + | InfernoOptions + | TOAOptions + | NexTaskOptions + | ZalcanoActivityTaskOptions + | TheatreOfBloodTaskOptions + | GuardiansOfTheRiftActivityTaskOptions + | GiantsFoundryActivityTaskOptions + | NightmareZoneActivityTaskOptions + | ShadesOfMortonOptions + | UnderwaterAgilityThievingTaskOptions + | PickpocketActivityTaskOptions + | BuryingActivityTaskOptions + | ScatteringActivityTaskOptions + | OfferingActivityTaskOptions + | AnimatedArmourActivityTaskOptions + | CookingActivityTaskOptions + | CraftingActivityTaskOptions + | FiremakingActivityTaskOptions + | FishingActivityTaskOptions + | MiningActivityTaskOptions + | MotherlodeMiningActivityTaskOptions + | PlunderActivityTaskOptions + | SmithingActivityTaskOptions + | SmeltingActivityTaskOptions + | TiaraRunecraftActivityTaskOptions + | ClueActivityTaskOptions + | AlchingActivityTaskOptions + | DarkAltarOptions + | GroupMonsterActivityTaskOptions + | MahoganyHomesActivityTaskOptions + | NightmareActivityTaskOptions + | TitheFarmActivityTaskOptions + | SepulchreActivityTaskOptions + | GnomeRestaurantActivityTaskOptions + | SpecificQuestOptions + | ActivityTaskOptionsWithNoChanges + | TokkulShopOptions + | BirdhouseActivityTaskOptions + | FightCavesActivityTaskOptions + | ActivityTaskOptionsWithQuantity + | MinigameActivityTaskOptionsWithNoChanges + | CutLeapingFishActivityTaskOptions + | ColoTaskOptions; + diff --git a/repeatStoredTrip.ts b/repeatStoredTrip.ts new file mode 100644 index 0000000000..329a168b0a --- /dev/null +++ b/repeatStoredTrip.ts @@ -0,0 +1,716 @@ +import type { Activity, Prisma } from '@prisma/client'; +import { activity_type_enum } from '@prisma/client'; +import type { ButtonInteraction } from 'discord.js'; +import { ButtonBuilder, ButtonStyle } from 'discord.js'; +import { Time } from 'e'; + +import { autocompleteMonsters } from '../../mahoji/commands/k'; +import type { PvMMethod } from '../constants'; +import { SlayerActivityConstants } from '../minions/data/combatConstants'; +import { darkAltarRunes } from '../minions/functions/darkAltarCommand'; +import { convertStoredActivityToFlatActivity } from '../settings/prisma'; +import { runCommand } from '../settings/settings'; +import type { + ActivityTaskOptionsWithQuantity, + AgilityActivityTaskOptions, + AlchingActivityTaskOptions, + AnimatedArmourActivityTaskOptions, + BuryingActivityTaskOptions, + ButlerActivityTaskOptions, + CastingActivityTaskOptions, + ClueActivityTaskOptions, + CollectingOptions, + ConstructionActivityTaskOptions, + CookingActivityTaskOptions, + CraftingActivityTaskOptions, + CutLeapingFishActivityTaskOptions, + DarkAltarOptions, + EnchantingActivityTaskOptions, + FarmingActivityTaskOptions, + FiremakingActivityTaskOptions, + FishingActivityTaskOptions, + FletchingActivityTaskOptions, + GauntletOptions, + GiantsFoundryActivityTaskOptions, + GroupMonsterActivityTaskOptions, + GuardiansOfTheRiftActivityTaskOptions, + HerbloreActivityTaskOptions, + HunterActivityTaskOptions, + MahoganyHomesActivityTaskOptions, + MiningActivityTaskOptions, + MonsterActivityTaskOptions, + MotherlodeMiningActivityTaskOptions, + NexTaskOptions, + NightmareActivityTaskOptions, + OfferingActivityTaskOptions, + PickpocketActivityTaskOptions, + PuroPuroActivityTaskOptions, + RaidsOptions, + RunecraftActivityTaskOptions, + SawmillActivityTaskOptions, + ScatteringActivityTaskOptions, + ShadesOfMortonOptions, + SmeltingActivityTaskOptions, + SmithingActivityTaskOptions, + TOAOptions, + TempleTrekkingActivityTaskOptions, + TheatreOfBloodTaskOptions, + TiaraRunecraftActivityTaskOptions, + WoodcuttingActivityTaskOptions, + ZalcanoActivityTaskOptions +} from '../types/minions'; +import { itemNameFromID } from '../util'; +import { giantsFoundryAlloys } from './../../mahoji/lib/abstracted_commands/giantsFoundryCommand'; +import type { NightmareZoneActivityTaskOptions, UnderwaterAgilityThievingTaskOptions } from './../types/minions'; +import getOSItem from './getOSItem'; +import { deferInteraction } from './interactionReply'; + +const taskCanBeRepeated = (activity: Activity) => { + if (activity.type === activity_type_enum.ClueCompletion) { + const realActivity = convertStoredActivityToFlatActivity(activity) as ClueActivityTaskOptions; + return realActivity.implingID !== undefined; + } + return !( + [ + activity_type_enum.TearsOfGuthix, + activity_type_enum.ShootingStars, + activity_type_enum.BirthdayEvent, + activity_type_enum.BlastFurnace, + activity_type_enum.Easter, + activity_type_enum.TokkulShop, + activity_type_enum.Birdhouse, + activity_type_enum.StrongholdOfSecurity, + activity_type_enum.CombatRing + ] as activity_type_enum[] + ).includes(activity.type); +}; + +const tripHandlers = { + [activity_type_enum.ClueCompletion]: { + commandName: 'clue', + args: (data: ClueActivityTaskOptions) => ({ tier: data.clueID, implings: getOSItem(data.implingID!).name }) + }, + [activity_type_enum.SpecificQuest]: { + commandName: 'm', + args: () => ({}) + }, + [activity_type_enum.HalloweenEvent]: { + commandName: 'm', + args: () => ({}) + }, + [activity_type_enum.Birdhouse]: { + commandName: 'm', + args: () => ({}) + }, + [activity_type_enum.StrongholdOfSecurity]: { + commandName: 'm', + args: () => ({}) + }, + [activity_type_enum.CombatRing]: { + commandName: 'm', + args: () => ({}) + }, + [activity_type_enum.TearsOfGuthix]: { + commandName: 'm', + args: () => ({}) + }, + [activity_type_enum.TokkulShop]: { + commandName: 'm', + args: () => ({}) + }, + [activity_type_enum.ShootingStars]: { + commandName: 'm', + args: () => ({}) + }, + [activity_type_enum.BirthdayEvent]: { + commandName: 'm', + args: () => ({}) + }, + [activity_type_enum.BlastFurnace]: { + commandName: 'm', + args: () => ({}) + }, + [activity_type_enum.Easter]: { + commandName: 'm', + args: () => ({}) + }, + [activity_type_enum.Revenants]: { + commandName: 'm', + args: () => ({}) + }, + [activity_type_enum.KourendFavour]: { + commandName: 'm', + args: () => ({}) + }, + [activity_type_enum.AerialFishing]: { + commandName: 'activities', + args: () => ({ aerial_fishing: {} }) + }, + [activity_type_enum.Agility]: { + commandName: 'laps', + args: (data: AgilityActivityTaskOptions) => ({ + name: data.courseID, + quantity: data.quantity, + alch: Boolean(data.alch) + }) + }, + [activity_type_enum.AgilityArena]: { + commandName: 'minigames', + args: () => ({ agility_arena: { start: {} } }) + }, + [activity_type_enum.Alching]: { + commandName: 'activities', + args: (data: AlchingActivityTaskOptions) => ({ + alch: { quantity: data.quantity, item: itemNameFromID(data.itemID) } + }) + }, + [activity_type_enum.AnimatedArmour]: { + commandName: 'activities', + args: (data: AnimatedArmourActivityTaskOptions) => ({ + warriors_guild: { action: 'tokens', quantity: data.quantity } + }) + }, + [activity_type_enum.CamdozaalMining]: { + commandName: 'activities', + args: (data: ActivityTaskOptionsWithQuantity) => ({ + camdozaal: { action: 'mining', quantity: data.iQty } + }) + }, + [activity_type_enum.CamdozaalSmithing]: { + commandName: 'activities', + args: (data: ActivityTaskOptionsWithQuantity) => ({ + camdozaal: { action: 'smithing', quantity: data.quantity } + }) + }, + [activity_type_enum.CamdozaalFishing]: { + commandName: 'activities', + args: (data: ActivityTaskOptionsWithQuantity) => ({ + camdozaal: { action: 'fishing', quantity: data.iQty } + }) + }, + [activity_type_enum.BarbarianAssault]: { + commandName: 'minigames', + args: () => ({ barb_assault: { start: {} } }) + }, + [activity_type_enum.BigChompyBirdHunting]: { + commandName: 'activities', + args: () => ({ chompy_hunt: { action: 'start' } }) + }, + [activity_type_enum.Smelting]: { + commandName: 'smelt', + args: (data: SmeltingActivityTaskOptions) => ({ + name: itemNameFromID(data.barID), + quantity: data.quantity, + blast_furnace: data.blastf + }) + }, + [activity_type_enum.Burying]: { + commandName: 'activities', + args: (data: BuryingActivityTaskOptions) => ({ + bury: { quantity: data.quantity, name: itemNameFromID(data.boneID) } + }) + }, + [activity_type_enum.Scattering]: { + commandName: 'activities', + args: (data: ScatteringActivityTaskOptions) => ({ + scatter: { quantity: data.quantity, name: itemNameFromID(data.ashID) } + }) + }, + [activity_type_enum.Casting]: { + commandName: 'activities', + args: (data: CastingActivityTaskOptions) => ({ cast: { spell: data.spellID, quantity: data.quantity } }) + }, + [activity_type_enum.CastleWars]: { + commandName: 'minigames', + args: () => ({ castle_wars: { start: {} } }) + }, + [activity_type_enum.ChampionsChallenge]: { + commandName: 'activities', + args: () => ({ champions_challenge: {} }) + }, + [activity_type_enum.Collecting]: { + commandName: 'activities', + args: (data: CollectingOptions) => ({ + collect: { item: itemNameFromID(data.collectableID), no_stams: data.noStaminas, quantity: data.quantity } + }) + }, + [activity_type_enum.Construction]: { + commandName: 'build', + args: (data: ConstructionActivityTaskOptions) => ({ name: data.objectID, quantity: data.quantity }) + }, + [activity_type_enum.Cooking]: { + commandName: 'cook', + args: (data: CookingActivityTaskOptions) => ({ + name: itemNameFromID(data.cookableID), + quantity: data.quantity + }) + }, + [activity_type_enum.Crafting]: { + commandName: 'craft', + args: (data: CraftingActivityTaskOptions) => ({ + name: itemNameFromID(data.craftableID), + quantity: data.quantity + }) + }, + [activity_type_enum.Cyclops]: { + commandName: 'activities', + args: (data: ActivityTaskOptionsWithQuantity) => ({ + warriors_guild: { action: 'cyclops', quantity: data.quantity } + }) + }, + [activity_type_enum.DarkAltar]: { + commandName: 'runecraft', + args: (data: DarkAltarOptions) => ({ rune: `${darkAltarRunes[data.rune].item.name} (zeah)` }) + }, + [activity_type_enum.Runecraft]: { + commandName: 'runecraft', + args: (data: RunecraftActivityTaskOptions) => ({ + rune: itemNameFromID(data.runeID), + quantity: data.essenceQuantity, + daeyalt_essence: data.daeyaltEssence, + usestams: data.useStaminas + }) + }, + [activity_type_enum.TiaraRunecraft]: { + commandName: 'runecraft', + args: (data: TiaraRunecraftActivityTaskOptions) => ({ + rune: itemNameFromID(data.tiaraID), + quantity: data.tiaraQuantity + }) + }, + [activity_type_enum.Enchanting]: { + commandName: 'activities', + args: (data: EnchantingActivityTaskOptions) => ({ + enchant: { quantity: data.quantity, name: itemNameFromID(data.itemID) } + }) + }, + [activity_type_enum.Farming]: { + commandName: 'farming', + args: (data: FarmingActivityTaskOptions) => + data.autoFarmed + ? { + auto_farm: {} + } + : {} + }, + [activity_type_enum.FightCaves]: { + commandName: 'activities', + args: () => ({ fight_caves: {} }) + }, + [activity_type_enum.Firemaking]: { + commandName: 'light', + args: (data: FiremakingActivityTaskOptions) => ({ + name: itemNameFromID(data.burnableID), + quantity: data.quantity + }) + }, + [activity_type_enum.Fishing]: { + commandName: 'fish', + args: (data: FishingActivityTaskOptions) => ({ + name: data.fishID, + quantity: data.quantity, + powerfish: data.powerfish ?? false, + spirit_flakes: data.spirit_flakes ?? false + }) + + }, + [activity_type_enum.FishingTrawler]: { + commandName: 'minigames', + args: () => ({ fishing_trawler: { start: {} } }) + }, + [activity_type_enum.Fletching]: { + commandName: 'fletch', + args: (data: FletchingActivityTaskOptions) => ({ name: data.fletchableName, quantity: data.quantity }) + }, + [activity_type_enum.Gauntlet]: { + commandName: 'minigames', + args: (data: GauntletOptions) => ({ gauntlet: { start: { corrupted: data.corrupted } } }) + }, + [activity_type_enum.GloryCharging]: { + commandName: 'activities', + args: (data: ActivityTaskOptionsWithQuantity) => ({ charge: { item: 'glory', quantity: data.quantity } }) + }, + [activity_type_enum.GnomeRestaurant]: { + commandName: 'minigames', + args: () => ({ gnome_restaurant: { start: {} } }) + }, + [activity_type_enum.GroupMonsterKilling]: { + commandName: 'mass', + args: (data: GroupMonsterActivityTaskOptions) => ({ + monster: autocompleteMonsters.find(i => i.id === data.monsterID)?.name ?? data.monsterID.toString() + }) + }, + [activity_type_enum.Herblore]: { + commandName: 'mix', + args: (data: HerbloreActivityTaskOptions) => ({ + name: itemNameFromID(data.mixableID), + quantity: data.quantity, + zahur: data.zahur + }) + }, + [activity_type_enum.CutLeapingFish]: { + commandName: 'cook', + args: (data: CutLeapingFishActivityTaskOptions) => ({ + name: itemNameFromID(data.fishID), + quantity: data.quantity + }) + }, + [activity_type_enum.Hunter]: { + commandName: 'hunt', + args: (data: HunterActivityTaskOptions) => ({ + name: data.creatureName, + quantity: data.quantity, + hunter_potion: data.usingHuntPotion, + stamina_potions: data.usingStaminaPotion + }) + }, + [activity_type_enum.Inferno]: { + commandName: 'activities', + args: () => ({ inferno: { action: 'start' } }) + }, + [activity_type_enum.LastManStanding]: { + commandName: 'minigames', + args: () => ({ lms: { start: {} } }) + }, + [activity_type_enum.MageArena]: { + commandName: 'minigames', + args: () => ({ mage_arena: { start: {} } }) + }, + [activity_type_enum.MageArena2]: { + commandName: 'minigames', + args: () => ({ mage_arena_2: { start: {} } }) + }, + [activity_type_enum.MageTrainingArena]: { + commandName: 'minigames', + args: () => ({ mage_training_arena: { start: {} } }) + }, + [activity_type_enum.MahoganyHomes]: { + commandName: 'minigames', + args: (data: MahoganyHomesActivityTaskOptions) => ({ mahogany_homes: { start: { tier: data.tier } } }) + }, + [activity_type_enum.Mining]: { + commandName: 'mine', + args: (data: MiningActivityTaskOptions) => ({ + name: data.oreID, + quantity: data.iQty, + powermine: data.powermine + }) + }, + [activity_type_enum.MotherlodeMining]: { + commandName: 'mine', + args: (data: MotherlodeMiningActivityTaskOptions) => ({ + name: 'Motherlode mine', + quantity: data.iQty + }) + }, + [activity_type_enum.MonsterKilling]: { + commandName: 'k', + args: (data: MonsterActivityTaskOptions) => { + let method: PvMMethod = 'none'; + if (data.usingCannon) method = 'cannon'; + if (data.chinning) method = 'chinning'; + else if (data.burstOrBarrage === SlayerActivityConstants.IceBarrage) method = 'barrage'; + else if (data.burstOrBarrage === SlayerActivityConstants.IceBurst) method = 'burst'; + return { + name: autocompleteMonsters.find(i => i.id === data.monsterID)?.name ?? data.monsterID.toString(), + quantity: data.iQty, + method, + wilderness: data.isInWilderness + }; + } + }, + [activity_type_enum.Nex]: { + commandName: 'k', + args: (data: NexTaskOptions) => { + return { + name: 'nex', + quantity: data.quantity, + solo: data.userDetails.length === 1 + }; + } + }, + [activity_type_enum.Zalcano]: { + commandName: 'k', + args: (data: ZalcanoActivityTaskOptions) => ({ + name: 'zalcano', + quantity: data.quantity + }) + }, + [activity_type_enum.Tempoross]: { + commandName: 'k', + args: () => ({ + name: 'tempoross' + }) + }, + [activity_type_enum.Wintertodt]: { + commandName: 'k', + args: (data: ActivityTaskOptionsWithQuantity) => ({ + name: 'wintertodt', + quantity: data.quantity + }) + }, + [activity_type_enum.Nightmare]: { + commandName: 'k', + args: (data: NightmareActivityTaskOptions) => ({ + name: data.isPhosani ? 'phosani nightmare' : data.method === 'mass' ? 'mass nightmare' : 'solo nightmare', + quantity: data.quantity + }) + }, + [activity_type_enum.Offering]: { + commandName: 'offer', + args: (data: OfferingActivityTaskOptions) => ({ quantity: data.quantity, name: itemNameFromID(data.boneID) }) + }, + [activity_type_enum.PestControl]: { + commandName: 'minigames', + args: () => ({ pest_control: { start: {} } }) + }, + [activity_type_enum.Pickpocket]: { + commandName: 'steal', + args: (data: PickpocketActivityTaskOptions) => ({ name: data.monsterID, quantity: data.quantity }) + }, + [activity_type_enum.Plunder]: { + commandName: 'minigames', + args: () => ({ pyramid_plunder: {} }) + }, + [activity_type_enum.PuroPuro]: { + commandName: 'activities', + args: (data: PuroPuroActivityTaskOptions) => ({ + puro_puro: { implingTier: data.implingTier || '', dark_lure: data.darkLure } + }) + }, + [activity_type_enum.Questing]: { + commandName: 'activities', + args: () => ({ + quest: {} + }) + }, + [activity_type_enum.Raids]: { + commandName: 'raid', + args: (data: RaidsOptions) => ({ + cox: { + start: { + challenge_mode: data.challengeMode, + type: data.users.length === 1 ? 'solo' : 'mass', + quantity: data.quantity + } + } + }) + }, + [activity_type_enum.RoguesDenMaze]: { + commandName: 'minigames', + args: () => ({ + rogues_den: {} + }) + }, + [activity_type_enum.Sawmill]: { + commandName: 'activities', + args: (data: SawmillActivityTaskOptions) => ({ + plank_make: { action: 'sawmill', quantity: data.plankQuantity, type: itemNameFromID(data.plankID) } + }) + }, + [activity_type_enum.Butler]: { + commandName: 'activities', + args: (data: ButlerActivityTaskOptions) => ({ + plank_make: { action: 'butler', quantity: data.plankQuantity, type: itemNameFromID(data.plankID) } + }) + }, + [activity_type_enum.Sepulchre]: { + commandName: 'minigames', + args: () => ({ sepulchre: { start: {} } }) + }, + [activity_type_enum.Smithing]: { + commandName: 'smith', + args: (data: SmithingActivityTaskOptions) => ({ + name: itemNameFromID(data.smithedBarID), + quantity: data.quantity + }) + }, + [activity_type_enum.SoulWars]: { + commandName: 'minigames', + args: () => ({ soul_wars: { start: {} } }) + }, + [activity_type_enum.TheatreOfBlood]: { + commandName: 'raid', + args: (data: TheatreOfBloodTaskOptions) => ({ + tob: { + start: { + hard_mode: data.hardMode, + solo: data.solo + } + } + }) + }, + [activity_type_enum.TitheFarm]: { + commandName: 'farming', + args: () => ({ tithe_farm: {} }) + }, + [activity_type_enum.Trekking]: { + commandName: 'minigames', + args: (data: TempleTrekkingActivityTaskOptions) => ({ + temple_trek: { start: { difficulty: data.difficulty, quantity: data.quantity } } + }) + }, + [activity_type_enum.TroubleBrewing]: { + commandName: 'minigames', + args: () => ({ trouble_brewing: { start: {} } }) + }, + [activity_type_enum.VolcanicMine]: { + commandName: 'minigames', + args: (data: ActivityTaskOptionsWithQuantity) => ({ volcanic_mine: { start: { quantity: data.quantity } } }) + }, + [activity_type_enum.WealthCharging]: { + commandName: 'activities', + args: (data: ActivityTaskOptionsWithQuantity) => ({ charge: { item: 'wealth', quantity: data.quantity } }) + }, + [activity_type_enum.Woodcutting]: { + commandName: 'chop', + args: (data: WoodcuttingActivityTaskOptions) => ({ + name: itemNameFromID(data.logID), + quantity: data.iQty, + powerchop: data.powerchopping, + forestry_events: data.forestry, + twitchers_gloves: data.twitchers + }) + }, + [activity_type_enum.GiantsFoundry]: { + commandName: 'minigames', + args: (data: GiantsFoundryActivityTaskOptions) => ({ + giants_foundry: { + start: { name: giantsFoundryAlloys.find(i => i.id === data.alloyID)?.name, quantity: data.quantity } + } + }) + }, + [activity_type_enum.GuardiansOfTheRift]: { + commandName: 'minigames', + args: (data: GuardiansOfTheRiftActivityTaskOptions) => ({ + gotr: { + start: { combination_runes: data.combinationRunes } + } + }) + }, + [activity_type_enum.NightmareZone]: { + commandName: 'minigames', + args: (data: NightmareZoneActivityTaskOptions) => ({ + nmz: { + start: { strategy: data.strategy } + } + }) + }, + [activity_type_enum.ShadesOfMorton]: { + commandName: 'minigames', + args: (data: ShadesOfMortonOptions) => ({ + shades_of_morton: { + start: { shade: data.shadeID, logs: itemNameFromID(data.logID) } + } + }) + }, + [activity_type_enum.TombsOfAmascut]: { + commandName: 'raid', + args: (data: TOAOptions) => ({ + toa: { + start: { + raid_level: data.raidLevel, + max_team_size: data.users.length, + solo: data.users.length === 1, + quantity: data.quantity + } + } + }) + }, + [activity_type_enum.UnderwaterAgilityThieving]: { + commandName: 'activities', + args: (data: UnderwaterAgilityThievingTaskOptions) => ({ + underwater: { + agility_thieving: { + training_skill: data.trainingSkill, + minutes: Math.floor(data.duration / Time.Minute), + no_stams: data.noStams + } + } + }) + }, + [activity_type_enum.DriftNet]: { + commandName: 'activities', + args: (data: ActivityTaskOptionsWithQuantity) => ({ + underwater: { + drift_net_fishing: { minutes: Math.floor(data.duration / Time.Minute) } + } + }) + }, + [activity_type_enum.Colosseum]: { + commandName: 'k', + args: () => ({ + name: 'colosseum' + }) + } +} as const; + +for (const type of Object.values(activity_type_enum)) { + if (!tripHandlers[type]) { + throw new Error(`Missing trip handler for ${type}`); + } +} + +export async function fetchRepeatTrips(userID: string) { + const res: Activity[] = await prisma.activity.findMany({ + where: { + user_id: BigInt(userID), + finish_date: { + gt: new Date(Date.now() - Time.Day * 7) + } + }, + orderBy: { + id: 'desc' + }, + take: 20 + }); + const filtered: { + type: activity_type_enum; + data: Prisma.JsonValue; + }[] = []; + for (const trip of res) { + if (!taskCanBeRepeated(trip)) continue; + if (trip.type === activity_type_enum.Farming && !(trip.data as any as FarmingActivityTaskOptions).autoFarmed) { + continue; + } + if (!filtered.some(i => i.type === trip.type)) { + filtered.push(trip); + } + } + return filtered; +} + +export async function makeRepeatTripButtons(user: MUser) { + const trips = await fetchRepeatTrips(user.id); + const buttons: ButtonBuilder[] = []; + const limit = Math.min(user.perkTier() + 1, 5); + for (const trip of trips.slice(0, limit)) { + buttons.push( + new ButtonBuilder() + .setLabel(`Repeat ${trip.type}`) + .setCustomId(`REPEAT_TRIP_${trip.type}`) + .setStyle(ButtonStyle.Secondary) + ); + } + return buttons; +} + +export async function repeatTrip( + interaction: ButtonInteraction, + data: { data: Prisma.JsonValue; type: activity_type_enum } +) { + await deferInteraction(interaction); + const handler = tripHandlers[data.type]; + return runCommand({ + commandName: handler.commandName, + isContinue: true, + args: handler.args(data.data as any), + interaction, + guildID: interaction.guildId, + member: interaction.member, + channelID: interaction.channelId, + user: interaction.user, + continueDeltaMillis: interaction.createdAt.getTime() - interaction.message.createdTimestamp + }); +} + diff --git a/types.ts b/types.ts new file mode 100644 index 0000000000..a6b11fee6f --- /dev/null +++ b/types.ts @@ -0,0 +1,362 @@ +import type { Bank } from 'oldschooljs'; +import type { Item } from 'oldschooljs/dist/meta/types'; +import type LootTable from 'oldschooljs/dist/structures/LootTable'; + +import type { Emoji } from '../constants'; +import type { SlayerTaskUnlocksEnum } from '../slayer/slayerUnlocks'; +import type { ItemBank } from '../types'; +import type { FarmingPatchName } from '../util/farmingHelpers'; + +export enum SkillsEnum { + Agility = 'agility', + Cooking = 'cooking', + Fishing = 'fishing', + Mining = 'mining', + Smithing = 'smithing', + Woodcutting = 'woodcutting', + Firemaking = 'firemaking', + Runecraft = 'runecraft', + Crafting = 'crafting', + Prayer = 'prayer', + Fletching = 'fletching', + Farming = 'farming', + Herblore = 'herblore', + Thieving = 'thieving', + Hunter = 'hunter', + Construction = 'construction', + Magic = 'magic', + Attack = 'attack', + Strength = 'strength', + Defence = 'defence', + Ranged = 'ranged', + Hitpoints = 'hitpoints', + Slayer = 'slayer' +} + +export const SkillsArray = [ + 'agility', + 'cooking', + 'fishing', + 'mining', + 'smithing', + 'woodcutting', + 'firemaking', + 'runecraft', + 'crafting', + 'prayer', + 'fletching', + 'farming', + 'herblore', + 'thieving', + 'hunter', + 'construction', + 'magic', + 'attack', + 'strength', + 'defence', + 'ranged', + 'hitpoints', + 'slayer' +] as const; + +export type SkillNameType = (typeof SkillsArray)[number]; +for (const skill of SkillsArray) { + const matching = Object.keys(SkillsEnum).find(key => key.toLowerCase() === skill); + if (!matching) throw new Error(`Missing skill enum for ${skill}`); +} +if (SkillsArray.length !== Object.keys(SkillsEnum).length) { + throw new Error('Not all skills have been added to the SkillsArray.'); +} + +export interface Ore { + level: number; + xp: number; + id: number; + name: string; + respawnTime: number; + bankingTime: number; + slope: number; + intercept: number; + petChance?: number; + minerals?: number; + clueScrollChance?: number; + aliases?: string[]; +} + +export interface Log { + level: number; + xp: number; + id: number; + lootTable?: LootTable; + name: string; + leaf?: number; + aliases?: string[]; + findNewTreeTime: number; + bankingTime: number; + slope: number; + intercept: number; + depletionChance: number; + wcGuild?: boolean; + petChance?: number; + qpRequired: number; + clueScrollChance?: number; + clueNestsOnly?: boolean; +} + +export interface Burnable { + level: number; + xp: number; + name: string; + inputLogs: number; +} + +export interface Fish { + name: string; + alias?: string[]; + level: number; + xp: number; + id: number; + chance1Lvl1?: number; + chance1Lvl99?: number; + level2?: number; + xp2?: number; + id2?: number; + chance2Lvl1?: number; + chance2Lvl99?: number; + level3?: number; + xp3?: number; + id3?: number; + chance3Lvl1?: number; + chance3Lvl99?: number; + + petChance?: number; + clueScrollChance?: number; + lostTicks?: number; + bankingTime?: number; + ticksPerRoll?: number; + + bait?: number; + qpRequired?: number; + bigFish?: number; + bigFishRate?: number; + + timePerFish?: number; +} + +export interface Course { + id: number; + name: string; + level: number; + xp: number | ((agilityLevel: number) => number); + marksPer60?: number; + lapTime: number; + petChance: number; + aliases: string[]; + qpRequired?: number; +} + +export interface Cookable { + level: number; + xp: number; + id: number; + name: string; + inputCookables: ItemBank; + stopBurnAt: number; + stopBurnAtCG?: number; + // Burn level with hosidius/diary: [ noGauntletsHosidius, noGauntletsElite, gauntletsHosidius, gauntletsElite ] + burnKourendBonus?: number[]; + burntCookable: number; + alias?: string[]; +} + +export interface Bar { + level: number; + xp: number; + id: number; + name: string; + inputOres: Bank; + /** + * Chance that the ore will fail to smelt (i.e iron), out of 100 + */ + chanceOfFail: number; + timeToUse: number; +} + +export interface BlastableBar { + level: number; + xp: number; + id: number; + name: string; + inputOres: Bank; + timeToUse: number; +} + +export interface SmithedItem { + level: number; + xp: number; + id: number; + name: string; + inputBars: ItemBank; + timeToUse: number; + outputMultiple: number; + qpRequired?: number; +} + +export interface Craftable { + name: string; + alias?: string[]; + id: number; + level: number; + xp: number; + inputItems: Bank; + tickRate: number; + crushChance?: number[]; + bankChest?: boolean; + outputMultiple?: number; + qpRequired?: number; + wcLvl?: number; +} + +export interface Fletchable { + name: string; + id: number; + level: number; + xp: number; + inputItems: Bank; + tickRate: number; + outputMultiple?: number; + requiredSlayerUnlocks?: SlayerTaskUnlocksEnum[]; + craftingXp?: number; +} + +export interface Mixable { + item: Item; + aliases: string[]; + level: number; + xp: number; + inputItems: Bank; + tickRate: number; + bankTimePerPotion: number; + outputMultiple?: number; + zahur?: boolean; + wesley?: boolean; + qpRequired?: number; +} + +export interface CutLeapingFish { + item: Item; + aliases: string[]; + tickRate: number; +} + +export interface Bone { + level: number; + xp: number; + name: string; + inputId: number; +} + +export interface Ash { + level: number; + xp: number; + name: string; + inputId: number; +} + +export type LevelRequirements = Partial<{ + [key in SkillsEnum]: number; +}>; + +export interface Skill { + aliases: string[]; + id: SkillsEnum; + emoji: Emoji; + name: string; +} + +export interface Plankable { + name: string; + inputItem: number; + outputItem: number; + gpCost: number; +} + +export interface Plant { + id: number; + level: number; + plantXp: number; + checkXp: number; + harvestXp: number; + name: string; + inputItems: Bank; + aliases: string[]; + outputCrop?: number; + cleanHerbCrop?: number; + herbXp?: number; + herbLvl?: number; + outputLogs?: number; + outputRoots?: number; + treeWoodcuttingLevel?: number; + fixedOutputAmount?: number; + variableYield?: boolean; + variableOutputAmount?: [string | null, number, number][]; + woodcuttingXp?: number; + needsChopForHarvest: boolean; + fixedOutput: boolean; + givesLogs: boolean; + givesCrops: boolean; + petChance: number; + seedType: FarmingPatchName; + growthTime: number; + numOfStages: number; + chance1: number; + chance99: number; + chanceOfDeath: number; + protectionPayment?: Bank; + defaultNumOfPatches: number; + canPayFarmer: boolean; + canCompostPatch: boolean; + canCompostandPay: boolean; + additionalPatchesByQP: number[][]; + additionalPatchesByFarmLvl: number[][]; + additionalPatchesByFarmGuildAndLvl: number[][]; + timePerPatchTravel: number; + timePerHarvest: number; +} + +export enum HunterTechniqueEnum { + AerialFishing = 'aerial fishing', + DriftNet = 'drift net fishing', + BirdSnaring = 'bird snaring', + BoxTrapping = 'box trapping', + ButterflyNetting = 'butterfly netting', + DeadfallTrapping = 'deadfall trapping', + Falconry = 'falconry', + MagicBoxTrapping = 'magic box trapping', + NetTrapping = 'net trapping', + PitfallTrapping = 'pitfall trapping', + RabbitSnaring = 'rabbit snaring', + Tracking = 'tracking' +} + +export interface Creature { + name: string; + id: number; + aliases: string[]; + level: number; + hunterXP: number; + fishLvl?: number; + fishingXP?: number; + itemsRequired?: Bank; + itemsConsumed?: Bank; + table: LootTable; + huntTechnique: HunterTechniqueEnum; + multiTraps?: boolean; + wildy?: boolean; + prayerLvl?: number; + herbloreLvl?: number; + catchTime: number; + qpRequired?: number; + slope: number; + intercept: number; +}