Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added gem optimizer for Feral DPS sim #3377

Merged
merged 3 commits into from
Jul 27, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions ui/core/proto_utils/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,10 @@ export class Database {
return Object.values(this.gems).filter(gem => gemMatchesSocket(gem, socketColor));
}

lookupGem(itemID: number): Gem {
return this.gems[itemID]
}

lookupItemSpec(itemSpec: ItemSpec): EquippedItem | null {
const item = this.items[itemSpec.id];
if (!item)
Expand Down
26 changes: 26 additions & 0 deletions ui/core/proto_utils/equipped_item.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,16 @@ export class EquippedItem {
return curItem;
}

removeAllGems(): EquippedItem {
let curItem: EquippedItem | null = this;

for (let i = 0; i < curItem._gems.length; i++) {
curItem = curItem.withGemHelper(null, i);
}

return curItem;
}

asActionId(): ActionId {
return ActionId.fromItemId(this._item.id);
}
Expand Down Expand Up @@ -203,10 +213,26 @@ export class EquippedItem {
return this._item.gemSockets.length + (this.hasExtraSocket(isBlacksmithing) ? 1 : 0);
}

numSocketsOfColor(color: GemColor): number {
let numSockets: number = 0;

for (var socketColor of this._item.gemSockets) {
if (socketColor == color) {
numSockets += 1;
}
}

return numSockets;
}

hasExtraGem(): boolean {
return this._gems.length > this.item.gemSockets.length;
}

hasSocketedGem(socketIdx: number): boolean {
return this._gems[socketIdx] != null;
}

allSocketColors(): Array<GemColor> {
return this.couldHaveExtraSocket() ? this._item.gemSockets.concat([GemColor.GemColorPrismatic]) : this._item.gemSockets;
}
Expand Down
48 changes: 48 additions & 0 deletions ui/core/proto_utils/gear.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,16 @@ export class Gear extends BaseGear {
return this.getTrinkets().map(t => t?.item.id).includes(itemId);
}

hasRelic(itemId: number): boolean {
const relicItem = this.getEquippedItem(ItemSlot.ItemSlotRanged);

if (!relicItem) {
return false;
}

return relicItem!.item.id == itemId;
}

asMap(): InternalGear {
const newInternalGear: Partial<InternalGear> = {};
getEnumValues(ItemSlot).map(slot => Number(slot) as ItemSlot).forEach(slot => {
Expand Down Expand Up @@ -226,6 +236,30 @@ export class Gear extends BaseGear {
return this.getMetaGem() != null && !this.hasActiveMetaGem(isBlacksmithing);
}

withGem(itemSlot: ItemSlot, socketIdx: number, gem: Gem): Gear {
const item = this.getEquippedItem(itemSlot);

if (item) {
return this.withEquippedItem(itemSlot, item.withGem(gem, socketIdx), true);
}

return this;
}

withMetaGem(metaGem: Gem): Gear {
const headItem = this.getEquippedItem(ItemSlot.ItemSlotHead);

if (headItem) {
for (const [socketIdx, socketColor] of headItem.allSocketColors().entries()) {
if (socketColor == GemColor.GemColorMeta) {
return this.withEquippedItem(ItemSlot.ItemSlotHead, headItem.withGem(metaGem, socketIdx), true);
}
}
}

return this;
}

withoutMetaGem(): Gear {
const headItem = this.getEquippedItem(ItemSlot.ItemSlotHead);
const metaGem = this.getMetaGem();
Expand All @@ -236,6 +270,20 @@ export class Gear extends BaseGear {
}
}

withoutGems(): Gear {
let curGear: Gear = this;

for (var slot of this.getItemSlots()) {
const item = this.getEquippedItem(slot);

if (item) {
curGear = curGear.withEquippedItem(slot, item.removeAllGems(), true);
}
}

return curGear;
}

// Removes bonus gems from blacksmith profession bonus.
withoutBlacksmithSockets(): Gear {
let curGear: Gear = this;
Expand Down
10 changes: 10 additions & 0 deletions ui/core/proto_utils/stats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,16 @@ export class Stats {
return total;
}

belowCaps(statCaps: Stats): boolean {
for (const [idx, stat] of this.stats.entries()) {
if ((statCaps.stats[idx] > 0) && (stat > statCaps.stats[idx])) {
return false;
}
}

return true;
}

asArray(): Array<number> {
return this.stats.slice();
}
Expand Down
2 changes: 1 addition & 1 deletion ui/core/sim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,7 +272,7 @@ export class Sim {
}

// This should be invoked internally whenever stats might have changed.
private async updateCharacterStats(eventID: EventID) {
async updateCharacterStats(eventID: EventID) {
if (eventID == 0) {
// Skip the first event ID because it interferes with the loaded stats.
return;
Expand Down
221 changes: 221 additions & 0 deletions ui/feral_druid/sim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ import { TristateEffect } from '../core/proto/common.js'
import { Stats } from '../core/proto_utils/stats.js';
import { Player } from '../core/player.js';
import { IndividualSimUI } from '../core/individual_sim_ui.js';
import { EventID, TypedEvent } from '../core/typed_event.js';
import { Gear } from '../core/proto_utils/gear.js';
import { ItemSlot } from '../core/proto/common.js';
import { GemColor } from '../core/proto/common.js';
import { Profession } from '../core/proto/common.js';

import * as IconInputs from '../core/components/icon_inputs.js';
import * as OtherInputs from '../core/components/other_inputs.js';
Expand Down Expand Up @@ -157,5 +162,221 @@ export class FeralDruidSimUI extends IndividualSimUI<Spec.SpecFeralDruid> {
],
},
});

this.addOptimizeGemsAction();
}

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)]);
const expCap = new Stats().withStat(Stat.StatExpertise, 6.5 * 32.79);
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)) {
for (const [itemSlot, socketIdx] of redSockets.slice(0, 3)) {
optimizedGear = optimizedGear.withGem(itemSlot, socketIdx, this.sim.db.lookupGem(42153));
}

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);
yellowGemCaps.push([40125, hitCap]);
yellowGemCaps.push([40162, hitCap.add(expCap)]);
yellowGemCaps.push([40148, hitCap.add(critCap)]);
yellowGemCaps.push([40147, critCap]);
yellowGemCaps.push([40143, hitCap]);
yellowGemCaps.push([40142, critCap]);
yellowGemCaps.push([40146, new Stats()]);
await this.fillGemsToCaps(optimizedGear, yellowSockets, yellowGemCaps, 0, 0);
}

calcArpCap(gear: Gear): Stats {
let arpCap = 1399;

if (gear.hasTrinket(45931)) {
arpCap = 659;
} else if (gear.hasTrinket(40256)) {
arpCap = 798;
}

return new Stats().withStat(Stat.StatArmorPenetration, arpCap);
}

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) {
this.player.setGear(TypedEvent.nextEventID(), gear);
await this.sim.updateCharacterStats(TypedEvent.nextEventID());
}

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) {
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]>();

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!.allSocketColors().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<Gear> {
let updatedGear: Gear = gear;
let nextGem = 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, nextGem);
}
}

// Update player stats after the last pass
await this.updateGear(updatedGear);

// If we are below the relevant stat cap for the gem we just filled on the last pass, then we are finished.
let newStats = Stats.fromProto(this.player.getCurrentStats().finalStats);

if (newStats.belowCaps(gemCaps[numPasses][1]) || (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
nextGem = this.sim.db.lookupGem(gemCaps[numPasses + 1][0]);

for (var idx = socketList.length - 1; idx >= firstIdx; idx--) {
if (newStats.belowCaps(gemCaps[numPasses][1])) {
break;
}

const [itemSlot, socketIdx] = socketList[idx];
updatedGear = updatedGear.withGem(itemSlot, socketIdx, nextGem);
await this.updateGear(updatedGear);
newStats = Stats.fromProto(this.player.getCurrentStats().finalStats);
}

// Now run a new pass to check whether we've exceeded the next stat cap
return await this.fillGemsToCaps(updatedGear, socketList, gemCaps, numPasses + 1, idx + 1);
}
}
Loading