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/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 diff --git a/package.json b/package.json index fbd80a360a..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\"", @@ -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", @@ -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/prisma/schema.prisma b/prisma/schema.prisma index 93068f4969..980daebb64 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -494,7 +494,6 @@ model User { store_bitfield Int[] - // 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 @@ -535,6 +534,8 @@ model User { grinchions_caught Int @default(0) last_giveaway_ticket_given_date DateTime? @db.Timestamp(6) + cl_array Int[] @default([]) + @@index([id, last_command_date]) @@map("users") } @@ -993,7 +994,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/MUser.ts b/src/lib/MUser.ts index ef7711b922..2c46ad8066 100644 --- a/src/lib/MUser.ts +++ b/src/lib/MUser.ts @@ -152,7 +152,7 @@ export class MUserClass { return gearImages.find(i => i.id === this.user.gear_template)!; } - countSkillsAtleast99() { + countSkillsAtLeast99() { return Object.values(this.skillsAsLevels).filter(lvl => lvl >= 99).length; } diff --git a/src/lib/bso/divination.ts b/src/lib/bso/divination.ts index 1e0d3b4622..2535457efe 100644 --- a/src/lib/bso/divination.ts +++ b/src/lib/bso/divination.ts @@ -351,9 +351,9 @@ export const portents: SourcePortent[] = [ description: 'Consumes stone spirits to grant extra mining XP, instead of extra ore.', divinationLevelToCreate: 90, cost: new Bank().add('Incandescent energy', 1200), - chargesPerPortent: 1000, + chargesPerPortent: 60 * 10, addChargeMessage: portent => - `You used a Spiritual mining portent, your next ${portent.charges_remaining}x stone spirits will grant XP instead of ore.` + `You used a Spiritual mining portent, it will turn stone spirits into extra mining XP, instead of ore, in your next ${portent.charges_remaining} minutes of mining.` }, { id: PortentID.PacifistPortent, diff --git a/src/lib/colosseum.ts b/src/lib/colosseum.ts index 89dd93dbfc..18cd4c1457 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/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) } }, diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 344757f545..1ea31a6f87 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -76,7 +76,10 @@ export const Roles = { TopSlayer: DISCORD_SETTINGS.Roles?.TopSlayer ?? '867967551819358219', TopInventor: '992799099801833582', TopLeagues: '1005417171112972349', - EventOrganizer: '1149907536749801542' + EventOrganizer: '1149907536749801542', + TopTamer: '1054356709222666240', + TopMysterious: '1074592096968785960', + TopGlobalCL: '848966773885763586' }; export enum DefaultPingableRoles { @@ -540,7 +543,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 } = { @@ -556,7 +561,9 @@ export const badges: { [key: number]: string } = { [BadgesEnum.TopSkiller]: Emoji.Skiller, [BadgesEnum.TopCollector]: Emoji.CollectionLog, [BadgesEnum.TopMinigame]: Emoji.MinigameIcon, - [BadgesEnum.SotWTrophy]: Emoji.SOTW + [BadgesEnum.SotWTrophy]: Emoji.SOTWTrophy, + [BadgesEnum.Slayer]: Emoji.Slayer, + [BadgesEnum.TopGiveawayer]: Emoji.SantaHat }; export const MAX_XP = 5_000_000_000; diff --git a/src/lib/data/buyables/skillCapeBuyables.ts b/src/lib/data/buyables/skillCapeBuyables.ts index 715f43004f..81bca7cb2b 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 a09ea195a5..6f5bbe69aa 100644 --- a/src/lib/data/cox.ts +++ b/src/lib/data/cox.ts @@ -241,11 +241,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/data/itemAliases.ts b/src/lib/data/itemAliases.ts index 00f211cacf..0c46bdcfb9 100644 --- a/src/lib/data/itemAliases.ts +++ b/src/lib/data/itemAliases.ts @@ -195,12 +195,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/degradeableItems.ts b/src/lib/degradeableItems.ts index 45c29836fa..78786b4437 100644 --- a/src/lib/degradeableItems.ts +++ b/src/lib/degradeableItems.ts @@ -427,10 +427,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/diaries.ts b/src/lib/diaries.ts index ba4f9c0eb0..83f5d5ad95 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 diff --git a/src/lib/handleNewCLItems.ts b/src/lib/handleNewCLItems.ts index 9da92ab0ec..9ee23af33b 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'; @@ -51,6 +52,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); @@ -105,15 +110,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/minions/data/killableMonsters/vannakaMonsters.ts b/src/lib/minions/data/killableMonsters/vannakaMonsters.ts index 0e702405fe..8893da4fd3 100644 --- a/src/lib/minions/data/killableMonsters/vannakaMonsters.ts +++ b/src/lib/minions/data/killableMonsters/vannakaMonsters.ts @@ -244,7 +244,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/data/quests.ts b/src/lib/minions/data/quests.ts index 9bc4142c44..0bc8365632 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/lib/minions/functions/index.ts b/src/lib/minions/functions/index.ts index 279504616d..a2433b0007 100644 --- a/src/lib/minions/functions/index.ts +++ b/src/lib/minions/functions/index.ts @@ -84,7 +84,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)) { @@ -97,7 +97,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 777645904f..1bffbea1c1 100644 --- a/src/lib/minions/types.ts +++ b/src/lib/minions/types.ts @@ -189,7 +189,7 @@ export interface AddMonsterXpParams { export interface ResolveAttackStylesParams { monsterID: number | undefined; - boostMethod?: string; + boostMethod?: string[]; } export interface BlowpipeData { diff --git a/src/lib/musicCape.ts b/src/lib/musicCape.ts index 3686b5eef2..da18afda19 100644 --- a/src/lib/musicCape.ts +++ b/src/lib/musicCape.ts @@ -103,7 +103,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/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..c1b2cd2d97 --- /dev/null +++ b/src/lib/rawSql.ts @@ -0,0 +1,55 @@ +import { Prisma } from '@prisma/client'; +import type { ItemBank } from './types'; +import { logError } from './util/logError'; + +const u = Prisma.UserScalarFieldEnum; + +const RawBSOSQL = { + leaguesTaskLeaderboard: () => roboChimpClient.$queryRaw<{ id: string; tasks_completed: number }[]>`SELECT id::text, COALESCE(cardinality(leagues_completed_tasks_ids), 0) AS tasks_completed + FROM public.user + ORDER BY tasks_completed DESC + LIMIT 2;`, + openablesLeaderboard: (id: number) => + prisma.$queryRawUnsafe<{ id: string; score: number }[]>( + `SELECT user_id::text AS id, ("openable_scores"->>'${id}')::int AS score +FROM user_stats +WHERE "openable_scores"->>'${id}' IS NOT NULL +AND ("openable_scores"->>'${id}')::int > 50 +ORDER BY ("openable_scores"->>'${id}')::int DESC +LIMIT 50;` + ), + monkeysFoughtLeaderboard: () => + prisma.$queryRawUnsafe<{ id: string }[]>( + 'SELECT id FROM users WHERE monkeys_fought IS NOT NULL ORDER BY cardinality(monkeys_fought) DESC LIMIT 1;' + ), + inventionDisassemblyLeaderboard: () => + prisma.$queryRawUnsafe<{ id: string; uniques: number; disassembled_items_bank: ItemBank }[]>(`SELECT u.id, u.uniques, u.disassembled_items_bank FROM ( + SELECT (SELECT COUNT(*) FROM JSON_OBJECT_KEYS("disassembled_items_bank")) uniques, id, disassembled_items_bank FROM users WHERE "skills.invention" > 0 +) u +ORDER BY u.uniques DESC LIMIT 300;`) +}; + +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}';`, + ...RawBSOSQL +}; + +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 12317a1a79..c9c328c7b5 100644 --- a/src/lib/rolesTask.ts +++ b/src/lib/rolesTask.ts @@ -1,519 +1,352 @@ -import { Prisma } from '@prisma/client'; -import { noOp, notEmpty } from 'e'; -import { Bank } from 'oldschooljs'; +import { noOp, notEmpty, 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, overallPlusItems } from '../lib/data/Collections'; import { Minigames } from '../lib/settings/minigames'; -import Skills from '../lib/skilling/skills'; -import { assert, convertXPtoLVL, getUsername, getUsernameSync, sanitizeBank } from '../lib/util'; -import { logError } from '../lib/util/logError'; +import { Prisma } from '@prisma/client'; +import { Bank } from 'oldschooljs'; +import PQueue from 'p-queue'; +import { partition } from 'remeda'; +import z from 'zod'; +import { + type CommandResponse, + Stopwatch, + convertXPtoLVL, + getUsernameSync, + resolveItems, + returnStringOrFile +} from '../lib/util'; +import { ClueTiers } from './clues/clueTiers'; +import { RawSQL, loggedRawPrismaQuery } from './rawSql'; import { TeamLoot } from './simulation/TeamLoot'; +import { SkillsArray } from './skilling/types'; import type { ItemBank } from './types'; -import { fetchTameCLLeaderboard } from './util/clLeaderboard'; -import resolveItems from './util/resolveItems'; +import { fetchMultipleCLLeaderboards, fetchTameCLLeaderboard } from './util/clLeaderboard'; +import { logError } from './util/logError'; -function addToUserMap(userMap: Record, id: string, reason: string) { - if (!userMap[id]) userMap[id] = []; - userMap[id].push(reason); -} +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'); -const collections = ['skilling', 'clues', 'minigames', 'raids', 'Dyed Items', 'slayer', 'other']; +const CLS_THAT_GET_ROLE = ['skilling', 'clues', 'minigames', 'other', 'overall']; -for (const cl of collections) { +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); +async function topSkillers() { + const results: RoleResult[] = []; + + const [top200TotalXPUsers, ...top200ms] = await prisma.$transaction([ + prisma.$queryRawUnsafe( + `SELECT id, ${SkillsArray.map(s => `"skills.${s}"`)}, ${SkillsArray.map(s => `"skills.${s}"::bigint`).join( + ' + ' + )} as totalxp FROM users ORDER BY totalxp DESC LIMIT 200;` + ), + ...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; + }>(query); + }) + ]); + + for (const { id, skill } of top200ms.flat()) { + results.push({ + userID: id, + roleID: Roles.TopSkiller, + reason: `Rank 1 ${skill} XP`, + badge: BadgesEnum.TopSkiller + }); } - 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) - }); + const rankOneTotal = top200TotalXPUsers + .map((u: any) => { + let totalLevel = 0; + for (const skill of SkillsArray) { + totalLevel += convertXPtoLVL(Number(u[`skills.${skill}` as keyof any]) as any); } - 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() { - const skillVals = Object.values(Skills); + return { + id: u.id, + totalLevel + }; + }) + .sort((a: any, b: any) => b.totalLevel - a.totalLevel)[0]; - 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;` - ) - ) - .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 })); - } + results.push({ + userID: rankOneTotal.id, + roleID: Roles.TopSkiller, + reason: 'Rank 1 Total Level', + 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; - } + return results; +} - 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); +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 + }); } - - 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`); - } - } - 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'); - - 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.flat()) { + 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]); +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; +} - const userMap = {}; - for (const [id, desc] of topSlayers) { - addToUserMap(userMap, id, desc); +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 + } } + }); - results.push( - await addRoles({ - users: topSlayers.map(i => i[0]), - role: Roles.TopSlayer, - badge: null, - userMap - }) - ); + if (giveaways.length === 0) return results; + + for (const giveaway of giveaways) { + giveawayBank.add(giveaway.user_id, giveaway.loot as ItemBank); } - // 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 [[highestID, loot]] = giveawayBank.entries().sort((a, b) => b[1].value() - a[1].value()); - const giveawayBank = new TeamLoot(); + results.push({ + userID: highestID, + roleID: '1104155653745946746', + reason: `Most Value Given Away (${loot.value()})`, + badge: BadgesEnum.TopGiveawayer + }); + return results; +} - const giveaways = await prisma.giveaway.findMany({ - where: { - channel_id: { - in: GIVEAWAY_CHANNELS - }, - user_id: { - in: userIDsToCheck - } - } +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 }); - for (const giveaway of giveaways) { - giveawayBank.add(giveaway.user_id, giveaway.loot as ItemBank); - } - for (const [, bank] of giveawayBank.entries()) { - sanitizeBank(bank); - } - - 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: '1104155653745946746', - badge: null, - userMap - }) - ); } + return results; +} - async function monkeyKing() { - const res = await q( - 'SELECT id FROM users WHERE monkeys_fought IS NOT NULL ORDER BY cardinality(monkeys_fought) DESC LIMIT 1;' - ); - results.push(await addRoles({ users: [res[0].id], role: '886180040465870918', badge: null })); - } - async function topInventor() { - const userMap = {}; - const topInventors: string[] = []; - const mostUniques = await q<{ id: string; uniques: number; disassembled_items_bank: ItemBank }[]>(`SELECT u.id, u.uniques, u.disassembled_items_bank FROM ( - SELECT (SELECT COUNT(*) FROM JSON_OBJECT_KEYS("disassembled_items_bank")) uniques, id, disassembled_items_bank FROM users WHERE "skills.invention" > 0 -) u -ORDER BY u.uniques DESC LIMIT 300;`); - topInventors.push(mostUniques[0].id); - addToUserMap(userMap, mostUniques[0].id, 'Most Uniques Disassembled'); - const parsed = mostUniques - .map(i => ({ ...i, value: new Bank(i.disassembled_items_bank).value() })) - .sort((a, b) => b.value - a.value); - topInventors.push(parsed[0].id); - addToUserMap(userMap, parsed[0].id, 'Most Value Disassembled'); - results.push(await addRoles({ users: topInventors, role: Roles.TopInventor, badge: null, userMap })); - } - async function topLeagues() { - if (process.env.TEST) return; - const topPoints = await roboChimpClient.user.findMany({ +async function topLeagues() { + const [topPoints, topTasks] = await prisma.$transaction([ + roboChimpClient.user.findMany({ where: { leagues_points_total: { gt: 0 @@ -523,126 +356,190 @@ ORDER BY u.uniques DESC LIMIT 300;`); leagues_points_total: 'desc' }, take: 2 + }), + RawSQL.leaguesTaskLeaderboard() + ]); + + const results: RoleResult[] = []; + for (const userID of [topPoints, topTasks].flat().map(i => i.id.toString())) { + results.push({ + userID: userID, + roleID: Roles.TopLeagues, + reason: 'Top 2 leagues points/points' }); - const topTasks: { id: string; tasks_completed: number }[] = await roboChimpClient.$queryRaw`SELECT id::text, COALESCE(cardinality(leagues_completed_tasks_ids), 0) AS tasks_completed - FROM public.user - ORDER BY tasks_completed DESC - LIMIT 2;`; - const userMap = {}; - addToUserMap(userMap, topPoints[0].id.toString(), 'Rank 1 Leagues Points'); - addToUserMap(userMap, topPoints[1].id.toString(), 'Rank 2 Leagues Points'); - addToUserMap(userMap, topTasks[0].id, 'Rank 1 Leagues Tasks'); - addToUserMap(userMap, topTasks[1].id, 'Rank 2 Leagues Tasks'); - const allLeagues = topPoints.map(i => i.id.toString()).concat(topTasks.map(i => i.id)); - assert(allLeagues.length > 0 && allLeagues.length <= 4); - results.push(await addRoles({ users: allLeagues, role: Roles.TopLeagues, badge: null, userMap })); } + return results; +} + +async function topTamer(): Promise { + const [rankOne] = await fetchTameCLLeaderboard({ items: overallPlusItems, resultLimit: 1 }); + if (rankOne) { + return [ + { + userID: rankOne.user_id, + roleID: Roles.TopTamer, + reason: 'Rank 1 Tames CL' + } + ]; + } + return []; +} - async function topTamer() { - const [rankOne] = await fetchTameCLLeaderboard({ items: overallPlusItems, resultLimit: 1 }); - if (rankOne) { - results.push( - await addRoles({ - users: [rankOne.user_id], - role: '1054356709222666240', - badge: null, - userMap: { [rankOne.user_id]: ['Rank 1 Tames CL'] } - }) - ); +async function topMysterious(): Promise { + const items = resolveItems([ + 'Pet mystery box', + 'Holiday mystery box', + 'Equippable mystery box', + 'Clothing mystery box', + 'Tradeable mystery box', + 'Untradeable mystery box' + ]); + const res = await Promise.all(items.map(id => RawSQL.openablesLeaderboard(id))); + + const userScoreMap: Record = {}; + for (const lb of res) { + const [rankOne] = lb; + for (const user of lb) { + if (!userScoreMap[user.id]) userScoreMap[user.id] = 0; + userScoreMap[user.id] += user.score / rankOne.score; } } - const notes: string[] = []; - - async function mysterious() { - const items = resolveItems([ - 'Pet mystery box', - 'Holiday mystery box', - 'Equippable mystery box', - 'Clothing mystery box', - 'Tradeable mystery box', - 'Untradeable mystery box' - ]); - const res = await Promise.all( - items.map(id => - prisma - .$queryRawUnsafe<{ id: string; score: number }[]>( - `SELECT user_id::text AS id, ("openable_scores"->>'${id}')::int AS score -FROM user_stats -WHERE "openable_scores"->>'${id}' IS NOT NULL -AND ("openable_scores"->>'${id}')::int > 50 -ORDER BY ("openable_scores"->>'${id}')::int DESC -LIMIT 50;` - ) - .then(i => i.map(t => ({ id: t.id, score: Number(t.score) }))) - ) - ); + const entries = Object.entries(userScoreMap).sort((a, b) => b[1] - a[1]); + const [[rankOneID]] = entries; - const userScoreMap: Record = {}; - for (const lb of res) { - const [rankOne] = lb; - for (const user of lb) { - if (!userScoreMap[user.id]) userScoreMap[user.id] = 0; - userScoreMap[user.id] += user.score / rankOne.score; - } + return [ + { + userID: rankOneID, + roleID: Roles.TopMysterious, + reason: 'Rank 1 Mystery Box Opens' } + ]; +} - const entries = Object.entries(userScoreMap).sort((a, b) => b[1] - a[1]); - const [[rankOneID]] = entries; +async function monkeyKing(): Promise { + const [user] = await RawSQL.monkeysFoughtLeaderboard(); + return [{ userID: user.id, roleID: '886180040465870918', reason: 'Most Monkeys Fought' }]; +} - let note = '**Top Mystery Box Openers**\n\n'; - for (const [id, score] of entries.slice(0, 10)) { - note += `${getUsernameSync(id) ?? id} - ${score}\n`; - } +async function topInventor(): Promise { + const mostUniquesLb = await RawSQL.inventionDisassemblyLeaderboard(); + const topInventors: string[] = [mostUniquesLb[0].id]; + const parsed = mostUniquesLb + .map(i => ({ ...i, value: new Bank(i.disassembled_items_bank).value() })) + .sort((a, b) => b.value - a.value); + topInventors.push(parsed[0].id); + return topInventors.map(i => ({ userID: i, roleID: Roles.TopInventor, reason: 'Most Uniques/Value Disassembled' })); +} - notes.push(note); +export async function runRolesTask(dryRun: boolean): Promise { + const results: RoleResult[] = []; - results.push( - await addRoles({ - users: [rankOneID], - role: '1074592096968785960', - badge: null, - userMap: { [rankOneID]: ['Rank 1 Mystery Box Opens'] } - }) - ); - } + const promiseQueue = new PQueue({ concurrency: 2 }); 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], - ['Monkey King', monkeyKing], - ['Top Farmers', farmers], - ['Top Inventor', topInventor], + ['Global CL', globalCL], + + // BSO Only ['Top Leagues', topLeagues], ['Top Tamer', topTamer], - ['Mysterious', mysterious] + ['Top Mysterious', topMysterious], + ['Monkey King', monkeyKing], + ['Top Inventor', topInventor] ] as const; - const failed: string[] = []; - await Promise.all( - tup.map(async ([name, fn]) => { + for (const [name, fn] of tup) { + promiseQueue.add(async () => { + const stopwatch = new Stopwatch(); try { - await fn(); - } catch (err: any) { - if (process.env.TEST) { - throw err; + 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)}`); } - failed.push(`${name} (${err.message})`); - logError(err); + } catch (err) { + logError(`[RolesTask] Error in ${name}: ${err}`); + } finally { + debugLog(`[RolesTask] Ran ${name} in ${stopwatch.stop()}`); + } + }); + } + + await promiseQueue.onIdle(); + + debugLog(`Finished role functions, ${results.length} results`); + + 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(); + 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) + debugLog('Removing badges...'); + const badgeIDs = `ARRAY[${allBadgeIDs.join(',')}]`; + await loggedRawPrismaQuery(` +UPDATE users +SET badges = badges - ${badgeIDs} +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(console.error); + } + } + + // 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) { + 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(console.error); } - }) - ); - const res = `**Roles** -${results.join('\n')} -${failed.length > 0 ? `Failed: ${failed.join(', ')}` : ''} -${notes.join('\n')}`; + if (badge) { + const user = await mUserFetch(userID); + if (!user.user.badges.includes(badge)) { + await user.update({ + badges: { + push: badge + } + }); + } + } + } + + return returnStringOrFile( + `**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/simulation/toa.ts b/src/lib/simulation/toa.ts index d01273df71..e38fab51b2 100644 --- a/src/lib/simulation/toa.ts +++ b/src/lib/simulation/toa.ts @@ -255,7 +255,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` }, @@ -293,7 +293,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(', ')}` }, @@ -350,11 +350,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' @@ -363,7 +363,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' @@ -380,7 +380,7 @@ const toaRequirements: { return true; }, - desc: () => `Need atleast ${minimumSuppliesNeeded}` + desc: () => `Need at least ${minimumSuppliesNeeded}` }, { name: 'Rune Pouch', @@ -391,7 +391,7 @@ const toaRequirements: { } return true; }, - desc: () => `Need atleast ${minimumSuppliesNeeded}` + desc: () => `Need at least ${minimumSuppliesNeeded}` }, { name: 'Poison Protection', @@ -1067,7 +1067,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.` ]; @@ -1080,7 +1080,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/functions/calcsFarming.ts b/src/lib/skilling/functions/calcsFarming.ts index 89c8128487..d316429382 100644 --- a/src/lib/skilling/functions/calcsFarming.ts +++ b/src/lib/skilling/functions/calcsFarming.ts @@ -1,6 +1,7 @@ import { randInt } from 'e'; import { userHasMasterFarmerOutfit } from '../../../mahoji/mahojiSettings'; import { BitField } from '../../constants'; +import { QuestID } from '../../minions/data/quests'; import { hasUnlockedAtlantis } from '../../util'; import type { FarmingPatchName } from '../../util/farmingHelpers'; import { type Plant, SkillsEnum } from '../types'; @@ -46,6 +47,18 @@ export function calcNumOfPatches(plant: Plant, user: MUser, qp: number): [number } } + 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]; } diff --git a/src/lib/skilling/types.ts b/src/lib/skilling/types.ts index 713d9d3d55..77bd5dd420 100644 --- a/src/lib/skilling/types.ts +++ b/src/lib/skilling/types.ts @@ -327,7 +327,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/slayer/slayerUtil.ts b/src/lib/slayer/slayerUtil.ts index bf70005e83..97ef9b2fcf 100644 --- a/src/lib/slayer/slayerUtil.ts +++ b/src/lib/slayer/slayerUtil.ts @@ -35,45 +35,55 @@ 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'; - - // BSO Only: - if (!params.isOnTask) return boostChoice; - - if (params.method && params.method === 'none') { - return boostChoice; - } - if (params.method && (params.method as string) === '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'; +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.isOnTask) return []; + + // check if user has cannon combat option turned on + if (params.cbOpts.includes(CombatOptionsEnum.AlwaysCannon)) { + boostMethods.includes('cannon') ? null : boostMethods.push('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/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 = { alry: 'Alry the Angler', wurMuTheMonkey: 'Wur Mu the Monkey', marimbo: 'Marimbo', - ketKeh: 'Tzhaar-Ket-Keh', + ketKeh: 'TzHaar-Ket-Keh', gertrude: 'Gertrude', antiSanta: 'Anti-Santa', bunny: 'Easter Bunny', diff --git a/src/lib/util/clLeaderboard.ts b/src/lib/util/clLeaderboard.ts index 70f4c74016..6f44455d8a 100644 --- a/src/lib/util/clLeaderboard.ts +++ b/src/lib/util/clLeaderboard.ts @@ -1,55 +1,64 @@ -import type { UserEvent } from '@prisma/client'; - +import { stringMatches } from '@oldschoolgg/toolkit'; import { userEventsToMap } from './userEvents'; -export async function fetchCLLeaderboard({ - ironmenOnly, - items, - resultLimit, - method = 'cl_array', - userEvents -}: { - ironmenOnly: boolean; - items: number[]; - resultLimit: number; - method?: 'cl_array' | 'raw_cl'; - userEvents: UserEvent[] | null; -}) { - const userEventMap = userEventsToMap(userEvents); - const userIds = Array.from(userEventMap.keys()); - if (method === 'cl_array') { - const userIdsList = userIds.length > 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(cl_array) - CARDINALITY(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(cl_array) - CARDINALITY(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(', ')}] - ${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'; + + 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' : ''}) ${userIds.length > 0 ? `OR id IN (${userIdsList})` : ''} + ) AS subquery +ORDER BY qty DESC +LIMIT ${resultLimit}; +`; + return prisma.$queryRawUnsafe<{ id: string; qty: number }[]>(query); + }) + ]); - 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,32 +71,28 @@ 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]; } export async function fetchTameCLLeaderboard({ items, resultLimit }: { items: number[]; resultLimit: number }) { diff --git a/src/mahoji/commands/admin.ts b/src/mahoji/commands/admin.ts index 952ccb0a65..0dfaf11bfd 100644 --- a/src/mahoji/commands/admin.ts +++ b/src/mahoji/commands/admin.ts @@ -506,11 +506,6 @@ export const adminCommand: OSBMahojiCommand = { } ] }, - { - type: ApplicationCommandOptionType.Subcommand, - name: 'sync_roles', - description: 'Sync roles' - }, { type: ApplicationCommandOptionType.Subcommand, name: 'badges', @@ -745,7 +740,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 }; @@ -780,20 +774,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)) { @@ -967,6 +947,7 @@ export const adminCommand: OSBMahojiCommand = { ${META_CONSTANTS.RENDERED_STR}` }).catch(noOp); import('exit-hook').then(({ gracefulExit }) => gracefulExit(1)); + return 'Turning off...'; } if (options.shut_down) { debugLog('SHUTTING DOWN'); @@ -976,13 +957,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 'Turning off...'; } if (options.sync_blacklist) { @@ -1087,7 +1069,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 5ebfbfb68a..9404f0ccd4 100644 --- a/src/mahoji/commands/bingo.ts +++ b/src/mahoji/commands/bingo.ts @@ -674,7 +674,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); @@ -712,9 +712,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 @@ -913,7 +913,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/hunt.ts b/src/mahoji/commands/hunt.ts index 57c79fadf6..335ee8b099 100644 --- a/src/mahoji/commands/hunt.ts +++ b/src/mahoji/commands/hunt.ts @@ -431,8 +431,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 (messages.length > 0) { diff --git a/src/mahoji/commands/laps.ts b/src/mahoji/commands/laps.ts index 43ce63e912..3278ac813a 100644 --- a/src/mahoji/commands/laps.ts +++ b/src/mahoji/commands/laps.ts @@ -140,7 +140,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.`; } if (course.name === 'Daemonheim Rooftop Course' && !user.bitfield.includes(BitField.HasDaemonheimAgilityPass)) { diff --git a/src/mahoji/commands/leaderboard.ts b/src/mahoji/commands/leaderboard.ts index a571c7b19e..dc57ab4c4f 100644 --- a/src/mahoji/commands/leaderboard.ts +++ b/src/mahoji/commands/leaderboard.ts @@ -357,7 +357,7 @@ 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."; } inputType = toTitleCase(inputType.toLowerCase()); @@ -380,17 +380,7 @@ async function clLb( return lbMsg(`${inputType} Tame Collection Log Leaderboard`); } - 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({ @@ -893,7 +883,7 @@ async function leaguesPointsLeaderboard(interaction: ChatInputCommandInteraction } async function leastCompletedLeagueTasksLb() { - const taskCounts = await roboChimpClient.$queryRaw<{ task_id: number; qty: number }[]>`SELECT task_id, count(*) AS qty + const taskCounts = await roboChimpClient.$queryRaw<{ task_id: number; qty: number }[]>`SELECT task_id, count(*)::int AS qty FROM ( SELECT unnest(leagues_completed_tasks_ids) AS task_id FROM public.user diff --git a/src/mahoji/commands/mix.ts b/src/mahoji/commands/mix.ts index e885144ad4..b687d0602f 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 c86a40bd76..a4c2f7de3c 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/rp.ts b/src/mahoji/commands/rp.ts index 75a3c56612..bc8dfc9993 100644 --- a/src/mahoji/commands/rp.ts +++ b/src/mahoji/commands/rp.ts @@ -11,9 +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 { calculateCompCapeProgress } from '../../lib/bso/calculateCompCapeProgress'; -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'; @@ -22,14 +20,16 @@ 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 { isValidDiscordSnowflake, returnStringOrFile } from '../../lib/util'; +import { isValidDiscordSnowflake } from '../../lib/util'; 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'; @@ -45,7 +45,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!'; + } } ]; @@ -143,6 +150,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', @@ -151,51 +187,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: 'force_comp_update', - description: 'Force the top 100 completionist users to update their completion percentage.', - 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, @@ -540,15 +538,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?: {}; - force_comp_update?: {}; - view_all_items?: {}; - analytics_tick?: {}; - networth_sync?: {}; - redis_sync?: {}; - }; + action?: any; player?: { givetgb?: { user: MahojiUserOption }; viewbank?: { user: MahojiUserOption; json?: boolean }; @@ -642,74 +632,19 @@ Date: ${dateFm(date)}`; if (!isMod) return randArrItem(gifs); - if (!guildID || (production && guildID.toString() !== SupportServer)) return randArrItem(gifs); - - if (!isMod) return randArrItem(gifs); - // Mod+ only commands: - 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?.force_comp_update) { - const usersToUpdate = await prisma.userStats.findMany({ - where: { - untrimmed_comp_cape_percent: { - not: null - } - }, - orderBy: { - untrimmed_comp_cape_percent: 'desc' - }, - take: 100 - }); - for (const user of usersToUpdate) { - await calculateCompCapeProgress(await mUserFetch(user.user_id.toString())); - } - return 'Done.'; - } - - 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 + if (options.action) { + for (const action of actions) { + if (options.action[action.name]) { + if (!action.allowed(adminUser)) return randArrItem(gifs); + try { + const result = await action.run(); + return result; + } catch (err) { + logError(err); + return 'An error occurred.'; } - }, - take: 20, - orderBy: { - GP: 'desc' - }, - select: { - id: true } - }); - 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/src/mahoji/commands/smelt.ts b/src/mahoji/commands/smelt.ts index 5637c17333..05f7de0480 100644 --- a/src/mahoji/commands/smelt.ts +++ b/src/mahoji/commands/smelt.ts @@ -164,7 +164,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 bab255868c..d4a16a7b83 100644 --- a/src/mahoji/commands/steal.ts +++ b/src/mahoji/commands/steal.ts @@ -68,7 +68,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/autoSlayCommand.ts b/src/mahoji/lib/abstracted_commands/autoSlayCommand.ts index 690da67cc3..dadfb684e1 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/bankBgCommand.ts b/src/mahoji/lib/abstracted_commands/bankBgCommand.ts index 2948b8b2bf..15540ea346 100644 --- a/src/mahoji/lib/abstracted_commands/bankBgCommand.ts +++ b/src/mahoji/lib/abstracted_commands/bankBgCommand.ts @@ -39,7 +39,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 b8a0d38e24..2584d5bb98 100644 --- a/src/mahoji/lib/abstracted_commands/coxCommand.ts +++ b/src/mahoji/lib/abstracted_commands/coxCommand.ts @@ -101,7 +101,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 ba4137eb5d..e022d70b6b 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 21b03ff192..ea6175aa7e 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 ( diff --git a/src/mahoji/lib/abstracted_commands/fightCavesCommand.ts b/src/mahoji/lib/abstracted_commands/fightCavesCommand.ts index 50691facbf..a306e7dea3 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 b7a5e53b81..2c5e40bd0a 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 7648cc6db1..c1f1d96fee 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 ffe7ac84fd..a077a6016e 100644 --- a/src/mahoji/lib/abstracted_commands/gnomeRestaurantCommand.ts +++ b/src/mahoji/lib/abstracted_commands/gnomeRestaurantCommand.ts @@ -19,7 +19,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 cb1251b620..55f83f0aae 100644 --- a/src/mahoji/lib/abstracted_commands/infernoCommand.ts +++ b/src/mahoji/lib/abstracted_commands/infernoCommand.ts @@ -330,7 +330,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!'; } if (isEmergedZuk) { if (!['Dragon dart', 'Rune dart', 'Amethyst dart'].includes(dartItem.name)) { @@ -724,7 +724,7 @@ ${emergedZukDeathMsg} { 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/lampCommand.ts b/src/mahoji/lib/abstracted_commands/lampCommand.ts index eb81771dab..d7e788bbf7 100644 --- a/src/mahoji/lib/abstracted_commands/lampCommand.ts +++ b/src/mahoji/lib/abstracted_commands/lampCommand.ts @@ -116,13 +116,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, @@ -181,6 +180,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, diff --git a/src/mahoji/lib/abstracted_commands/lmsCommand.ts b/src/mahoji/lib/abstracted_commands/lmsCommand.ts index 99a3c8820e..2284cb31f6 100644 --- a/src/mahoji/lib/abstracted_commands/lmsCommand.ts +++ b/src/mahoji/lib/abstracted_commands/lmsCommand.ts @@ -44,7 +44,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/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 { diff --git a/src/mahoji/lib/abstracted_commands/minionKill.ts b/src/mahoji/lib/abstracted_commands/minionKill.ts index 528a8719e5..c4c7f1c651 100644 --- a/src/mahoji/lib/abstracted_commands/minionKill.ts +++ b/src/mahoji/lib/abstracted_commands/minionKill.ts @@ -53,7 +53,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 { addStatsOfItemsTogether } from '../../../lib/structures/Gear'; import type { Peak } from '../../../lib/tickers'; import type { MonsterActivityTaskOptions } from '../../../lib/types/minions'; @@ -163,7 +163,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 { @@ -253,17 +253,16 @@ export async function minionKillCommand( // Add jelly & bloodveld check as can barrage 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 }); @@ -315,7 +314,7 @@ export async function minionKillCommand( const [, osjsMon, attackStyles] = resolveAttackStyles(user, { monsterID: monster.id, - boostMethod: boostChoice + boostMethod: combatMethods }); const [newTime, skillBoostMsg] = applySkillBoost(user, timeToFinish, attackStyles); @@ -544,10 +543,10 @@ 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)}`; } @@ -555,15 +554,16 @@ export async function minionKillCommand( const { canAfford } = await canAffordInventionBoost(user, InventionID.SuperiorDwarfMultiCannon, timeToFinish); const canAffordSuperiorCannonBoost = hasSuperiorCannon ? canAfford : false; - if (boostChoice === '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; @@ -574,8 +574,9 @@ export async function minionKillCommand( } } - if ((method === 'burst' || method === 'barrage') && !monster!.canBarrage) { - if (jelly || bloodveld) { + // Burst/barrage check with wilderness conditions + if ((method === 'burst' || method === 'barrage') && !monster?.canBarrage) { + if (jelly) { if (!isInWilderness) { return `${monster.name} can only be barraged or burst in the wilderness.`; } @@ -589,7 +590,7 @@ export async function minionKillCommand( } if ( - boostChoice === 'cannon' && + combatMethods.includes('barrage') && !user.user.disabled_inventions.includes(InventionID.SuperiorDwarfMultiCannon) && canAffordSuperiorCannonBoost && (monster.canCannon || monster.cannonMulti) @@ -608,7 +609,7 @@ export async function minionKillCommand( boosts.push(`${boost}% for Superior Cannon (${res.messages})`); } } else if ( - boostChoice === 'barrage' && + combatMethods.includes('barrage') && attackStyles.includes(SkillsEnum.Magic) && (monster!.canBarrage || wildyBurst) ) { @@ -618,7 +619,7 @@ export async function minionKillCommand( 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) ) { @@ -627,13 +628,13 @@ 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) { + } else 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); diff --git a/src/mahoji/lib/abstracted_commands/nightmareCommand.ts b/src/mahoji/lib/abstracted_commands/nightmareCommand.ts index 17963a9cd0..22ffadca96 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 90812094bc..9296258246 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 7b9556272a..f4936eeca4 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 5cfa5a41af..38c7d936ff 100644 --- a/src/mahoji/lib/abstracted_commands/tobCommand.ts +++ b/src/mahoji/lib/abstracted_commands/tobCommand.ts @@ -189,11 +189,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) { @@ -225,7 +225,7 @@ async function checkTOBUser( if (!user.hasEquipped('Chincannon') && 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.` ]; } @@ -233,7 +233,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 at least an Infernal cape to do Hard mode.`]; @@ -341,7 +341,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 428a7a5aa6..6e74620c8d 100644 --- a/src/mahoji/lib/abstracted_commands/trekCommand.ts +++ b/src/mahoji/lib/abstracted_commands/trekCommand.ts @@ -52,7 +52,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!] }.`; } @@ -61,7 +61,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 bfd0011eb7..540aaecac9 100644 --- a/src/mahoji/lib/abstracted_commands/volcanicMineCommand.ts +++ b/src/mahoji/lib/abstracted_commands/volcanicMineCommand.ts @@ -239,6 +239,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 37242b6ba8..b7a1857ea8 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 f6b59d07cb..cdae187d7a 100644 --- a/src/mahoji/mahojiSettings.ts +++ b/src/mahoji/mahojiSettings.ts @@ -298,7 +298,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/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 5137180411..35035f2b51 100644 --- a/src/tasks/minions/minigames/fightCavesActivity.ts +++ b/src/tasks/minions/minigames/fightCavesActivity.ts @@ -36,7 +36,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); @@ -82,7 +82,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, @@ -118,7 +118,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, @@ -158,8 +158,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) { @@ -192,7 +192,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/miningActivity.ts b/src/tasks/minions/miningActivity.ts index 2f93938b93..4b5c42d16e 100644 --- a/src/tasks/minions/miningActivity.ts +++ b/src/tasks/minions/miningActivity.ts @@ -251,6 +251,7 @@ export const miningTask: MinionTask = { async run(data: MiningActivityTaskOptions) { const { oreID, userID, channelID, duration, powermine } = data; const { quantity } = data; + const minutes = Math.round(duration / Time.Minute); const user = await mUserFetch(userID); const ore = Mining.Ores.find(ore => ore.id === oreID)!; @@ -264,7 +265,7 @@ export const miningTask: MinionTask = { ? await chargePortentIfHasCharges({ user, portentID: PortentID.MiningPortent, - charges: amountOfSpiritsToUse + charges: minutes }) : null; const { 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 + }); +}); 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/redis.test.ts b/tests/integration/redis.test.ts index e4c2fec1e7..86d1444893 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/tests/integration/rolesTask.test.ts b/tests/integration/rolesTask.test.ts index c3c3c12472..94cfb8ec43 100644 --- a/tests/integration/rolesTask.test.ts +++ b/tests/integration/rolesTask.test.ts @@ -49,7 +49,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'); }); diff --git a/yarn.lock b/yarn.lock index be7b9c09c5..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 @@ -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" @@ -4113,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" @@ -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"