diff --git a/ui/core/components/suggest_gems_action.ts b/ui/core/components/suggest_gems_action.ts new file mode 100644 index 0000000000..968985b1bb --- /dev/null +++ b/ui/core/components/suggest_gems_action.ts @@ -0,0 +1,631 @@ +import { IndividualSimUI } from '../individual_sim_ui.js'; +import { Player } from '../player.js'; +import { Sim } from '../sim.js'; +import { Gear } from '../proto_utils/gear.js'; +import { EquippedItem } from '../proto_utils/equipped_item.js'; +import { TypedEvent } from '../typed_event.js'; +import { Stats } from '../proto_utils/stats.js'; +import { GemColor, Stat, Profession, ItemSlot, Spec } from '../proto/common.js'; + +interface GemCapsData { + gemId: number + statCaps: Stats +} + +interface SocketData { + itemSlot: ItemSlot + socketIdx: number +} + +abstract class GemOptimizer { + protected readonly player: Player; + protected readonly sim: Sim; + protected readonly gemPriorityByColor: Record>; + abstract metaGemID: number; + static allGemColors: Array = [GemColor.GemColorRed, GemColor.GemColorYellow, GemColor.GemColorBlue]; + epWeights!: Stats; + useJcGems!: boolean; + isBlacksmithing!: boolean; + numSocketedJcGems!: number; + jcUpgradePriority: Array; + + static jcUpgradesById: Record = { + 40118: 42154, + 40125: 42156, + 40112: 42143, + 40111: 42142, + 40119: 36767, + }; + + constructor(simUI: IndividualSimUI) { + this.player = simUI.player; + this.sim = simUI.sim; + + // Initialize empty arrays of gem priorities for each socket color + this.gemPriorityByColor = {} as Record>; + + for (var gemColor of GemOptimizer.allGemColors) { + this.gemPriorityByColor[gemColor] = new Array(); + } + + this.jcUpgradePriority = new Array(); + + simUI.addAction('Suggest Gems', 'suggest-gems-action', async () => { + this.optimizeGems(); + }); + } + + async optimizeGems() { + // First, clear all existing gems + let optimizedGear = this.player.getGear().withoutGems(); + this.numSocketedJcGems = 0; + + // Store relevant player attributes for use in optimizations + this.epWeights = this.player.getEpWeights(); + this.useJcGems = this.player.hasProfession(Profession.Jewelcrafting); + this.isBlacksmithing = this.player.isBlacksmithing(); + + /* + * Use subclass-specific logic to rank order gems of each color by value + * and calculate the associated stat caps for each gem (when applicable). + */ + const ungemmedStats = await this.updateGear(optimizedGear); + this.updateGemPriority(optimizedGear, ungemmedStats); + + // Next, socket and activate the meta + optimizedGear = optimizedGear.withMetaGem(this.sim.db.lookupGem(this.metaGemID)); + optimizedGear = this.activateMetaGem(optimizedGear); + await this.updateGear(optimizedGear); + + // Now loop through all gem colors where a priority list has been defined + for (var gemColor of GemOptimizer.allGemColors) { + if (this.gemPriorityByColor[gemColor].length > 0) { + optimizedGear = await this.fillGemsByColor(optimizedGear, gemColor); + } + } + + // Finally, substitute JC gems by priority while respecting stat caps + if (this.useJcGems) { + optimizedGear = await this.substituteJcGems(optimizedGear); + } + } + + async updateGear(gear: Gear): Promise { + this.player.setGear(TypedEvent.nextEventID(), gear); + await this.sim.updateCharacterStats(TypedEvent.nextEventID()); + return Stats.fromProto(this.player.getCurrentStats().finalStats); + } + + /** + * Helper method for meta gem activation. + * + * @remarks + * Based on the ansatz that most specs are forced to use a suboptimal gem color in + * order to statisfy their meta requirements. As a result, it is helpful to + * compute the item slot in a gear set that provides the strongest socket bonus + * for that color, since this should minimize the "cost" of activation. + * + * @param gear - Ungemmed gear set + * @param color - Socket color used for meta gem activation + * @param singleOnly - If true, exclude items containing more than one socket of the specified color. If false, instead normalize the socket bonus by the number of such sockets. + * @param blacklistedColor - If non-null, exclude items containing any sockets of this color (assumed to be different from the color used for activation). + * @returns Optimal item slot for activation under the specified constraints, or null if not found. + */ + findStrongestSocketBonus(gear: Gear, color: GemColor, singleOnly: boolean, blacklistedColor: GemColor | null): ItemSlot | null { + let optimalSlot: ItemSlot | null = null; + let maxSocketBonusEP: number = 1e-8; + + for (var slot of gear.getItemSlots()) { + const item = gear.getEquippedItem(slot); + + if (!item) { + continue; + } + + if (item.numSocketsOfColor(blacklistedColor) != 0) { + continue; + } + + const numSockets = item.numSocketsOfColor(color); + + if ((numSockets == 0) || (singleOnly && (numSockets != 1))) { + continue; + } + + const socketBonusEP = new Stats(item.item.socketBonus).computeEP(this.epWeights); + const normalizedEP = socketBonusEP / numSockets; + + if (normalizedEP > maxSocketBonusEP) { + optimalSlot = slot; + maxSocketBonusEP = normalizedEP; + } + } + + return optimalSlot; + } + + socketGemInFirstMatchingSocket(gear: Gear, itemSlot: ItemSlot | null, colorToMatch: GemColor, gemId: number): Gear { + if (itemSlot != null) { + const item = gear.getEquippedItem(itemSlot); + + if (!item) { + return gear; + } + + for (const [socketIdx, socketColor] of item!.allSocketColors().entries()) { + if (socketColor == colorToMatch) { + return gear.withEquippedItem(itemSlot, item!.withGem(this.sim.db.lookupGem(gemId), socketIdx), true); + } + } + } + + return gear; + } + + async fillGemsByColor(gear: Gear, color: GemColor): Promise { + const socketList = this.findSocketsByColor(gear, color); + return await this.fillGemsToCaps(gear, socketList, this.gemPriorityByColor[color], 0, 0); + } + + /** + * Shared wrapper for compiling eligible sockets for each gem priority list. + * + * @remarks + * Subclasses are required to implement the allowGemInSocket method, which + * contains the (spec-specific) logic on when to match socket bonuses etc. + * + * @param gear - Partially gemmed gear set + * @param color - Color associated with a single gem priority list + * @returns Array of sockets that will be filled using the priority list associated with the specified color. + */ + findSocketsByColor(gear: Gear, color: GemColor): Array { + const socketList = new Array(); + + for (var slot of gear.getItemSlots()) { + const item = gear.getEquippedItem(slot); + + if (!item) { + continue; + } + + for (const [socketIdx, socketColor] of item.curSocketColors(this.isBlacksmithing).entries()) { + if (item!.hasSocketedGem(socketIdx)) { + continue; + } + + if (this.allowGemInSocket(color, socketColor, slot, item)) { + socketList.push({ itemSlot: slot, socketIdx: socketIdx }); + } + } + } + + return socketList; + } + + async substituteJcGems(gear: Gear): Promise { + let updatedGear: Gear = gear; + let gemIdx = 0; + + while ((this.numSocketedJcGems < 3) && (gemIdx < this.jcUpgradePriority.length)) { + const gemData = this.jcUpgradePriority[gemIdx]; + const baseGem = this.sim.db.lookupGem(gemData.gemId); + + if (!updatedGear.getAllGems(this.isBlacksmithing).includes(baseGem!)) { + gemIdx += 1; + continue; + } + + const upgradedGem = this.sim.db.lookupGem(GemOptimizer.jcUpgradesById[gemData.gemId]); + const testGear = updatedGear.withSingleGemSubstitution(baseGem, upgradedGem, this.isBlacksmithing); + const newStats = await this.updateGear(testGear); + + if (newStats.belowCaps(gemData.statCaps)) { + updatedGear = testGear; + this.numSocketedJcGems += 1; + } else { + await this.updateGear(updatedGear); + gemIdx += 1; + } + } + + return updatedGear; + } + + async fillGemsToCaps(gear: Gear, socketList: Array, gemCaps: Array, numPasses: number, firstIdx: number): Promise { + let updatedGear: Gear = gear; + const currentGem = this.sim.db.lookupGem(gemCaps[numPasses].gemId); + + // On the first pass, we simply fill all sockets with the highest priority gem + if (numPasses == 0) { + for (var socketData of socketList.slice(firstIdx)) { + updatedGear = updatedGear.withGem(socketData.itemSlot, socketData.socketIdx, currentGem); + } + } + + // If we are below the relevant stat cap for the gem we just filled on the last pass, then we are finished. + let newStats = await this.updateGear(updatedGear); + const currentCap = gemCaps[numPasses].statCaps; + + if (newStats.belowCaps(currentCap) || (numPasses == gemCaps.length - 1)) { + return updatedGear; + } + + // If we exceeded the stat cap, then work backwards through the socket list and replace each gem with the next highest priority option until we are below the cap + const nextGem = this.sim.db.lookupGem(gemCaps[numPasses + 1].gemId); + const nextCap = gemCaps[numPasses + 1].statCaps; + let capForReplacement = currentCap.subtract(nextCap); + + if (currentCap.computeEP(capForReplacement) <= 0) { + capForReplacement = currentCap; + } + + for (var idx = socketList.length - 1; idx >= firstIdx; idx--) { + if (newStats.belowCaps(capForReplacement)) { + break; + } + + updatedGear = updatedGear.withGem(socketList[idx].itemSlot, socketList[idx].socketIdx, nextGem); + newStats = await this.updateGear(updatedGear); + } + + // Now run a new pass to check whether we've exceeded the next stat cap + let nextIdx = idx + 1; + + if (!newStats.belowCaps(currentCap)) { + nextIdx = firstIdx; + } + + return await this.fillGemsToCaps(updatedGear, socketList, gemCaps, numPasses + 1, nextIdx); + } + + abstract activateMetaGem(gear: Gear): Gear; + + abstract updateGemPriority(ungemmedGear: Gear, passiveStats: Stats): void; + + abstract allowGemInSocket(gemColor: GemColor, socketColor: GemColor, itemSlot: ItemSlot, item: EquippedItem): boolean; +} + +export class PhysicalDPSGemOptimizer extends GemOptimizer { + metaGemID: number = 41398; // Relentless Earthsiege Diamond + arpSlop: number = 11; + expSlop: number = 4; + hitSlop: number = 4; + useArpGems: boolean; + useExpGems: boolean; + useAgiGems: boolean; + useStrGems: boolean; + arpTarget!: number; + passiveArp!: number; + arpStackDetected!: boolean; + tearSlot!: ItemSlot | null; + + constructor(simUI: IndividualSimUI, useArpGems: boolean, useExpGems: boolean, useAgiGems: boolean, useStrGems: boolean) { + super(simUI); + this.useArpGems = useArpGems; + this.useExpGems = useExpGems; + this.useAgiGems = useAgiGems; + this.useStrGems = useStrGems; + } + + updateGemPriority(ungemmedGear: Gear, passiveStats: Stats) { + // First calculate any gear-dependent stat caps. + this.arpTarget = this.calcArpTarget(ungemmedGear); + const arpCap = new Stats().withStat(Stat.StatArmorPenetration, this.arpTarget + this.arpSlop); + const critCap = this.calcCritCap(ungemmedGear); + const expCap = new Stats().withStat(Stat.StatExpertise, this.calcExpTarget() + this.expSlop); + const hitCap = new Stats().withStat(Stat.StatMeleeHit, 8. * 32.79 + this.hitSlop); + + // Reset optimal Tear slot from prior calculations + this.tearSlot = null; + + /* + * For specs that gem ArP, determine whether the current gear + * configuration will optimally hard stack Fractured gems or not. + */ + this.passiveArp = passiveStats.getStat(Stat.StatArmorPenetration); + this.arpStackDetected = this.detectArpStackConfiguration(ungemmedGear); + + // Update red gem priority + const redGemCaps = new Array(); + + // Fractured Cardinal Ruby + if (this.useArpGems) { + redGemCaps.push({ gemId: 40117, statCaps: arpCap }); + } + + // Precise Cardinal Ruby + if (this.useExpGems) { + redGemCaps.push({ gemId: 40118, statCaps: expCap }); + } + + // Delicate Cardinal Ruby + if (this.useAgiGems) { + redGemCaps.push({ gemId: 40112, statCaps: critCap }); + } + + // Bold Cardinal Ruby + if (this.useStrGems) { + redGemCaps.push({ gemId: 40111, statCaps: new Stats() }); + } + + this.gemPriorityByColor[GemColor.GemColorRed] = redGemCaps; + + // Update yellow gem priority + const yellowGemCaps = new Array(); + + // Accurate Ametrine + if (this.useExpGems) { + yellowGemCaps.push({ gemId: 40162, statCaps: hitCap.add(expCap) }); + } + + // Rigid Ametrine + yellowGemCaps.push({ gemId: 40125, statCaps: hitCap }); + + // Fractured Cardinal Ruby + if (this.arpStackDetected) { + yellowGemCaps.push({ gemId: 40117, statCaps: arpCap }); + } + + // Accurate Ametrine (needed to add twice to catch some edge cases) + if (this.useExpGems) { + yellowGemCaps.push({ gemId: 40162, statCaps: hitCap.add(expCap) }); + } + + // Glinting Ametrine + if (this.useAgiGems) { + yellowGemCaps.push({ gemId: 40148, statCaps: hitCap.add(critCap) }); + } + + // Etched Ametrine + if (this.useStrGems) { + yellowGemCaps.push({ gemId: 40143, statCaps: hitCap }); + } + + // Deadly Ametrine + if (this.useAgiGems) { + yellowGemCaps.push({ gemId: 40147, statCaps: critCap }); + } + + // Inscribed Ametrine + if (this.useStrGems) { + yellowGemCaps.push({ gemId: 40142, statCaps: critCap }); + } + + // Fierce Ametrine + if (this.useStrGems) { + yellowGemCaps.push({ gemId: 40146, statCaps: new Stats() }); + } + + this.gemPriorityByColor[GemColor.GemColorYellow] = yellowGemCaps; + + // Update JC upgrade priority + this.jcUpgradePriority = new Array(); + + if (this.useExpGems) { + this.jcUpgradePriority.push({ gemId: 40118, statCaps: expCap }); + } + + this.jcUpgradePriority.push({ gemId: 40125, statCaps: hitCap }); + + if (this.useAgiGems) { + this.jcUpgradePriority.push({ gemId: 40112, statCaps: critCap }); + } + + if (this.useStrGems) { + this.jcUpgradePriority.push({ gemId: 40111, statCaps: new Stats() }); + } + } + + detectArpStackConfiguration(ungemmedGear: Gear): boolean { + if (!this.useArpGems) { + return false; + } + + /* + * Generate a "dummy" list of red sockets in order to determine whether + * ignoring yellow socket bonuses to stack more ArP gems will be correct. + * Subtract 2 from the length of this list to account for meta gem + + * Nightmare Tear. + */ + const dummyRedSocketList = this.findSocketsByColor(ungemmedGear, GemColor.GemColorRed); + const numRedSockets = dummyRedSocketList.length - 2; + let projectedArp = this.passiveArp + 20 * numRedSockets; + + if (this.useJcGems) { + projectedArp += 42; + } + + return (this.arpTarget > 1000) && (projectedArp > 648) && (projectedArp + 20 < this.arpTarget + this.arpSlop); + } + + activateMetaGem(gear: Gear): Gear { + /* + * Use a single Nightmare Tear for meta activation. Prioritize blue + * sockets for it if possible, and fall back to yellow sockets if not. + */ + let tearColor = GemColor.GemColorBlue; + this.tearSlot = this.findBlueTearSlot(gear); + + if (this.tearSlot == null) { + tearColor = GemColor.GemColorYellow; + this.tearSlot = this.findYellowTearSlot(gear); + } + + return this.socketTear(gear, tearColor); + } + + socketTear(gear: Gear, tearColor: GemColor): Gear { + return this.socketGemInFirstMatchingSocket(gear, this.tearSlot, tearColor, 49110); + } + + findBlueTearSlot(gear: Gear): ItemSlot | null { + // Eligible Tear slots have only one blue socket max. + const singleOnly = true; + + /* + * Additionally, for hard ArP stack configurations, only use blue sockets + * for Tear if there are no yellow sockets in that item slot, since hard + * ArP stacks ignore yellow socket bonuses in favor of stacking more + * Fractured gems. + */ + const blacklistedColor = this.arpStackDetected ? GemColor.GemColorYellow : null; + + return this.findStrongestSocketBonus(gear, GemColor.GemColorBlue, singleOnly, blacklistedColor); + } + + findYellowTearSlot(gear: Gear): ItemSlot | null { + return this.findStrongestSocketBonus(gear, GemColor.GemColorYellow, false, GemColor.GemColorBlue); + } + + allowGemInSocket(gemColor: GemColor, socketColor: GemColor, itemSlot: ItemSlot, item: EquippedItem): boolean { + const ignoreYellowSockets = ((item!.numSocketsOfColor(GemColor.GemColorBlue) > 0) && (itemSlot != this.tearSlot)); + let matchYellowSocket = false; + + if ((socketColor == GemColor.GemColorYellow) && !ignoreYellowSockets) { + matchYellowSocket = new Stats(item.item.socketBonus).computeEP(this.epWeights) > 1e-8; + } + + return ((gemColor == GemColor.GemColorYellow) && matchYellowSocket) || ((gemColor == GemColor.GemColorRed) && !matchYellowSocket); + } + + findSocketsByColor(gear: Gear, color: GemColor): Array { + const socketList = super.findSocketsByColor(gear, color); + + if (this.arpStackDetected && (color == GemColor.GemColorYellow)) { + this.sortYellowSockets(gear, socketList); + } + + return socketList; + } + + sortYellowSockets(gear: Gear, yellowSocketList: Array) { + yellowSocketList.sort((a,b) => { + // If both yellow sockets belong to the same item, then treat them equally. + const slot1 = a.itemSlot; + const slot2 = b.itemSlot; + + if (slot1 == slot2) { + return 0; + } + + // If an item already has a Nightmare Tear socketed, then bump up any yellow sockets in it to highest priority. + if (slot1 == this.tearSlot) { + return -1; + } + + if (slot2 == this.tearSlot) { + return 1; + } + + // For all other cases, sort by the ratio of the socket bonus value divided by the number of yellow sockets required to activate it. + const item1 = gear.getEquippedItem(slot1); + const bonus1 = new Stats(item1!.item.socketBonus).computeEP(this.epWeights); + const item2 = gear.getEquippedItem(slot2); + const bonus2 = new Stats(item2!.item.socketBonus).computeEP(this.epWeights); + return bonus2 / item2!.numSocketsOfColor(GemColor.GemColorYellow) - bonus1 / item1!.numSocketsOfColor(GemColor.GemColorYellow); + }); + } + + calcArpTarget(gear: Gear): number { + let arpTarget = 1399; + + /* + * First handle ArP proc trinkets. If more than one of these are equipped + * simultaneously, it is assumed that the user is desyncing them via ICD + * resets, such that the soft cap is set by the strongest proc. + */ + if (gear.hasTrinket(45931)) { + arpTarget -= 751; // Mjolnir Runestone + } else if (gear.hasTrinket(50198)) { + arpTarget -= 678; // Needle-Encrusted Scorpion + } else if (gear.hasTrinket(40256)) { + arpTarget -= 612; // Grim Toll + } + + // Then check for Executioner enchant + const weapon = gear.getEquippedItem(ItemSlot.ItemSlotMainHand); + + if (weapon?.enchant?.effectId == 3225) { + arpTarget -= 120; + } + + return arpTarget; + } + + calcExpTarget(): number { + return 6.5 * 32.79; + } + + calcCritCap(gear: Gear): Stats { + /* + * Only some specs incorporate Crit soft caps into their gemming logic, so + * the parent method here simply returns an empty Stats object (meaning + * that Crit cap will just be ignored elsewhere in the code). Custom + * spec-specific subclasses can override this as desired. + */ + return new Stats(); + } + + async fillGemsByColor(gear: Gear, color: GemColor): Promise { + /* + * Parent logic substitutes JC gems after filling normal gems first, but + * for specs that gem ArP, it is more optimal to pre-fill some Fractured + * Dragon's Eyes if doing so gets us closer to the target. + */ + let updatedGear: Gear = gear; + + if ((color == GemColor.GemColorRed) && this.useArpGems && this.useJcGems) { + updatedGear = this.optimizeJcArpGems(updatedGear); + } + + return await super.fillGemsByColor(updatedGear, color); + } + + calcDistanceToArpTarget(numJcArpGems: number, numRedSockets: number): number { + const numNormalArpGems = Math.max(0, Math.min(numRedSockets - 3, Math.floor((this.arpTarget + this.arpSlop - this.passiveArp - 34 * numJcArpGems) / 20))); + const projectedArp = this.passiveArp + 34 * numJcArpGems + 20 * numNormalArpGems; + return Math.abs(projectedArp - this.arpTarget); + } + + optimizeJcArpGems(gear: Gear): Gear { + // First determine how many of the JC gems should be 34 ArP gems + const redSocketList = this.findSocketsByColor(gear, GemColor.GemColorRed); + const numRedSockets = redSocketList.length; + let optimalJcArpGems = [0,1,2,3].reduce((m,x)=> this.calcDistanceToArpTarget(m, numRedSockets)(); + blueGemCaps.push({ gemId: 40119, statCaps: new Stats() }); + this.gemPriorityByColor[GemColor.GemColorBlue] = blueGemCaps; + this.jcUpgradePriority = blueGemCaps; + } + + activateMetaGem(gear: Gear): Gear { + /* + * Use a single Shifting Dreadstone gem for meta activation, in the slot + * with the strongest bonus for a single red socket. + */ + return this.socketGemInFirstMatchingSocket(gear, this.findStrongestSocketBonus(gear, GemColor.GemColorRed, true, GemColor.GemColorYellow), GemColor.GemColorRed, 40130); + } + + allowGemInSocket(gemColor: GemColor, socketColor: GemColor, itemSlot: ItemSlot, item: EquippedItem): boolean { + return gemColor == GemColor.GemColorBlue; + } +} diff --git a/ui/core/proto_utils/equipped_item.ts b/ui/core/proto_utils/equipped_item.ts index 27dc4555b3..601d65c032 100644 --- a/ui/core/proto_utils/equipped_item.ts +++ b/ui/core/proto_utils/equipped_item.ts @@ -217,7 +217,7 @@ export class EquippedItem { return this._item.gemSockets.length + (this.hasExtraSocket(isBlacksmithing) ? 1 : 0); } - numSocketsOfColor(color: GemColor): number { + numSocketsOfColor(color: GemColor | null): number { let numSockets: number = 0; for (var socketColor of this._item.gemSockets) { diff --git a/ui/core/proto_utils/gear.ts b/ui/core/proto_utils/gear.ts index bc5cb807e7..8d6c164856 100644 --- a/ui/core/proto_utils/gear.ts +++ b/ui/core/proto_utils/gear.ts @@ -246,6 +246,25 @@ export class Gear extends BaseGear { return this; } + withSingleGemSubstitution(oldGem: Gem | null, newGem: Gem | null, isBlacksmithing: boolean): Gear { + for (var slot of this.getItemSlots()) { + const item = this.getEquippedItem(slot); + + if (!item) { + continue; + } + + const currentGems = item!.curGems(isBlacksmithing); + + if (currentGems.includes(oldGem)) { + const socketIdx = currentGems.indexOf(oldGem); + return this.withGem(slot, socketIdx, newGem); + } + } + + return this; + } + withMetaGem(metaGem: Gem | null): Gear { const headItem = this.getEquippedItem(ItemSlot.ItemSlotHead); diff --git a/ui/feral_druid/sim.ts b/ui/feral_druid/sim.ts index f7e8d9f568..ed483191f3 100644 --- a/ui/feral_druid/sim.ts +++ b/ui/feral_druid/sim.ts @@ -16,6 +16,7 @@ import { } from '../core/proto/common.js'; import { IndividualSimUI, registerSpecConfig } from '../core/individual_sim_ui.js'; import { Gear } from '../core/proto_utils/gear.js'; +import { PhysicalDPSGemOptimizer } from '../core/components/suggest_gems_action.js'; import { Stats } from '../core/proto_utils/stats.js'; import { getSpecIcon, specNames } from '../core/proto_utils/utils.js'; import { TypedEvent } from '../core/typed_event.js'; @@ -211,101 +212,13 @@ export class FeralDruidSimUI extends IndividualSimUI { constructor(parentElem: HTMLElement, player: Player) { super(parentElem, player, SPEC_CONFIG); - this.addOptimizeGemsAction(); + const gemOptimizer = new FeralGemOptimizer(this); } +} - addOptimizeGemsAction() { - this.addAction('Suggest Gems', 'optimize-gems-action', async () => { - this.optimizeGems(); - }); - } - - async optimizeGems() { - // First, clear all existing gems - let optimizedGear = this.player.getGear().withoutGems(); - - // Next, socket the meta - optimizedGear = optimizedGear.withMetaGem(this.sim.db.lookupGem(41398)); - - // Next, socket a Nightmare Tear in the best blue socket bonus - const epWeights = this.player.getEpWeights(); - let tearColor = GemColor.GemColorBlue; - let tearSlot = this.findBlueTearSlot(optimizedGear, epWeights); - - if (tearSlot == null) { - tearColor = GemColor.GemColorYellow; - tearSlot = this.findYellowTearSlot(optimizedGear, epWeights); - } - - optimizedGear = this.socketTear(optimizedGear, tearSlot, tearColor); - await this.updateGear(optimizedGear); - - // Next, identify all sockets where red gems will be placed - const redSockets = this.findSocketsByColor(optimizedGear, epWeights, GemColor.GemColorRed, tearSlot); - - // Rank order red gems to use with their associated stat caps - const redGemCaps = new Array<[number, Stats]>(); - const arpTarget = this.calcArpTarget(optimizedGear); - const arpCap = new Stats().withStat(Stat.StatArmorPenetration, arpTarget + 11); - redGemCaps.push([40117, arpCap]); - const expCap = new Stats().withStat(Stat.StatExpertise, 6.5 * 32.79 + 4); - redGemCaps.push([40118, expCap]); - const critCap = this.calcCritCap(optimizedGear); - redGemCaps.push([40112, critCap]); - redGemCaps.push([40111, new Stats()]); - - // If JC, then socket 34 ArP gems in first three red sockets before proceeding - let startIdx = 0; - - if (this.player.hasProfession(Profession.Jewelcrafting)) { - optimizedGear = this.optimizeJcGems(optimizedGear, redSockets, arpTarget, arpCap, critCap); - startIdx = 3; - } - - // Do multiple passes to fill in red gems up their caps - optimizedGear = await this.fillGemsToCaps(optimizedGear, redSockets, redGemCaps, 0, startIdx); - - // Now repeat the process for yellow gems - const yellowSockets = this.findSocketsByColor(optimizedGear, epWeights, GemColor.GemColorYellow, tearSlot); - const yellowGemCaps = new Array<[number, Stats]>(); - const hitCap = new Stats().withStat(Stat.StatMeleeHit, 8. * 32.79 + 4); - yellowGemCaps.push([40125, hitCap]); - yellowGemCaps.push([40162, hitCap.add(expCap)]); - - // If a hard ArP stack configuration is detected, then allow for socketing ArP gems in weaker yellow sockets after capping Hit and Expertise - if (this.detectArpStackConfiguration(arpTarget)) { - this.sortYellowSockets(optimizedGear, yellowSockets, epWeights, tearSlot); - yellowGemCaps.reverse(); - yellowGemCaps.push([40117, arpCap]); - } - - // Continue with the rest of the yellow gems otherwise - yellowGemCaps.push([40148, hitCap.add(critCap)]); - yellowGemCaps.push([40143, hitCap]); - yellowGemCaps.push([40147, critCap]); - yellowGemCaps.push([40142, critCap]); - yellowGemCaps.push([40146, new Stats()]); - await this.fillGemsToCaps(optimizedGear, yellowSockets, yellowGemCaps, 0, 0); - } - - calcArpTarget(gear: Gear): number { - let arpTarget = 1399; - - // First handle ArP proc trinkets - if (gear.hasTrinket(45931)) { - arpTarget -= 751; - } else if (gear.hasTrinket(40256)) { - arpTarget -= 612; - } - - // Then check for Executioner enchant - const weapon = gear.getEquippedItem(ItemSlot.ItemSlotMainHand); - - if ((weapon != null) && (weapon!.enchant != null) && (weapon!.enchant!.effectId == 3225)) { - arpTarget -= 120; - } - - return arpTarget; +class FeralGemOptimizer extends PhysicalDPSGemOptimizer { + constructor(simUI: IndividualSimUI) { + super(simUI, true, true, true, true); } calcCritCap(gear: Gear): Stats { @@ -334,231 +247,4 @@ export class FeralDruidSimUI extends IndividualSimUI { return new Stats().withStat(Stat.StatMeleeCrit, (baseCritCapPercentage - agiProcs*1.1*1.06*1.02/83.33) * 45.91); } - - async updateGear(gear: Gear): Promise { - this.player.setGear(TypedEvent.nextEventID(), gear); - await this.sim.updateCharacterStats(TypedEvent.nextEventID()); - return Stats.fromProto(this.player.getCurrentStats().finalStats); - } - - findBlueTearSlot(gear: Gear, epWeights: Stats): ItemSlot | null { - let tearSlot: ItemSlot | null = null; - let maxBlueSocketBonusEP: number = 1e-8; - - for (var slot of gear.getItemSlots()) { - const item = gear.getEquippedItem(slot); - - if (!item) { - continue; - } - - if (item!.numSocketsOfColor(GemColor.GemColorBlue) != 1) { - continue; - } - - const socketBonusEP = new Stats(item.item.socketBonus).computeEP(epWeights); - - if (socketBonusEP > maxBlueSocketBonusEP) { - tearSlot = slot; - maxBlueSocketBonusEP = socketBonusEP; - } - } - - return tearSlot; - } - - findYellowTearSlot(gear: Gear, epWeights: Stats): ItemSlot | null { - let tearSlot: ItemSlot | null = null; - let maxYellowSocketBonusEP: number = 1e-8; - - for (var slot of gear.getItemSlots()) { - const item = gear.getEquippedItem(slot); - - if (!item) { - continue; - } - - if (item!.numSocketsOfColor(GemColor.GemColorBlue) != 0) { - continue; - } - - const numYellowSockets = item!.numSocketsOfColor(GemColor.GemColorYellow); - - if (numYellowSockets == 0) { - continue; - } - - const socketBonusEP = new Stats(item.item.socketBonus).computeEP(epWeights); - const normalizedEP = socketBonusEP / numYellowSockets; - - if (normalizedEP > maxYellowSocketBonusEP) { - tearSlot = slot; - maxYellowSocketBonusEP = normalizedEP; - } - } - - return tearSlot; - } - - socketTear(gear: Gear, tearSlot: ItemSlot | null, tearColor: GemColor): Gear { - if (tearSlot != null) { - const tearSlotItem = gear.getEquippedItem(tearSlot); - - for (const [socketIdx, socketColor] of tearSlotItem!.allSocketColors().entries()) { - if (socketColor == tearColor) { - return gear.withEquippedItem(tearSlot, tearSlotItem!.withGem(this.sim.db.lookupGem(49110), socketIdx), true); - } - } - } - - return gear; - } - - findSocketsByColor(gear: Gear, epWeights: Stats, color: GemColor, tearSlot: ItemSlot | null): Array<[ItemSlot, number]> { - const socketList = new Array<[ItemSlot, number]>(); - const isBlacksmithing = this.player.isBlacksmithing(); - - for (var slot of gear.getItemSlots()) { - const item = gear.getEquippedItem(slot); - - if (!item) { - continue; - } - - const ignoreYellowSockets = ((item!.numSocketsOfColor(GemColor.GemColorBlue) > 0) && (slot != tearSlot)) - - for (const [socketIdx, socketColor] of item!.curSocketColors(isBlacksmithing).entries()) { - if (item!.hasSocketedGem(socketIdx)) { - continue; - } - - let matchYellowSocket = false; - - if ((socketColor == GemColor.GemColorYellow) && !ignoreYellowSockets) { - matchYellowSocket = new Stats(item.item.socketBonus).computeEP(epWeights) > 1e-8; - } - - if (((color == GemColor.GemColorYellow) && matchYellowSocket) || ((color == GemColor.GemColorRed) && !matchYellowSocket)) { - socketList.push([slot, socketIdx]); - } - } - } - - return socketList; - } - - sortYellowSockets(gear: Gear, yellowSocketList: Array<[ItemSlot, number]>, epWeights: Stats, tearSlot: ItemSlot | null) { - yellowSocketList.sort((a,b) => { - // If both yellow sockets belong to the same item, then treat them equally. - const slot1 = a[0]; - const slot2 = b[0]; - - if (slot1 == slot2) { - return 0; - } - - // If an item already has a Nightmare Tear socketed, then bump up any yellow sockets in it to highest priority. - if (slot1 == tearSlot) { - return -1; - } - - if (slot2 == tearSlot) { - return 1; - } - - // For all other cases, sort by the ratio of the socket bonus value divided by the number of yellow sockets required to activate it. - const item1 = gear.getEquippedItem(slot1); - const bonus1 = new Stats(item1!.item.socketBonus).computeEP(epWeights); - const item2 = gear.getEquippedItem(slot2); - const bonus2 = new Stats(item2!.item.socketBonus).computeEP(epWeights); - return bonus2 / item2!.numSocketsOfColor(GemColor.GemColorYellow) - bonus1 / item1!.numSocketsOfColor(GemColor.GemColorYellow); - }); - } - - async fillGemsToCaps(gear: Gear, socketList: Array<[ItemSlot, number]>, gemCaps: Array<[number, Stats]>, numPasses: number, firstIdx: number): Promise { - let updatedGear: Gear = gear; - const currentGem = this.sim.db.lookupGem(gemCaps[numPasses][0]); - - // On the first pass, we simply fill all sockets with the highest priority gem - if (numPasses == 0) { - for (const [itemSlot, socketIdx] of socketList.slice(firstIdx)) { - updatedGear = updatedGear.withGem(itemSlot, socketIdx, currentGem); - } - } - - // If we are below the relevant stat cap for the gem we just filled on the last pass, then we are finished. - let newStats = await this.updateGear(updatedGear); - const currentCap = gemCaps[numPasses][1]; - - if (newStats.belowCaps(currentCap) || (numPasses == gemCaps.length - 1)) { - return updatedGear; - } - - // If we exceeded the stat cap, then work backwards through the socket list and replace each gem with the next highest priority option until we are below the cap - const nextGem = this.sim.db.lookupGem(gemCaps[numPasses + 1][0]); - const nextCap = gemCaps[numPasses + 1][1]; - let capForReplacement = currentCap.subtract(nextCap); - - if (currentCap.computeEP(capForReplacement) <= 0) { - capForReplacement = currentCap; - } - - for (var idx = socketList.length - 1; idx >= firstIdx; idx--) { - if (newStats.belowCaps(capForReplacement)) { - break; - } - - const [itemSlot, socketIdx] = socketList[idx]; - updatedGear = updatedGear.withGem(itemSlot, socketIdx, nextGem); - newStats = await this.updateGear(updatedGear); - } - - // Now run a new pass to check whether we've exceeded the next stat cap - let nextIdx = idx + 1; - - if (!newStats.belowCaps(currentCap)) { - nextIdx = firstIdx; - } - - return await this.fillGemsToCaps(updatedGear, socketList, gemCaps, numPasses + 1, nextIdx); - } - - calcDistanceToArpTarget(numJcArpGems: number, passiveArp: number, numRedSockets: number, arpCap: number, arpTarget: number): number { - const numNormalArpGems = Math.max(0, Math.min(numRedSockets - 3, Math.floor((arpCap - passiveArp - 34 * numJcArpGems) / 20))); - const projectedArp = passiveArp + 34 * numJcArpGems + 20 * numNormalArpGems; - return Math.abs(projectedArp - arpTarget); - } - - optimizeJcGems(gear: Gear, redSocketList: Array<[ItemSlot, number]>, arpTarget: number, arpCap: Stats, critCap: Stats): Gear { - const passiveStats = Stats.fromProto(this.player.getCurrentStats().finalStats); - const passiveArp = passiveStats.getStat(Stat.StatArmorPenetration); - const numRedSockets = redSocketList.length; - const arpCapValue = arpCap.getStat(Stat.StatArmorPenetration); - - // First determine how many of the JC gems should be 34 ArP gems - const optimalJcArpGems = [0,1,2,3].reduce((m,x)=> this.calcDistanceToArpTarget(m, passiveArp, numRedSockets, arpCapValue, arpTarget) 1000) && (currentArp > 648) && (currentArp + 20 < arpTarget + 11); - } } diff --git a/ui/feral_tank_druid/sim.ts b/ui/feral_tank_druid/sim.ts index 9b183be642..a42a25c005 100644 --- a/ui/feral_tank_druid/sim.ts +++ b/ui/feral_tank_druid/sim.ts @@ -21,6 +21,7 @@ import { Stats } from '../core/proto_utils/stats.js'; import { getSpecIcon, specNames } from '../core/proto_utils/utils.js'; import { Player } from '../core/player.js'; import { IndividualSimUI, registerSpecConfig } from '../core/individual_sim_ui.js'; +import { TankGemOptimizer } from '../core/components/suggest_gems_action.js'; import { FeralTankDruid_Rotation as DruidRotation, @@ -279,5 +280,6 @@ const SPEC_CONFIG = registerSpecConfig(Spec.SpecFeralTankDruid, { export class FeralTankDruidSimUI extends IndividualSimUI { constructor(parentElem: HTMLElement, player: Player) { super(parentElem, player, SPEC_CONFIG); + const gemOptimizer = new TankGemOptimizer(this); } } diff --git a/ui/hunter/sim.ts b/ui/hunter/sim.ts index 7850a97c33..67c8b34dd0 100644 --- a/ui/hunter/sim.ts +++ b/ui/hunter/sim.ts @@ -25,6 +25,8 @@ import { IndividualSimUI, registerSpecConfig } from '../core/individual_sim_ui.j import { TypedEvent } from '../core/typed_event.js'; import { getPetTalentsConfig } from '../core/talents/hunter_pet.js'; import { protoToTalentString } from '../core/talents/factory.js'; +import { Gear } from '../core/proto_utils/gear.js'; +import { PhysicalDPSGemOptimizer } from '../core/components/suggest_gems_action.js'; import { Hunter_Rotation as HunterRotation, @@ -463,5 +465,28 @@ const SPEC_CONFIG = registerSpecConfig(Spec.SpecHunter, { export class HunterSimUI extends IndividualSimUI { constructor(parentElem: HTMLElement, player: Player) { super(parentElem, player, SPEC_CONFIG); + const gemOptimizer = new HunterGemOptimizer(this); + } +} + +class HunterGemOptimizer extends PhysicalDPSGemOptimizer { + readonly player: Player; + arpSlop: number = 4; + hitSlop: number = 11; + + constructor(simUI: IndividualSimUI) { + super(simUI, true, false, true, false); + this.player = simUI.player; + } + + detectArpStackConfiguration(ungemmedGear: Gear): boolean { + /* + * Allow ArP gems only for Marksmanship specialization. Additionally, + * unlike the Warrior and Feral sims, Marksmanship gemming algorithm has + * an additional restriction of only gemming ArP in hard cap setups where + * the passive ArP on the ungemmed gear set is already very high. + */ + this.useArpGems = (this.player.getTalentTree() === 1) && (this.arpTarget > 1000) && (this.passiveArp > 648); + return super.detectArpStackConfiguration(ungemmedGear); } } diff --git a/ui/warrior/sim.ts b/ui/warrior/sim.ts index 227a900193..bf98f00ba9 100644 --- a/ui/warrior/sim.ts +++ b/ui/warrior/sim.ts @@ -22,6 +22,7 @@ import { getSpecIcon } from '../core/proto_utils/utils.js'; import { IndividualSimUI, registerSpecConfig } from '../core/individual_sim_ui.js'; import { TypedEvent } from '../core/typed_event.js'; import { Gear } from '../core/proto_utils/gear.js'; +import { PhysicalDPSGemOptimizer } from '../core/components/suggest_gems_action.js'; import * as IconInputs from '../core/components/icon_inputs.js'; @@ -279,297 +280,32 @@ const SPEC_CONFIG = registerSpecConfig(Spec.SpecWarrior, { export class WarriorSimUI extends IndividualSimUI { constructor(parentElem: HTMLElement, player: Player) { super(parentElem, player, SPEC_CONFIG); - this.addOptimizeGemsAction(); + const gemOptimizer = new WarriorGemOptimizer(this); } - addOptimizeGemsAction() { - this.addAction('Suggest Gems', 'optimize-gems-action', async () => { - this.optimizeGems(); - }); - } - - async optimizeGems() { - // First, clear all existing gems - let optimizedGear = this.player.getGear().withoutGems(); - - // Next, socket the meta - optimizedGear = optimizedGear.withMetaGem(this.sim.db.lookupGem(41398)); - - // Next, socket a Nightmare Tear in the best blue socket bonus - const epWeights = this.player.getEpWeights(); - const tearSlot = this.findTearSlot(optimizedGear, epWeights); - optimizedGear = this.socketTear(optimizedGear, tearSlot); - await this.updateGear(optimizedGear); - - // Next, identify all sockets where red gems will be placed - const redSockets = this.findSocketsByColor(optimizedGear, epWeights, GemColor.GemColorRed, tearSlot); - - // Rank order red gems to use with their associated stat caps - const redGemCaps = new Array<[number, Stats]>(); - redGemCaps.push([40117, this.calcArpCap(optimizedGear)]); - // Should we gem expertise? - const enableExpertiseGemming = !this.player.getSpecOptions().disableExpertiseGemming - const expCap = this.calcExpCap(); - if(enableExpertiseGemming){ - redGemCaps.push([40118, expCap]); - } - const critCap = this.calcCritCap(optimizedGear); - redGemCaps.push([40111, new Stats()]); - - // If JC, then socket 34 ArP gems in first three red sockets before proceeding - let startIdx = 0; +} - if (this.player.hasProfession(Profession.Jewelcrafting)) { - optimizedGear = this.optimizeJcGems(optimizedGear, redSockets); - startIdx = 3; - } +class WarriorGemOptimizer extends PhysicalDPSGemOptimizer { + readonly player: Player; - // Do multiple passes to fill in red gems up their caps - optimizedGear = await this.fillGemsToCaps(optimizedGear, redSockets, redGemCaps, 0, startIdx); + constructor(simUI: IndividualSimUI) { + super(simUI, true, true, false, true); + this.player = simUI.player; + } - // Now repeat the process for yellow gems - const yellowSockets = this.findSocketsByColor(optimizedGear, epWeights, GemColor.GemColorYellow, tearSlot); - const yellowGemCaps = new Array<[number, Stats]>(); - const hitCap = new Stats().withStat(Stat.StatMeleeHit, 8. * 32.79 + 4); - yellowGemCaps.push([40125, hitCap]); - if(enableExpertiseGemming){ - yellowGemCaps.push([40162, hitCap.add(expCap)]); - yellowGemCaps.push([40118, expCap]); - } - yellowGemCaps.push([40143, hitCap]); - yellowGemCaps.push([40142, critCap]); - await this.fillGemsToCaps(optimizedGear, yellowSockets, yellowGemCaps, 0, 0); + updateGemPriority(ungemmedGear: Gear, passiveStats: Stats) { + this.useExpGems = !this.player.getSpecOptions().disableExpertiseGemming; + super.updateGemPriority(ungemmedGear, passiveStats); } - calcExpCap(): Stats { - let expCap = 6.5 * 32.79 + 4; + calcExpTarget(): number { + let expTarget = super.calcExpTarget(); const weaponMastery = this.player.getTalents().weaponMastery; const hasWeaponMasteryTalent = !!weaponMastery; if (hasWeaponMasteryTalent) { - expCap -= - weaponMastery * 4 * Mechanics.EXPERTISE_PER_QUARTER_PERCENT_REDUCTION; - } - - return new Stats().withStat(Stat.StatExpertise, expCap); - } - - calcArpCap(gear: Gear): Stats { - let arpCap = 1404; - - if (gear.hasTrinket(45931)) { - arpCap = 659; - } else if (gear.hasTrinket(40256)) { - arpCap = 798; - } - - return new Stats().withStat(Stat.StatArmorPenetration, arpCap); - } - - calcArpTarget(gear: Gear): number { - if (gear.hasTrinket(45931)) { - return 648; - } - - if (gear.hasTrinket(40256)) { - return 787; - } - - return 1399; - } - - calcCritCap(gear: Gear): Stats { - const baseCritCapPercentage = 77.8; // includes 3% Crit debuff - let agiProcs = 0; - - if (gear.hasRelic(47668)) { - agiProcs += 200; - } - - if (gear.hasRelic(50456)) { - agiProcs += 44*5; - } - - if (gear.hasTrinket(47131) || gear.hasTrinket(47464)) { - agiProcs += 510; - } - - if (gear.hasTrinket(47115) || gear.hasTrinket(47303)) { - agiProcs += 450; - } - - if (gear.hasTrinket(44253) || gear.hasTrinket(42987)) { - agiProcs += 300; - } - - return new Stats().withStat(Stat.StatMeleeCrit, (baseCritCapPercentage - agiProcs*1.1*1.06*1.02/83.33) * 45.91); - } - - async updateGear(gear: Gear): Promise { - this.player.setGear(TypedEvent.nextEventID(), gear); - await this.sim.updateCharacterStats(TypedEvent.nextEventID()); - return Stats.fromProto(this.player.getCurrentStats().finalStats); - } - - findTearSlot(gear: Gear, epWeights: Stats): ItemSlot | null { - let tearSlot: ItemSlot | null = null; - let maxBlueSocketBonusEP: number = 1e-8; - - for (var slot of gear.getItemSlots()) { - const item = gear.getEquippedItem(slot); - - if (!item) { - continue; - } - - if (item!.numSocketsOfColor(GemColor.GemColorBlue) != 1) { - continue; - } - - const socketBonusEP = new Stats(item.item.socketBonus).computeEP(epWeights); - - if (socketBonusEP > maxBlueSocketBonusEP) { - tearSlot = slot; - maxBlueSocketBonusEP = socketBonusEP; - } - } - - return tearSlot; - } - - socketTear(gear: Gear, tearSlot: ItemSlot | null): Gear { - if (tearSlot != null) { - const tearSlotItem = gear.getEquippedItem(tearSlot); - - for (const [socketIdx, socketColor] of tearSlotItem!.allSocketColors().entries()) { - if (socketColor == GemColor.GemColorBlue) { - return gear.withEquippedItem(tearSlot, tearSlotItem!.withGem(this.sim.db.lookupGem(49110), socketIdx), true); - } - } - } - - return gear; - } - - findSocketsByColor(gear: Gear, epWeights: Stats, color: GemColor, tearSlot: ItemSlot | null): Array<[ItemSlot, number]> { - const socketList = new Array<[ItemSlot, number]>(); - const isBlacksmithing = this.player.isBlacksmithing(); - - for (var slot of gear.getItemSlots()) { - const item = gear.getEquippedItem(slot); - - if (!item) { - continue; - } - - const ignoreYellowSockets = ((item!.numSocketsOfColor(GemColor.GemColorBlue) > 0) && (slot != tearSlot)) - - for (const [socketIdx, socketColor] of item!.curSocketColors(isBlacksmithing).entries()) { - if (item!.hasSocketedGem(socketIdx)) { - continue; - } - - let matchYellowSocket = false; - - if ((socketColor == GemColor.GemColorYellow) && !ignoreYellowSockets) { - matchYellowSocket = new Stats(item.item.socketBonus).computeEP(epWeights) > 1e-8; - } - - if (((color == GemColor.GemColorYellow) && matchYellowSocket) || ((color == GemColor.GemColorRed) && !matchYellowSocket)) { - socketList.push([slot, socketIdx]); - } - } - } - - return socketList; - } - - async fillGemsToCaps(gear: Gear, socketList: Array<[ItemSlot, number]>, gemCaps: Array<[number, Stats]>, numPasses: number, firstIdx: number): Promise { - let updatedGear: Gear = gear; - const currentGem = this.sim.db.lookupGem(gemCaps[numPasses][0]); - - // On the first pass, we simply fill all sockets with the highest priority gem - if (numPasses == 0) { - for (const [itemSlot, socketIdx] of socketList.slice(firstIdx)) { - updatedGear = updatedGear.withGem(itemSlot, socketIdx, currentGem); - } - } - - // If we are below the relevant stat cap for the gem we just filled on the last pass, then we are finished. - let newStats = await this.updateGear(updatedGear); - const currentCap = gemCaps[numPasses][1]; - - if (newStats.belowCaps(currentCap) || (numPasses == gemCaps.length - 1)) { - return updatedGear; - } - - // If we exceeded the stat cap, then work backwards through the socket list and replace each gem with the next highest priority option until we are below the cap - const nextGem = this.sim.db.lookupGem(gemCaps[numPasses + 1][0]); - const nextCap = gemCaps[numPasses + 1][1]; - let capForReplacement = currentCap; - - if ((numPasses > 0) && !currentCap.equals(nextCap)) { - capForReplacement = currentCap.subtract(nextCap); - } - - for (var idx = socketList.length - 1; idx >= firstIdx; idx--) { - if (newStats.belowCaps(capForReplacement)) { - break; - } - - const [itemSlot, socketIdx] = socketList[idx]; - updatedGear = updatedGear.withGem(itemSlot, socketIdx, nextGem); - newStats = await this.updateGear(updatedGear); - } - - // Now run a new pass to check whether we've exceeded the next stat cap - let nextIdx = idx + 1; - - if (!newStats.belowCaps(currentCap)) { - nextIdx = firstIdx; - } - - return await this.fillGemsToCaps(updatedGear, socketList, gemCaps, numPasses + 1, nextIdx); - } - - calcDistanceToArpTarget(numJcArpGems: number, passiveArp: number, numRedSockets: number, arpCap: number, arpTarget: number): number { - const numNormalArpGems = Math.max(0, Math.min(numRedSockets - 3, Math.floor((arpCap - passiveArp - 34 * numJcArpGems) / 20))); - const projectedArp = passiveArp + 34 * numJcArpGems + 20 * numNormalArpGems; - return Math.abs(projectedArp - arpTarget); - } - - optimizeJcGems(gear: Gear, redSocketList: Array<[ItemSlot, number]>): Gear { - const passiveStats = Stats.fromProto(this.player.getCurrentStats().finalStats); - const passiveArp = passiveStats.getStat(Stat.StatArmorPenetration); - const numRedSockets = redSocketList.length; - const arpCap = this.calcArpCap(gear).getStat(Stat.StatArmorPenetration); - const arpTarget = this.calcArpTarget(gear); - - // First determine how many of the JC gems should be 34 ArP gems - let optimalJcArpGems = 0; - let minDistanceToArpTarget = this.calcDistanceToArpTarget(0, passiveArp, numRedSockets, arpCap, arpTarget); - - for (let i = 1; i <= 3; i++) { - const distanceToArpTarget = this.calcDistanceToArpTarget(i, passiveArp, numRedSockets, arpCap, arpTarget); - - if (distanceToArpTarget < minDistanceToArpTarget) { - optimalJcArpGems = i; - minDistanceToArpTarget = distanceToArpTarget; - } - } - - // Now actually socket the gems - let updatedGear: Gear = gear; - - for (let i = 0; i < 3; i++) { - let gemId = 42142; // Str by default - - if (i < optimalJcArpGems) { - gemId = 42153; - } - - updatedGear = updatedGear.withGem(redSocketList[i][0], redSocketList[i][1], this.sim.db.lookupGem(gemId)); + expTarget -= weaponMastery * 4 * Mechanics.EXPERTISE_PER_QUARTER_PERCENT_REDUCTION; } - return updatedGear; + return expTarget; } }