From 260d46ece636566986137562cf95ca05a711b1b6 Mon Sep 17 00:00:00 2001 From: GC <30398469+gc@users.noreply.github.com> Date: Mon, 18 Nov 2024 16:34:08 +1100 Subject: [PATCH] Add test / fix degradeable items (#6195) --- src/lib/data/cox.ts | 460 ++++++++++++------ .../lib/abstracted_commands/coxCommand.ts | 170 ++++--- tests/integration/pvm/cox.test.ts | 59 +++ 3 files changed, 467 insertions(+), 222 deletions(-) create mode 100644 tests/integration/pvm/cox.test.ts diff --git a/src/lib/data/cox.ts b/src/lib/data/cox.ts index f64ff0f795..8e5a76595a 100644 --- a/src/lib/data/cox.ts +++ b/src/lib/data/cox.ts @@ -13,14 +13,15 @@ import { Bank, type Item } from 'oldschooljs'; import type { ChambersOfXericOptions } from 'oldschooljs/dist/simulation/misc/ChambersOfXeric'; import { checkUserCanUseDegradeableItem } from '../degradeableItems'; -import type { GearStats } from '../gear'; -import { inventionBoosts } from '../invention/inventions'; +import type { GearStats } from '../gear/types'; +import { getMinigameScore } from '../settings/minigames'; import { SkillsEnum } from '../skilling/types'; import { Gear, constructGearSetup } from '../structures/Gear'; import type { Skills } from '../types'; -import { randomVariation } from '../util'; +import { itemID, itemNameFromID, randomVariation, resolveItems } from '../util'; import getOSItem from '../util/getOSItem'; import { logError } from '../util/logError'; +import { getSimilarItems } from './similarItems'; const bareMinStats: Skills = { attack: 80, @@ -31,10 +32,44 @@ const bareMinStats: Skills = { prayer: 70 }; -export const SANGUINESTI_CHARGES_PER_COX = 150; -export const SHADOW_CHARGES_PER_COX = 130; -export const TENTACLE_CHARGES_PER_COX = 200; -export const VOID_STAFF_CHARGES_PER_COX = 15; +export const coxUniques = [ + 'Dexterous prayer scroll', + 'Arcane prayer scroll', + 'Twisted buckler', + 'Dragon hunter crossbow', + "Dinh's bulwark", + 'Ancestral hat', + 'Ancestral robe top', + 'Ancestral robe bottom', + 'Dragon claws', + 'Elder maul', + 'Kodai insignia', + 'Twisted bow' +]; + +export const coxCMUniques = ['Metamorphic dust', 'Twisted ancestral colour kit']; + +const SCYTHE_CHARGERS_PER_COX = 100; +const SHADOW_CHARGES_PER_COX = 120; +const SANGUINESTI_CHARGES_PER_COX = 160; +const TENTACLE_CHARGES_PER_COX = 200; + +const REQUIRED_BOW = resolveItems(['Twisted bow', 'Bow of faerdhinen (c)', 'Magic shortbow']); +const REQUIRED_ARROWS = resolveItems(['Dragon arrow', 'Amethyst arrow', 'Rune arrow', 'Adamant arrow']); +const BOW_ARROWS_NEEDED = 150; +const REQUIRED_CROSSBOW = resolveItems([ + 'Zaryte crossbow', + 'Dragon hunter crossbow', + 'Armadyl crossbow', + 'rune crossbow' +]); +const REQUIRED_BOLTS = resolveItems([ + 'Ruby dragon bolts (e)', + 'Diamond dragon bolts (e)', + 'Dragon bolts', + 'Runite bolts' +]); +const CROSSBOW_BOLTS_NEEDED = 150; export function hasMinRaidsRequirements(user: MUser) { return user.hasSkillReqs(bareMinStats); @@ -74,7 +109,7 @@ export async function createTeam( deathChance -= calcPercentOfNum(total, 10); } - const kc = (await u.fetchMinigames())[cm ? 'raids_challenge_mode' : 'raids']; + const kc = await getMinigameScore(u.id, cm ? 'raids_challenge_mode' : 'raids'); const kcChange = kcPointsEffect(kc); if (kcChange < 0) points = reduceNumByPercent(points, Math.abs(kcChange)); else points = increaseNumByPercent(points, kcChange); @@ -130,7 +165,7 @@ export async function createTeam( return res; } -export function calcSetupPercent( +function calcSetupPercent( maxStats: GearStats, userStats: GearStats, heavyPenalizeStat: keyof GearStats, @@ -160,47 +195,46 @@ export function calcSetupPercent( return totalPercent; } -export const maxMageGear = constructGearSetup({ - head: 'Virtus mask', - body: 'Virtus robe top', - hands: 'Virtus gloves', - legs: 'Virtus robe legs', - feet: 'Virtus boots', - cape: 'Vasa cloak', - neck: 'Arcane blast necklace', - weapon: 'Virtus wand', - shield: 'Virtus book', - ring: 'Seers ring(i)' +// BIS CoX gear: https://oldschool.runescape.wiki/w/Chambers_of_Xeric/Strategies#Melee +export const COXMaxMageGear = constructGearSetup({ + head: 'Ancestral hat', + neck: 'Occult necklace', + body: 'Ancestral robe top', + cape: 'Imbued saradomin cape', + hands: 'Tormented bracelet', + legs: 'Ancestral robe bottom', + feet: 'Eternal boots', + '2h': "Tumeken's shadow", + ring: 'Magus ring' }); -const maxMage = new Gear(maxMageGear); - -export const maxRangeGear = constructGearSetup({ - head: 'Pernix cowl', - neck: 'Farsight snapshot necklace', - body: 'Pernix body', - cape: 'Tidal collector', - hands: 'Pernix gloves', - legs: 'Pernix chaps', - feet: 'Pernix boots', +const maxMage = new Gear(COXMaxMageGear); + +export const COXMaxRangeGear = constructGearSetup({ + head: 'Masori mask(f)', + neck: 'Necklace of anguish', + body: 'Masori Body (f)', + cape: "Blessed dizana's quiver", + hands: 'Zaryte vambraces', + legs: 'Masori chaps (f)', + feet: 'Pegasian boots', '2h': 'Twisted bow', - ring: 'Ring of piercing(i)', + ring: 'Venator ring', ammo: 'Dragon arrow' }); -const maxRange = new Gear(maxRangeGear); +const maxRange = new Gear(COXMaxRangeGear); -export const maxMeleeGear = constructGearSetup({ +export const COXMaxMeleeGear = constructGearSetup({ head: 'Torva full helm', - neck: "Brawler's hook necklace", + neck: 'Amulet of torture', body: 'Torva platebody', - cape: 'TzKal cape', - hands: 'Torva gloves', + cape: 'Infernal cape', + hands: 'Ferocious gloves', legs: 'Torva platelegs', - feet: 'Torva boots', - weapon: 'Drygore rapier', - shield: 'Offhand drygore rapier', - ring: 'Ignis ring(i)' + feet: 'Primordial boots', + '2h': 'Scythe of vitur', + ring: 'Ultor ring' }); -const maxMelee = new Gear(maxMeleeGear); +const maxMelee = new Gear(COXMaxMeleeGear); export function calculateUserGearPercents(user: MUser) { const melee = calcSetupPercent( @@ -270,11 +304,12 @@ export async function checkCoxTeam(users: MUser[], cm: boolean, quantity = 1): P if ( users.length > 1 && !user.hasEquippedOrInBank('Dragon hunter crossbow') && - !['Twisted bow', 'Zaryte bow', 'Bow of faerdhinen (c)'].some(i => user.hasEquippedOrInBank(i)) + !user.hasEquippedOrInBank('Twisted bow') && + !user.hasEquipped(['Bow of faerdhinen (c)', 'Crystal helm', 'Crystal legs', 'Crystal body'], true) ) { - return `${user.usernameOrMention} doesn't own a Twisted bow, Zaryte bow, Bow of faerdhinen (c) or Dragon hunter crossbow, which is required for Challenge Mode.`; + return `${user.usernameOrMention} doesn't own a Twisted bow, Bow of faerdhinen (c) or Dragon hunter crossbow, which is required for Challenge Mode.`; } - const kc = (await user.fetchMinigames()).raids; + const kc = await getMinigameScore(user.id, 'raids'); if (kc < 200) { return `${user.usernameOrMention} doesn't have the 200 KC required for Challenge Mode.`; } @@ -282,10 +317,42 @@ export async function checkCoxTeam(users: MUser[], cm: boolean, quantity = 1): P if (user.minionIsBusy) { return `${user.usernameOrMention}'s minion is already doing an activity and cannot join.`; } + + // Range weapon/ammo check + const rangeAmmo = user.gear.range.ammo; + const rangeWeapon = user.gear.range.equippedWeapon(); + const arrowsNeeded = BOW_ARROWS_NEEDED * quantity; + const boltsNeeded = CROSSBOW_BOLTS_NEEDED * quantity; + if (!rangeWeapon) return `<@${user.id}> Where is your range weapon?`; + if (rangeWeapon?.id) { + if (rangeWeapon.id !== itemID('Bow of faerdhinen (c)')) { + if (REQUIRED_BOW.includes(rangeWeapon.id)) { + if (!rangeAmmo || rangeAmmo.quantity < arrowsNeeded || !REQUIRED_ARROWS.includes(rangeAmmo.item)) { + return `<@${user.id}> needs ${arrowsNeeded} of one of these arrows equipped: ${REQUIRED_ARROWS.map(itemNameFromID).join(', ')}.`; + } + } else if (REQUIRED_CROSSBOW.includes(rangeWeapon.id)) { + if (!rangeAmmo || rangeAmmo.quantity < boltsNeeded || !REQUIRED_BOLTS.includes(rangeAmmo.item)) { + return `<@${user.id}> needs ${boltsNeeded} of ones of these bolts equipped: ${REQUIRED_BOLTS.map(itemNameFromID).join(', ')}.`; + } + } + } + } + + // Charge weapons check + if (user.gear.melee.hasEquipped('Scythe of vitur')) { + const scytheResult = checkUserCanUseDegradeableItem({ + item: getOSItem('Scythe of vitur'), + chargesToDegrade: SCYTHE_CHARGERS_PER_COX * quantity, + user + }); + if (!scytheResult.hasEnough) { + return scytheResult.userMessage; + } + } if (user.gear.melee.hasEquipped('Abyssal tentacle')) { const tentacleResult = checkUserCanUseDegradeableItem({ item: getOSItem('Abyssal tentacle'), - chargesToDegrade: TENTACLE_CHARGES_PER_COX, + chargesToDegrade: TENTACLE_CHARGES_PER_COX * quantity, user }); if (!tentacleResult.hasEnough) { @@ -295,27 +362,17 @@ export async function checkCoxTeam(users: MUser[], cm: boolean, quantity = 1): P if (user.gear.mage.hasEquipped('Sanguinesti staff')) { const sangResult = checkUserCanUseDegradeableItem({ item: getOSItem('Sanguinesti staff'), - chargesToDegrade: SANGUINESTI_CHARGES_PER_COX, + chargesToDegrade: SANGUINESTI_CHARGES_PER_COX * quantity, user }); if (!sangResult.hasEnough) { return sangResult.userMessage; } } - if (user.gear.mage.hasEquipped('Void staff')) { - const voidStaffResult = checkUserCanUseDegradeableItem({ - item: getOSItem('Void staff'), - chargesToDegrade: VOID_STAFF_CHARGES_PER_COX, - user - }); - if (!voidStaffResult.hasEnough) { - return voidStaffResult.userMessage; - } - } if (user.gear.mage.hasEquipped("Tumeken's shadow")) { const shadowResult = checkUserCanUseDegradeableItem({ item: getOSItem("Tumeken's shadow"), - chargesToDegrade: SHADOW_CHARGES_PER_COX, + chargesToDegrade: SHADOW_CHARGES_PER_COX * quantity, user }); if (!shadowResult.hasEnough) { @@ -346,23 +403,17 @@ function calcPerc(perc: number, num: number) { function teamSizeBoostPercent(size: number) { switch (size) { case 1: - return -10; + return -6; case 2: - return 12; + return 7; case 3: return 13; case 4: return 18; case 5: return 23; - case 6: - return 26; - case 7: - return 29; - case 8: - return 33; default: - return 35; + return 23; } } @@ -370,114 +421,192 @@ interface ItemBoost { item: Item; boost: number; mustBeEquipped: boolean; - setup?: 'mage' | 'range' | 'melee'; + setup?: 'melee' | 'mage' | 'range'; mustBeCharged?: boolean; requiredCharges?: number; } -const itemBoosts: ItemBoost[][] = [ +export const itemBoosts: ItemBoost[][] = [ [ + // melee weapon boost { - item: getOSItem('Twisted bow'), - boost: 7, - mustBeEquipped: false + item: getOSItem('Scythe of vitur'), + boost: 8, + mustBeEquipped: true, + setup: 'melee', + mustBeCharged: true, + requiredCharges: SCYTHE_CHARGERS_PER_COX }, { - item: getOSItem('Bow of faerdhinen (c)'), - boost: 6, - mustBeEquipped: false + item: getOSItem('Dragon hunter lance'), + boost: 5, + mustBeEquipped: true, + setup: 'melee' }, { - item: getOSItem('Dragon hunter crossbow'), - boost: 5, - mustBeEquipped: false + item: getOSItem('Soulreaper axe'), + boost: 4, + mustBeEquipped: true, + setup: 'melee' + }, + { + item: getOSItem("Osmumten's fang"), + boost: 3, + mustBeEquipped: true, + setup: 'melee' + }, + { + item: getOSItem('Abyssal tentacle'), + boost: 2, + mustBeEquipped: true, + setup: 'melee', + mustBeCharged: true, + requiredCharges: TENTACLE_CHARGES_PER_COX } ], [ + // Range weapon boost { - item: getOSItem('Dragon warhammer'), - boost: 3, - mustBeEquipped: false + item: getOSItem('Twisted bow'), + boost: 8, + mustBeEquipped: true, + setup: 'range' }, { - item: getOSItem('Bandos godsword'), - boost: 2.5, - mustBeEquipped: false + item: getOSItem('Bow of faerdhinen (c)'), + boost: 5, + mustBeEquipped: true, + setup: 'range' }, { - item: getOSItem('Bandos godsword (or)'), - boost: 2.5, - mustBeEquipped: false + item: getOSItem('Dragon hunter crossbow'), + boost: 4, + mustBeEquipped: true, + setup: 'range' + }, + { + item: getOSItem('Zaryte crossbow'), + boost: 3, + mustBeEquipped: true, + setup: 'range' } ], [ + // range ammo boost { - item: getOSItem('Drygore rapier'), - boost: 8, - mustBeEquipped: true + item: getOSItem('Dragon arrow'), + boost: 3, + mustBeEquipped: true, + setup: 'range' }, { - item: getOSItem('Dragon hunter lance'), - boost: 3, - mustBeEquipped: false + item: getOSItem('Ruby dragon bolts (e)'), + boost: 2, + mustBeEquipped: true, + setup: 'range' }, { - item: getOSItem('Abyssal tentacle'), + item: getOSItem('Diamond dragon bolts (e)'), boost: 2, - mustBeEquipped: false, - mustBeCharged: true, - requiredCharges: TENTACLE_CHARGES_PER_COX - } - ], - [ + mustBeEquipped: true, + setup: 'range' + }, { - item: getOSItem('Void staff'), - boost: 9, + item: getOSItem('Amethyst arrow'), + boost: 1, mustBeEquipped: true, - setup: 'mage', - mustBeCharged: true, - requiredCharges: VOID_STAFF_CHARGES_PER_COX + setup: 'range' + }, + { + item: getOSItem('Ruby dragon bolts'), + boost: 1, + mustBeEquipped: true, + setup: 'range' }, + { + item: getOSItem('Diamond dragon bolts'), + boost: 1, + mustBeEquipped: true, + setup: 'range' + }, + { + item: getOSItem('Dragon bolts'), + boost: 1, + mustBeEquipped: true, + setup: 'range' + } + ], + [ + // mage weapon boost { item: getOSItem("Tumeken's shadow"), boost: 8, - mustBeEquipped: false, + mustBeEquipped: true, setup: 'mage', mustBeCharged: true, requiredCharges: SHADOW_CHARGES_PER_COX }, { item: getOSItem('Sanguinesti staff'), - boost: 7, - mustBeEquipped: false, + boost: 4, + mustBeEquipped: true, setup: 'mage', mustBeCharged: true, requiredCharges: SANGUINESTI_CHARGES_PER_COX } ], [ + // defense reduction weapon boost { - item: getOSItem('Offhand spidergore rapier'), - boost: 6.5, - mustBeEquipped: true, - setup: 'melee' + item: getOSItem('Elder maul'), + boost: 5, + mustBeEquipped: false }, { - item: getOSItem('Offhand drygore rapier'), - boost: 4, - mustBeEquipped: true, - setup: 'melee' + item: getOSItem('Dragon warhammer'), + boost: 3, + mustBeEquipped: false + }, + { + item: getOSItem('Bandos godsword'), + boost: 2.5, + mustBeEquipped: false + } + ], + [ + // zaryte crossbow spec weapon + { + item: getOSItem('Zaryte crossbow'), + boost: 3, + mustBeEquipped: false + } + ], + [ + // lightbearer increases spec + { + item: getOSItem('Lightbearer'), + boost: 2, + mustBeEquipped: false + } + ], + [ + // pickaxe boost + { + item: getOSItem('Dragon pickaxe'), + boost: 1, + mustBeEquipped: false } ] ]; -const speedReductionForGear = 16; -const speedReductionForKC = 40; +const speedReductionForGear = 14; +const speedReductionForKC = 28; -const maxSpeedReductionFromItems = itemBoosts.reduce( +export const maxSpeedReductionFromItems = itemBoosts.reduce( (sum, items) => sum + Math.max(...items.map(item => item.boost)), 0 ); + const maxSpeedReductionUser = speedReductionForGear + speedReductionForKC + maxSpeedReductionFromItems; const baseDuration = Time.Minute * 83; @@ -491,20 +620,21 @@ export async function calcCoxDuration( duration: number; maxUserReduction: number; degradeables: { item: Item; user: MUser; chargesToDegrade: number }[]; - chinCannonUser: MUser | null; }> { const team = shuffleArr(_team).slice(0, 9); const size = team.length; let totalReduction = 0; - const reductions: Record = {}; - // Track degradeable items: + // Track degradeable items (fakemass works properly with this code, it wont remove 5x charges): const degradeableItems: { item: Item; user: MUser; chargesToDegrade: number }[] = []; + const uniqueUsers = new Map(); for (const u of team) { let userPercentChange = 0; + const isUserReal = !uniqueUsers.has(u.id); + uniqueUsers.set(u.id, true); // Reduce time for gear const { total } = calculateUserGearPercents(u); @@ -516,10 +646,11 @@ export async function calcCoxDuration( userPercentChange += calcPerc(kcPercent, speedReductionForKC); // Reduce time for item boosts - itemBoosts.forEach(set => { + for (const set of itemBoosts) { for (const item of set) { - if (item.mustBeCharged && item.requiredCharges) { - if (u.hasEquippedOrInBank(item.item.id)) { + const simItems = getSimilarItems(item.item.id); + if (item.mustBeEquipped && item.mustBeCharged && item.requiredCharges) { + if (u.hasEquipped(simItems)) { const testItem = { item: item.item, user: u, @@ -528,24 +659,38 @@ export async function calcCoxDuration( const canDegrade = checkUserCanUseDegradeableItem(testItem); if (canDegrade.hasEnough) { userPercentChange += item.boost; - degradeableItems.push(testItem); + if (isUserReal) degradeableItems.push(testItem); + break; + } + } + } else if (item.mustBeCharged && item.requiredCharges) { + if (u.hasEquippedOrInBank(simItems)) { + const testItem = { + item: item.item, + user: u, + chargesToDegrade: item.requiredCharges + }; + const canDegrade = checkUserCanUseDegradeableItem(testItem); + if (canDegrade.hasEnough) { + userPercentChange += item.boost; + if (isUserReal) degradeableItems.push(testItem); break; } } } else if (item.mustBeEquipped) { - if (item.setup && u.gear[item.setup].hasEquipped(item.item.id)) { + if (item.setup && u.gear[item.setup].hasEquipped(simItems)) { userPercentChange += item.boost; break; - } else if (!item.setup && u.hasEquipped(item.item.id)) { + } else if (!item.setup && u.hasEquipped(simItems)) { userPercentChange += item.boost; break; } - } else if (u.hasEquippedOrInBank(item.item.id)) { + } else if (u.hasEquippedOrInBank(simItems)) { userPercentChange += item.boost; break; } } - }); + } const perc = Math.min(100, userPercentChange / size); @@ -556,43 +701,40 @@ export async function calcCoxDuration( if (challengeMode) { duration = baseCmDuration; - duration = reduceNumByPercent(duration, totalReduction / 1.3); + duration = reduceNumByPercent(duration, totalReduction / 1.05); } else { duration = reduceNumByPercent(duration, totalReduction); } duration -= duration * (teamSizeBoostPercent(size) / 100); - let chinCannonUser: MUser | null = null; + return { duration, reductions, maxUserReduction: maxSpeedReductionUser / size, degradeables: degradeableItems }; +} - for (const u of team) { - if (u.gear.range.hasEquipped('Chincannon')) { - duration = reduceNumByPercent(duration, inventionBoosts.chincannon.coxPercentReduction); - chinCannonUser = u; - break; +export async function calcCoxInput(u: MUser, quantity: number, solo: boolean) { + const supplies = new Bank(); + const ammo = new Bank(); + for (let i = 0; i < quantity; i++) { + const kc = await getMinigameScore(u.id, 'raids'); + supplies.add('Stamina potion(4)', solo ? 2 : 1); + + let brewsNeeded = Math.max(1, 8 - Math.max(1, Math.ceil((kc + 1) / 30))); + if (solo) brewsNeeded++; + const restoresNeeded = Math.max(1, Math.floor(brewsNeeded / 3)); + + supplies.add('Saradomin brew(4)', brewsNeeded); + supplies.add('Super restore(4)', restoresNeeded); + + // get ammo usage (checkCoxTeam() handles checking the proper amount and correct ammo type) + const rangeAmmo = u.gear.range.ammo; + const rangeWeapon = u.gear.range.equippedWeapon(); + if (rangeWeapon?.id !== itemID('Bow of faerdhinen (c)') && rangeWeapon && rangeAmmo) { + if (REQUIRED_BOW.includes(rangeWeapon.id)) { + ammo.add(rangeAmmo.item, BOW_ARROWS_NEEDED); + } else if (REQUIRED_CROSSBOW.includes(rangeWeapon.id)) { + ammo.add(rangeAmmo.item, CROSSBOW_BOLTS_NEEDED); + } } } - - return { - duration, - reductions, - maxUserReduction: maxSpeedReductionUser / size, - degradeables: degradeableItems, - chinCannonUser - }; -} - -export async function calcCoxInput(u: MUser, solo: boolean) { - const items = new Bank(); - const kc = await u.fetchMinigames().then(stats => stats.raids); - items.add('Stamina potion(4)', solo ? 2 : 1); - - let brewsNeeded = Math.max(1, 8 - Math.max(1, Math.ceil((kc + 1) / 30))); - if (solo) brewsNeeded++; - const restoresNeeded = Math.max(1, Math.floor(brewsNeeded / 3)); - - items.add('Saradomin brew(4)', brewsNeeded); - items.add('Super restore(4)', restoresNeeded); - - return items; + return { supplies, ammo }; } diff --git a/src/mahoji/lib/abstracted_commands/coxCommand.ts b/src/mahoji/lib/abstracted_commands/coxCommand.ts index 2584d5bb98..93d24031f5 100644 --- a/src/mahoji/lib/abstracted_commands/coxCommand.ts +++ b/src/mahoji/lib/abstracted_commands/coxCommand.ts @@ -7,17 +7,15 @@ import { calcCoxInput, calculateUserGearPercents, checkCoxTeam, + coxUniques, createTeam, hasMinRaidsRequirements, + itemBoosts, + maxSpeedReductionFromItems, minimumCoxSuppliesNeeded } from '../../../lib/data/cox'; +import { getSimilarItems } from '../../../lib/data/similarItems'; import { degradeItem } from '../../../lib/degradeableItems'; -import { - InventionID, - canAffordInventionBoost, - inventionBoosts, - inventionItemBoost -} from '../../../lib/invention/inventions'; import { trackLoot } from '../../../lib/lootTrack'; import { setupParty } from '../../../lib/party'; import { getMinigameScore } from '../../../lib/settings/minigames'; @@ -29,20 +27,68 @@ import { calcMaxTripLength } from '../../../lib/util/calcMaxTripLength'; import { updateBankSetting } from '../../../lib/util/updateBankSetting'; import { mahojiParseNumber } from '../../mahojiSettings'; -const uniques = [ - 'Dexterous prayer scroll', - 'Arcane prayer scroll', - 'Twisted buckler', - 'Dragon hunter crossbow', - "Dinh's bulwark", - 'Ancestral hat', - 'Ancestral robe top', - 'Ancestral robe bottom', - 'Dragon claws', - 'Elder maul', - 'Kodai insignia', - 'Twisted bow' -]; +export async function coxBoostsCommand(user: MUser) { + const boostStr = []; + let workFromBank = false; + let boostPercent = 0; + boostStr.push('<:Twisted_bow:403018312402862081> Chambers of Xeric <:Olmlet:324127376873357316>\n'); + boostStr.push( + '*Item boosts help reduce the time required to complete Chambers. Only one boost from each bullet point can be applied. The further left the higher the boost.*\n\n' + ); + boostStr.push('**Equipped boost Items:**\n'); + for (const set of itemBoosts) { + if (set.some(item => !item.mustBeEquipped) && workFromBank === false) { + boostStr.push('**Works from bank:**\n'); + workFromBank = true; + } + boostStr.push('- '); + const ownedItems = set.filter(item => { + if (item.mustBeEquipped) { + if (item.setup && user.gear[item.setup].hasEquipped(item.item.id, false, true)) { + return true; + } + } else { + return user.hasEquippedOrInBank(getSimilarItems(item.item.id)); + } + }); + if (ownedItems.length > 0) { + const maxBoost = Math.max(...ownedItems.map(item => item.boost)); + const setItems = set.map(item => { + if (item.item.name === 'Dragon pickaxe') { + if (item.boost === maxBoost && ownedItems.some(ownedItem => ownedItem.item.id === item.item.id)) { + boostPercent += item.boost; + return `${Emoji.Tick}Pickaxe Boost (3a, Crystal, Dragon)`; + } else { + return `${Emoji.RedX}Pickaxe Boost (3a, Crystal, Dragon)`; + } + } else { + if (item.boost === maxBoost && ownedItems.some(ownedItem => ownedItem.item.id === item.item.id)) { + boostPercent += item.boost; + return `${Emoji.Tick}${item.item.name}`; + } else { + return `${Emoji.RedX}${item.item.name}`; + } + } + }); + boostStr.push(setItems.join(', ')); + } else { + const setItems = set.map(item => { + if (item.item.name === 'Dragon pickaxe') { + return `${Emoji.RedX}Pickaxe Boost (3a, Crystal, Dragon)`; + } else { + return `${Emoji.RedX}${item.item.name}`; + } + }); + boostStr.push(setItems.join(', ')); + } + boostStr.push('\n'); + } + const finalPercentage = ((boostPercent / maxSpeedReductionFromItems) * 100).toFixed(1); + boostStr.push( + `\nYour CoX Item Boost is: **${finalPercentage === '100.0' ? '100' : finalPercentage}/100%**\nEffectively lowering your raid time by: **${boostPercent} minutes**` + ); + return boostStr.join(''); +} export async function coxStatsCommand(user: MUser) { const [minigameScores, stats] = await Promise.all([ @@ -51,7 +97,7 @@ export async function coxStatsCommand(user: MUser) { ]); let totalUniques = 0; const { cl } = user; - for (const item of uniques) { + for (const item of coxUniques) { totalUniques += cl.amount(item); } const totalPoints = stats.total_cox_points; @@ -81,19 +127,20 @@ export async function coxStatsCommand(user: MUser) { **Melee:** <:Elder_maul:403018312247803906> ${melee.toFixed(1)}% **Range:** <:Twisted_bow:403018312402862081> ${range.toFixed(1)}% **Mage:** <:Kodai_insignia:403018312264712193> ${mage.toFixed(1)}% -**Total Gear Score:** ${Emoji.Gear} ${total.toFixed(1)}%`; +**Total Gear Score:** ${Emoji.Gear} ${total.toFixed(1)}%\n +Check \`/raid cox itemboosts\` for more information on Item boosts.`; } export async function coxCommand( channelID: string, user: MUser, - type: 'solo' | 'mass', + type: 'solo' | 'mass' | 'fakemass', maxSizeInput: number | undefined, isChallengeMode: boolean, _quantity?: number ) { - if (type !== 'mass' && type !== 'solo') { - return 'Specify your team setup for Chambers of Xeric, either solo or mass.'; + if (type !== 'mass' && type !== 'solo' && type !== 'fakemass') { + return 'Specify your team setup for Chambers of Xeric, either solo, mass, or mass (4 bots teammates).'; } const minigameID = isChallengeMode ? 'raids_challenge_mode' : 'raids'; @@ -145,7 +192,6 @@ export async function coxCommand( isChallengeMode && !user.hasEquippedOrInBank('Dragon hunter crossbow') && !user.hasEquippedOrInBank('Twisted bow') && - !user.hasEquippedOrInBank('Zaryte bow') && !user.hasEquipped(['Bow of faerdhinen (c)', 'Crystal helm', 'Crystal legs', 'Crystal body'], true) ) { return [ @@ -158,10 +204,16 @@ export async function coxCommand( } }; const channel = globalClient.channels.cache.get(channelID.toString()); - if (!channelIsSendable(channel)) return 'No channel found.'; let users: MUser[] = []; - if (type === 'mass') { + let isFakeMass = false; + + const fakeUsers = Math.min(maxSizeInput ?? 5, maxSize); + if (type === 'fakemass') { + users = new Array(fakeUsers).fill(user); + isFakeMass = true; + } else if (type === 'mass') { + if (!channelIsSendable(channel)) return 'No channel found.'; users = (await setupParty(channel, user, partyOptions)).filter(u => !u.minionIsBusy); } else { users = [user]; @@ -171,8 +223,7 @@ export async function coxCommand( duration: raidDuration, maxUserReduction, reductions, - degradeables, - chinCannonUser + degradeables } = await calcCoxDuration(users, isChallengeMode); const maxTripLength = calcMaxTripLength(user, 'Raids'); const maxCanDo = Math.max(Math.floor(maxTripLength / raidDuration), 1); @@ -183,53 +234,41 @@ export async function coxCommand( return `Your mass failed to start because of this reason: ${teamCheckFailure}`; } - // This gives a normal duration distribution. Better than (raidDuration * quantity) +/- 5% + // add variance to cox raid time const duration = sumArr( Array(quantity) .fill(raidDuration) .map(d => randomVariation(d, 5)) ); + let debugStr = ''; const isSolo = users.length === 1; - if (chinCannonUser) { - if (!canAffordInventionBoost(chinCannonUser, InventionID.ChinCannon, duration).canAfford) { - return `${chinCannonUser.usernameOrMention} doesn't have enough materials to use the Chincannon for this trip.`; - } + for (const d of degradeables) { + d.chargesToDegrade *= quantity; } - const totalCost = new Bank(); - await Promise.all( degradeables.map(async d => { await degradeItem(d); }) ); + const totalCost = new Bank(); + const usersToCheck = isFakeMass ? [users[0]] : users; + const costResult = await Promise.all([ - ...users.map(async u => { - const supplies = (await calcCoxInput(u, isSolo)).multiply(quantity); + ...usersToCheck.map(async u => { + const { supplies, ammo } = await calcCoxInput(u, quantity, isSolo); await u.removeItemsFromBank(supplies); totalCost.add(supplies); + const realAmmoCost = await u.specialRemoveItems(ammo); + totalCost.add(realAmmoCost.realCost); + supplies.add(realAmmoCost.realCost); const { total } = calculateUserGearPercents(u); - debugStr += `${u.usernameOrMention} (${Emoji.Gear}${total.toFixed(1)}% ${ Emoji.CombatSword - } ${calcWhatPercent(reductions[u.id], maxUserReduction).toFixed(1)}%) used ${supplies}`; - - if (chinCannonUser === u) { - const res = await inventionItemBoost({ - user, - inventionID: InventionID.ChinCannon, - duration - }); - if (!res.success) { - throw new Error(`${u.id} did not have enough charges to use the Chincannon.`); - } - debugStr += ` ${inventionBoosts.chincannon.coxPercentReduction}% speed increase from the Chincannon (${res.messages})`; - } - - debugStr += '\n'; + } ${calcWhatPercent(reductions[u.id], maxUserReduction).toFixed(1)}%) used ${supplies}\n`; return { userID: u.id, itemsRemoved: supplies @@ -256,21 +295,26 @@ export async function coxCommand( duration, type: 'Raids', leader: user.id, - users: users.map(u => u.id), + users: usersToCheck.map(u => u.id), challengeMode: isChallengeMode, - quantity, - cc: chinCannonUser?.id + maxSizeInput: isFakeMass ? fakeUsers : maxSize, + isFakeMass, + quantity }); let str = isSolo ? `${user.minionName} is now doing ${quantity > 1 ? quantity : 'a'} Chambers of Xeric raid${ quantity > 1 ? 's' : '' }. The total trip will take ${formatDuration(duration)}.` - : `${partyOptions.leader.usernameOrMention}'s party (${users - .map(u => u.usernameOrMention) - .join(', ')}) is now off to do ${quantity > 1 ? quantity : 'a'} Chambers of Xeric raid${ - quantity > 1 ? 's' : '' - } - the total trip will take ${formatDuration(duration)}.`; + : isFakeMass + ? `${partyOptions.leader.usernameOrMention} your party of (${user.minionName} & ${users.length - 1} simulated users) is now off to do ${quantity > 1 ? quantity : 'a'} Chambers of Xeric raid${ + quantity > 1 ? 's' : '' + } - the total trip will take ${formatDuration(duration)}.` + : `${partyOptions.leader.usernameOrMention}'s party (${users + .map(u => u.usernameOrMention) + .join(', ')}) is now off to do ${quantity > 1 ? quantity : 'a'} Chambers of Xeric raid${ + quantity > 1 ? 's' : '' + } - the total trip will take ${formatDuration(duration)}.`; str += ` \n\n${debugStr}`; diff --git a/tests/integration/pvm/cox.test.ts b/tests/integration/pvm/cox.test.ts new file mode 100644 index 0000000000..cc9ceed076 --- /dev/null +++ b/tests/integration/pvm/cox.test.ts @@ -0,0 +1,59 @@ +import { expect, test } from 'vitest'; + +import { COXMaxMageGear, COXMaxMeleeGear, COXMaxRangeGear } from '../../../src/lib/data/cox'; +import { Bank, itemID, resolveItems } from '../../../src/lib/util'; +import { raidCommand } from '../../../src/mahoji/commands/raid'; +import { mockClient, mockUser } from '../util'; + +test('CoX ', async () => { + const client = await mockClient(); + + const user = await mockUser({ + rangeGear: resolveItems(['Venator bow']), + rangeLevel: 70, + venatorBowCharges: 1000, + slayerLevel: 70 + }); + await user.max(); + + await user.update({ + tum_shadow_charges: 10000, + scythe_of_vitur_charges: 100, + gear_mage: COXMaxMageGear.raw() as any, + gear_melee: COXMaxMeleeGear.raw() as any, + gear_range: { + ...(COXMaxRangeGear.raw() as any), + ammo: { + item: itemID('Dragon arrow'), + quantity: 10000 + } + }, + bank: new Bank() + .add('Shark', 10000) + .add('Stamina potion(4)', 10000) + .add('Super restore(4)', 10000) + .add('Saradomin brew(4)', 10000) + .toJSON() + }); + await user.equip('melee', resolveItems(['Scythe of vitur'])); + const res = await user.runCommand( + raidCommand, + { + cox: { + start: { + type: 'fakemass', + max_team_size: 5 + } + } + }, + true + ); + expect(res).toContain('the total trip will take'); + await user.processActivities(client); + await user.sync(); + expect(user.bank.amount('Scythe of vitur (uncharged)')).toBe(1); + expect(user.bank.amount('Scythe of vitur')).toBe(0); + expect(user.gear.melee.weapon?.item).toBeUndefined(); + expect(user.allItemsOwned.amount('Scythe of vitur (uncharged)')).toBe(1); + expect(user.allItemsOwned.amount('Scythe of vitur')).toBe(0); +});