From dd20c99aa2fb2423e087f2dafaab457811bd5056 Mon Sep 17 00:00:00 2001 From: James Tanner Date: Sat, 23 Dec 2023 15:05:46 +0900 Subject: [PATCH] Implement item swapping in APL --- proto/api.proto | 3 + proto/apl.proto | 14 +- proto/ui.proto | 2 + sim/core/apl_action.go | 2 + sim/core/apl_actions_misc.go | 44 ++++++ sim/core/character.go | 4 + sim/core/item_swaps.go | 60 +++++--- sim/deathknight/dps/dps_deathknight.go | 4 - sim/shaman/enhancement/enhancement.go | 13 +- sim/shaman/fire_elemental_totem.go | 2 +- sim/warlock/warlock.go | 10 +- sim/warlock/warlock_test.go | 10 -- ui/core/components/gear_picker.tsx | 56 ++------ .../individual_sim_ui/apl_actions.ts | 29 ++++ .../individual_sim_ui/rotation_tab.ts | 4 - .../individual_sim_ui/settings_tab.ts | 26 +++- ui/core/components/input_helpers.ts | 35 +---- ui/core/components/item_swap_picker.ts | 131 +++++++++--------- ui/core/components/stat_weights_action.ts | 2 +- ui/core/individual_sim_ui.ts | 12 +- ui/core/player.ts | 114 ++++++++------- ui/core/proto_utils/database.ts | 21 ++- ui/core/proto_utils/equipped_item.ts | 2 - ui/core/proto_utils/gear.ts | 129 ++++++++--------- ui/core/utils.ts | 1 + ui/deathknight/inputs.ts | 19 --- ui/deathknight/sim.ts | 1 + ui/enhancement_shaman/inputs.ts | 24 +--- ui/enhancement_shaman/sim.ts | 2 + ui/warlock/inputs.ts | 18 +-- ui/warlock/sim.ts | 2 + 31 files changed, 407 insertions(+), 389 deletions(-) diff --git a/proto/api.proto b/proto/api.proto index df2d9d3019..3b4e9b3e05 100644 --- a/proto/api.proto +++ b/proto/api.proto @@ -27,6 +27,9 @@ message Player { Consumes consumes = 4; UnitStats bonus_stats = 36; + bool enable_item_swap = 46; + ItemSwap item_swap = 45; + IndividualBuffs buffs = 15; oneof spec { diff --git a/proto/apl.proto b/proto/apl.proto index 9c2f4087e8..43ac3b3455 100644 --- a/proto/apl.proto +++ b/proto/apl.proto @@ -46,7 +46,7 @@ message APLListItem { APLAction action = 3; // The action to be performed. } -// NextIndex: 17 +// NextIndex: 18 message APLAction { APLValue condition = 1; // If set, action will only execute if value is true or != 0. @@ -73,6 +73,7 @@ message APLAction { APLActionActivateAura activate_aura = 13; APLActionCancelAura cancel_aura = 10; APLActionTriggerICD trigger_icd = 11; + APLActionItemSwap item_swap = 17; } } @@ -249,6 +250,17 @@ message APLActionTriggerICD { ActionID aura_id = 1; } +message APLActionItemSwap { + enum SwapSet { + Unknown = 0; + Main = 1; + Swap1 = 2; + } + + // The set to swap to. + SwapSet swap_set = 1; +} + /////////////////////////////////////////////////////////////////////////// // VALUES /////////////////////////////////////////////////////////////////////////// diff --git a/proto/ui.proto b/proto/ui.proto index d636b6279e..1d6b7cfe6a 100644 --- a/proto/ui.proto +++ b/proto/ui.proto @@ -293,6 +293,8 @@ message SavedSettings { Cooldowns cooldowns = 6; string rotation_json = 8; repeated Profession professions = 9; + bool enable_item_swap = 18; + ItemSwap item_swap = 17; int32 reaction_time_ms = 10; int32 channel_clip_delay_ms = 14; diff --git a/sim/core/apl_action.go b/sim/core/apl_action.go index b870256c94..0d8f4f6aac 100644 --- a/sim/core/apl_action.go +++ b/sim/core/apl_action.go @@ -175,6 +175,8 @@ func (rot *APLRotation) newAPLActionImpl(config *proto.APLAction) APLActionImpl return rot.newActionCancelAura(config.GetCancelAura()) case *proto.APLAction_TriggerIcd: return rot.newActionTriggerICD(config.GetTriggerIcd()) + case *proto.APLAction_ItemSwap: + return rot.newActionItemSwap(config.GetItemSwap()) default: return nil } diff --git a/sim/core/apl_actions_misc.go b/sim/core/apl_actions_misc.go index 9e6d0a887e..e2dfc79d23 100644 --- a/sim/core/apl_actions_misc.go +++ b/sim/core/apl_actions_misc.go @@ -2,6 +2,7 @@ package core import ( "fmt" + "github.com/wowsims/wotlk/sim/core/proto" ) @@ -117,3 +118,46 @@ func (action *APLActionTriggerICD) Execute(sim *Simulation) { func (action *APLActionTriggerICD) String() string { return fmt.Sprintf("Trigger ICD(%s)", action.aura.ActionID) } + +type APLActionItemSwap struct { + defaultAPLActionImpl + character *Character + swapSet proto.APLActionItemSwap_SwapSet +} + +func (rot *APLRotation) newActionItemSwap(config *proto.APLActionItemSwap) APLActionImpl { + if config.SwapSet == proto.APLActionItemSwap_Unknown { + rot.ValidationWarning("Unknown item swap set") + return nil + } + + character := rot.unit.Env.Raid.GetPlayerFromUnit(rot.unit).GetCharacter() + if !character.ItemSwap.IsEnabled() { + if config.SwapSet != proto.APLActionItemSwap_Main { + rot.ValidationWarning("No swap set configured in Settings.") + } + return nil + } + + return &APLActionItemSwap{ + character: character, + swapSet: config.SwapSet, + } +} +func (action *APLActionItemSwap) IsReady(sim *Simulation) bool { + return (action.swapSet == proto.APLActionItemSwap_Main) == action.character.ItemSwap.IsSwapped() +} +func (action *APLActionItemSwap) Execute(sim *Simulation) { + if sim.Log != nil { + action.character.Log(sim, "Item Swap to set %s", action.swapSet) + } + + if action.swapSet == proto.APLActionItemSwap_Main { + action.character.ItemSwap.reset(sim) + } else { + action.character.ItemSwap.SwapItems(sim, action.character.ItemSwap.slots, true) + } +} +func (action *APLActionItemSwap) String() string { + return fmt.Sprintf("Item Swap(%s)", action.swapSet) +} diff --git a/sim/core/character.go b/sim/core/character.go index 1017ef6de6..a509265df4 100644 --- a/sim/core/character.go +++ b/sim/core/character.go @@ -181,6 +181,10 @@ func NewCharacter(party *Party, partyIndex int, player *proto.Player) Character } character.PseudoStats.InFrontOfTarget = player.InFrontOfTarget + if player.EnableItemSwap && player.ItemSwap != nil { + character.enableItemSwap(player.ItemSwap, character.DefaultMeleeCritMultiplier(), character.DefaultMeleeCritMultiplier(), 0) + } + return character } diff --git a/sim/core/item_swaps.go b/sim/core/item_swaps.go index 7358fd2699..0d817eb874 100644 --- a/sim/core/item_swaps.go +++ b/sim/core/item_swaps.go @@ -19,6 +19,9 @@ type ItemSwap struct { ohCritMultiplier float64 rangedCritMultiplier float64 + // Which slots to actually swap. + slots []proto.ItemSlot + // Used for resetting initialEquippedItems [3]Item initialUnequippedItems [3]Item @@ -33,15 +36,49 @@ TODO All the extra parameters here and the code in multiple places for handling we'll need to figure out something cleaner as this will be quite error-prone */ -func (character *Character) EnableItemSwap(itemSwap *proto.ItemSwap, mhCritMultiplier float64, ohCritMultiplier float64, rangedCritMultiplier float64) { - items := getItems(itemSwap) +func (character *Character) enableItemSwap(itemSwap *proto.ItemSwap, mhCritMultiplier float64, ohCritMultiplier float64, rangedCritMultiplier float64) { + var slots []proto.ItemSlot + hasMhSwap := itemSwap.MhItem != nil && itemSwap.MhItem.Id != 0 + hasOhSwap := itemSwap.OhItem != nil && itemSwap.OhItem.Id != 0 + hasRangedSwap := itemSwap.RangedItem != nil && itemSwap.RangedItem.Id != 0 + + mainItems := [3]Item{ + character.Equipment[proto.ItemSlot_ItemSlotMainHand], + character.Equipment[proto.ItemSlot_ItemSlotOffHand], + character.Equipment[proto.ItemSlot_ItemSlotRanged], + } + swapItems := [3]Item{ + toItem(itemSwap.MhItem), + toItem(itemSwap.OhItem), + toItem(itemSwap.RangedItem), + } + + // Handle MH and OH together, because present MH + empty OH --> swap MH and unequip OH + if hasMhSwap || hasOhSwap { + if swapItems[0].ID != mainItems[0].ID { + slots = append(slots, proto.ItemSlot_ItemSlotMainHand) + } + if swapItems[1].ID != mainItems[1].ID { + slots = append(slots, proto.ItemSlot_ItemSlotOffHand) + } + } + if hasRangedSwap { + if swapItems[2].ID != mainItems[2].ID { + slots = append(slots, proto.ItemSlot_ItemSlotRanged) + } + } + + if len(slots) == 0 { + return + } character.ItemSwap = ItemSwap{ character: character, mhCritMultiplier: mhCritMultiplier, ohCritMultiplier: ohCritMultiplier, rangedCritMultiplier: rangedCritMultiplier, - unEquippedItems: items, + slots: slots, + unEquippedItems: swapItems, swapped: false, } } @@ -161,7 +198,10 @@ func (swap *ItemSwap) SwapItems(sim *Simulation, slots []proto.ItemSlot, useGCD } if useGCD { - character.SetGCDTimer(sim, 1500*time.Millisecond+sim.CurrentTime) + newGCD := sim.CurrentTime + 1500*time.Millisecond + if newGCD > character.GCD.ReadyAt() { + character.SetGCDTimer(sim, newGCD) + } } swap.swapped = !swap.swapped } @@ -261,18 +301,6 @@ func getInitialEquippedItems(character *Character) [3]Item { return items } -func getItems(itemSwap *proto.ItemSwap) [3]Item { - var items [3]Item - - if itemSwap != nil { - items[0] = toItem(itemSwap.MhItem) - items[1] = toItem(itemSwap.OhItem) - items[2] = toItem(itemSwap.RangedItem) - } - - return items -} - func toItem(itemSpec *proto.ItemSpec) Item { if itemSpec == nil { return Item{} diff --git a/sim/deathknight/dps/dps_deathknight.go b/sim/deathknight/dps/dps_deathknight.go index 4b81b5185a..e342615622 100644 --- a/sim/deathknight/dps/dps_deathknight.go +++ b/sim/deathknight/dps/dps_deathknight.go @@ -75,10 +75,6 @@ func NewDpsDeathknight(character *core.Character, player *proto.Player) *DpsDeat AutoSwingMelee: true, }) - if dpsDk.Talents.SummonGargoyle && dpsDk.Rotation.UseGargoyle && dpsDk.Rotation.EnableWeaponSwap { - dpsDk.EnableItemSwap(dpsDk.Rotation.WeaponSwap, dpsDk.DefaultMeleeCritMultiplier(), dpsDk.DefaultMeleeCritMultiplier(), 0) - } - dpsDk.br.dk = dpsDk dpsDk.sr.dk = dpsDk dpsDk.ur.dk = dpsDk diff --git a/sim/shaman/enhancement/enhancement.go b/sim/shaman/enhancement/enhancement.go index 70502026ef..51f19b2b32 100644 --- a/sim/shaman/enhancement/enhancement.go +++ b/sim/shaman/enhancement/enhancement.go @@ -62,10 +62,6 @@ func NewEnhancementShaman(character *core.Character, options *proto.Player) *Enh enh.ApplySyncType(enhOptions.Options.SyncType) - if enh.Totems.UseFireElemental && enhOptions.Rotation.EnableItemSwap { - enh.EnableItemSwap(enhOptions.Rotation.ItemSwap, enh.DefaultMeleeCritMultiplier(), enh.DefaultMeleeCritMultiplier(), 0) - } - if enhOptions.Rotation.LightningboltWeave { enh.maelstromWeaponMinStack = enhOptions.Rotation.MaelstromweaponMinStack } else { @@ -136,9 +132,12 @@ func (enh *EnhancementShaman) Initialize() { }) } enh.DelayDPSCooldowns(3 * time.Second) - enh.RegisterPrepullAction(-time.Second, func(sim *core.Simulation) { - enh.ItemSwap.SwapItems(sim, []proto.ItemSlot{proto.ItemSlot_ItemSlotMainHand, proto.ItemSlot_ItemSlotOffHand}, false) - }) + + if !enh.IsUsingAPL { + enh.RegisterPrepullAction(-time.Second, func(sim *core.Simulation) { + enh.ItemSwap.SwapItems(sim, []proto.ItemSlot{proto.ItemSlot_ItemSlotMainHand, proto.ItemSlot_ItemSlotOffHand}, false) + }) + } } func (enh *EnhancementShaman) Reset(sim *core.Simulation) { diff --git a/sim/shaman/fire_elemental_totem.go b/sim/shaman/fire_elemental_totem.go index e130709280..8402a2c2ac 100644 --- a/sim/shaman/fire_elemental_totem.go +++ b/sim/shaman/fire_elemental_totem.go @@ -56,7 +56,7 @@ func (shaman *Shaman) registerFireElementalTotem() { shaman.FireElemental.EnableWithTimeout(sim, shaman.FireElemental, fireTotemDuration) //TODO handle more then one swap if the fight is greater then 5 mins, for now will just do the one. - if shaman.FireElementalTotem.SpellMetrics[target.Index].Casts == 1 { + if !shaman.IsUsingAPL && shaman.FireElementalTotem.SpellMetrics[target.Index].Casts == 1 { shaman.ItemSwap.SwapItems(sim, []proto.ItemSlot{proto.ItemSlot_ItemSlotMainHand, proto.ItemSlot_ItemSlotOffHand}, true) } diff --git a/sim/warlock/warlock.go b/sim/warlock/warlock.go index dba2735902..8666c6f8b9 100644 --- a/sim/warlock/warlock.go +++ b/sim/warlock/warlock.go @@ -203,8 +203,10 @@ func (warlock *Warlock) Reset(sim *core.Simulation) { warlock.petStmBonusSP = 0 } - warlock.ItemSwap.SwapItems(sim, []proto.ItemSlot{proto.ItemSlot_ItemSlotMainHand, - proto.ItemSlot_ItemSlotOffHand, proto.ItemSlot_ItemSlotRanged}, false) + if !warlock.IsUsingAPL { + warlock.ItemSwap.SwapItems(sim, []proto.ItemSlot{proto.ItemSlot_ItemSlotMainHand, + proto.ItemSlot_ItemSlotOffHand, proto.ItemSlot_ItemSlotRanged}, false) + } warlock.corrRefreshList = make([]time.Duration, len(warlock.Env.Encounter.TargetUnits)) warlock.setupCooldowns(sim) } @@ -236,10 +238,6 @@ func NewWarlock(character *core.Character, options *proto.Player) *Warlock { warlock.Infernal = warlock.NewInfernal() - if warlock.Rotation.Type == proto.Warlock_Rotation_Affliction && warlock.Rotation.EnableWeaponSwap { - warlock.EnableItemSwap(warlock.Rotation.WeaponSwap, 1, 1, 1) - } - warlock.applyWeaponImbue() wotlk.ConstructValkyrPets(&warlock.Character) diff --git a/sim/warlock/warlock_test.go b/sim/warlock/warlock_test.go index f84367a26d..632ced5f5c 100644 --- a/sim/warlock/warlock_test.go +++ b/sim/warlock/warlock_test.go @@ -22,9 +22,6 @@ func TestAffliction(t *testing.T) { Glyphs: AfflictionGlyphs, Consumes: FullConsumes, SpecOptions: core.SpecOptionsCombo{Label: "Affliction Warlock", SpecOptions: DefaultAfflictionWarlock}, - OtherSpecOptions: []core.SpecOptionsCombo{ - {Label: "AffItemSwap", SpecOptions: afflictionItemSwap}, - }, ItemFilter: ItemFilter, })) @@ -129,13 +126,6 @@ var DefaultAfflictionWarlock = &proto.Player_Warlock{ }, } -var afflictionItemSwap = &proto.Player_Warlock{ - Warlock: &proto.Warlock{ - Options: defaultAfflictionOptions, - Rotation: afflictionItemSwapRotation, - }, -} - var defaultAfflictionOptions = &proto.Warlock_Options{ Armor: proto.Warlock_Options_FelArmor, Summon: proto.Warlock_Options_Felhunter, diff --git a/ui/core/components/gear_picker.tsx b/ui/core/components/gear_picker.tsx index 0072f51e65..346a6103e4 100644 --- a/ui/core/components/gear_picker.tsx +++ b/ui/core/components/gear_picker.tsx @@ -307,7 +307,6 @@ export class IconItemSwapPicker extends Input< private readonly socketsContainerElem: HTMLElement; private readonly player: Player; private readonly slot: ItemSlot; - private readonly gear: ItemSwapGear; // All items and enchants that are eligible for this slot private _items: Array = []; @@ -319,7 +318,6 @@ export class IconItemSwapPicker extends Input< this.player = player; this.config = config; this.slot = slot; - this.gear = this.player.getItemSwapGear(); this.iconAnchor = document.createElement('a'); this.iconAnchor.classList.add('icon-picker-button'); @@ -333,70 +331,35 @@ export class IconItemSwapPicker extends Input< player.sim.waitForInit().then(() => { this._items = this.player.getItems(slot); this._enchants = this.player.getEnchants(slot); - this.addItemSpecToGear(); const gearData = { equipItem: (eventID: EventID, equippedItem: EquippedItem | null) => { - this.gear.equipItem(this.slot, equippedItem, player.canDualWield2H()); + let isg = this.player.getItemSwapGear(); + this.player.setItemSwapGear(eventID, isg.withEquippedItem(this.slot, equippedItem, player.canDualWield2H())); this.inputChanged(eventID); }, - getEquippedItem: () => this.gear.getEquippedItem(this.slot), + getEquippedItem: () => this.player.getItemSwapGear().getEquippedItem(this.slot), changeEvent: config.changedEvent(player), } - const onClickStart = (event: Event) => { + this.iconAnchor.addEventListener('click', (event: Event) => { event.preventDefault(); new SelectorModal(simUI.rootElem, simUI, this.player, { selectedTab: SelectorModalTabs.Items, slot: this.slot, - equippedItem: this.gear.getEquippedItem(slot), + equippedItem: this.player.getItemSwapGear().getEquippedItem(slot), eligibleItems: this._items, eligibleEnchants: this._enchants, gearData: gearData, - }) - }; - - this.iconAnchor.addEventListener('click', onClickStart); + }); + }); }).finally(() => this.init()); - - } - - private addItemSpecToGear() { - const itemSwap = this.config.getValue(this.player) as unknown as ItemSwap - const fieldName = this.getFieldNameFromItemSlot(this.slot) - - if (!fieldName) - return; - - const itemSpec = itemSwap[fieldName] as unknown as ItemSpec - - if (!itemSpec) - return; - - const equippedItem = this.player.sim.db.lookupItemSpec(itemSpec); - - if (equippedItem) { - this.gear.equipItem(this.slot, equippedItem, this.player.canDualWield2H()); - } - } - - private getFieldNameFromItemSlot(slot: ItemSlot): keyof ItemSwap | undefined { - switch (slot) { - case ItemSlot.ItemSlotMainHand: - return 'mhItem'; - case ItemSlot.ItemSlotOffHand: - return 'ohItem'; - case ItemSlot.ItemSlotRanged: - return 'rangedItem'; - } - - return undefined; } getInputElem(): HTMLElement { return this.iconAnchor; } getInputValue(): ValueType { - return this.gear.toProto() as unknown as ValueType + return this.player.getItemSwapGear().toProto() as unknown as ValueType } setInputValue(newValue: ValueType): void { @@ -405,7 +368,7 @@ export class IconItemSwapPicker extends Input< this.iconAnchor.href = "#"; this.socketsContainerElem.innerText = ''; - const equippedItem = this.gear.getEquippedItem(this.slot); + const equippedItem = this.player.getItemSwapGear().getEquippedItem(this.slot); if (equippedItem) { this.iconAnchor.classList.add("active") @@ -415,7 +378,6 @@ export class IconItemSwapPicker extends Input< equippedItem.allSocketColors().forEach((socketColor, gemIdx) => { this.socketsContainerElem.appendChild(createGemContainer(socketColor, equippedItem.gems[gemIdx])); }); - } else { this.iconAnchor.classList.remove("active") } diff --git a/ui/core/components/individual_sim_ui/apl_actions.ts b/ui/core/components/individual_sim_ui/apl_actions.ts index d313b24ada..3157bf0893 100644 --- a/ui/core/components/individual_sim_ui/apl_actions.ts +++ b/ui/core/components/individual_sim_ui/apl_actions.ts @@ -19,12 +19,15 @@ import { APLActionActivateAura, APLActionCancelAura, APLActionTriggerICD, + APLActionItemSwap, + APLActionItemSwap_SwapSet as ItemSwapSet, APLValue, } from '../../proto/apl.js'; import { isHealingSpec } from '../../proto_utils/utils.js'; import { EventID } from '../../typed_event.js'; +import { itemSwapEnabledSpecs } from '../../individual_sim_ui.js'; import { Input, InputConfig } from '../input.js'; import { Player } from '../../player.js'; import { TextDropdownPicker } from '../dropdown_picker.js'; @@ -239,6 +242,22 @@ type ActionKindConfig = { factory: (parent: HTMLElement, player: Player, config: InputConfig, T>) => Input, T>, }; +function itemSwapSetFieldConfig(field: string): AplHelpers.APLPickerBuilderFieldConfig { + return { + field: field, + newValue: () => ItemSwapSet.Swap1, + factory: (parent, player, config) => new TextDropdownPicker(parent, player, { + ...config, + defaultLabel: 'None', + equals: (a, b) => a == b, + values: [ + { value: ItemSwapSet.Main, label: 'Main' }, + { value: ItemSwapSet.Swap1, label: 'Swapped' }, + ], + }), + }; +} + function actionFieldConfig(field: string): AplHelpers.APLPickerBuilderFieldConfig { return { field: field, @@ -532,4 +551,14 @@ const actionKindFactories: {[f in NonNullable]: ActionKindConfig< AplHelpers.actionIdFieldConfig('auraId', 'icd_auras'), ], }), + ['itemSwap']: inputBuilder({ + label: 'Item Swap', + submenu: ['Misc'], + shortDescription: 'Swaps items, using the swap set specified in Settings.', + includeIf: (player: Player, isPrepull: boolean) => itemSwapEnabledSpecs.includes(player.spec), + newValue: () => APLActionItemSwap.create(), + fields: [ + itemSwapSetFieldConfig('swapSet'), + ], + }), }; \ No newline at end of file diff --git a/ui/core/components/individual_sim_ui/rotation_tab.ts b/ui/core/components/individual_sim_ui/rotation_tab.ts index 58af78589f..74c6ff3ce6 100644 --- a/ui/core/components/individual_sim_ui/rotation_tab.ts +++ b/ui/core/components/individual_sim_ui/rotation_tab.ts @@ -6,7 +6,6 @@ import { import { APLRotation, APLRotation_Type as APLRotationType, - SimpleRotation, } from "../../proto/apl"; import { SavedRotation, @@ -21,7 +20,6 @@ import { NumberPicker } from "../number_picker"; import { BooleanPicker } from "../boolean_picker"; import { EnumPicker } from "../enum_picker"; import { Input } from "../input"; -import { ItemSwapPicker } from "../item_swap_picker"; import { CooldownsPicker } from "./cooldowns_picker"; import { CustomRotationPicker } from "./custom_rotation_picker"; import { SavedDataManager } from "../saved_data_manager"; @@ -181,8 +179,6 @@ export class RotationTab extends SimTab { new EnumPicker(sectionElem, this.simUI.player, inputConfig); } else if (inputConfig.type == 'customRotation') { new CustomRotationPicker(sectionElem, this.simUI, this.simUI.player, inputConfig); - } else if (inputConfig.type == 'itemSwap') { - new ItemSwapPicker(sectionElem, this.simUI, this.simUI.player, inputConfig) } }); } diff --git a/ui/core/components/individual_sim_ui/settings_tab.ts b/ui/core/components/individual_sim_ui/settings_tab.ts index f323474393..c5e59348de 100644 --- a/ui/core/components/individual_sim_ui/settings_tab.ts +++ b/ui/core/components/individual_sim_ui/settings_tab.ts @@ -5,6 +5,7 @@ import { Debuffs, HealingModel, IndividualBuffs, + ItemSwap, PartyBuffs, Profession, RaidBuffs, @@ -233,16 +234,24 @@ export class SettingsTab extends SimTab { !inputs.extraCssClasses?.includes('within-raid-sim-hide') || true ) - if (settings.length > 0) { + const swapSlots = this.simUI.individualConfig.itemSwapSlots || []; + if (settings.length > 0 || swapSlots.length > 0) { const contentBlock = new ContentBlock(this.column2, 'other-settings', { header: { title: 'Other' } }); - this.configureInputSection(contentBlock.bodyElement, this.simUI.individualConfig.otherInputs); + if (settings.length > 0) { + this.configureInputSection(contentBlock.bodyElement, this.simUI.individualConfig.otherInputs); + contentBlock.bodyElement.querySelectorAll('.input-root').forEach(elem => { + elem.classList.add('input-inline'); + }) + } - contentBlock.bodyElement.querySelectorAll('.input-root').forEach(elem => { - elem.classList.add('input-inline'); - }) + if (swapSlots.length > 0) { + const _itemSwapPicker = new ItemSwapPicker(contentBlock.bodyElement, this.simUI, this.simUI.player, { + itemSlots: swapSlots, + }); + } } } @@ -395,6 +404,8 @@ export class SettingsTab extends SimTab { consumes: player.getConsumes(), race: player.getRace(), professions: player.getProfessions(), + enableItemSwap: player.getEnableItemSwap(), + itemSwap: player.getItemSwapGear().toProto(), reactionTimeMs: player.getReactionTime(), channelClipDelayMs: player.getChannelClipDelay(), inFrontOfTarget: player.getInFrontOfTarget(), @@ -417,6 +428,8 @@ export class SettingsTab extends SimTab { simUI.player.setConsumes(eventID, newSettings.consumes || Consumes.create()); simUI.player.setRace(eventID, newSettings.race); simUI.player.setProfessions(eventID, newSettings.professions); + simUI.player.setEnableItemSwap(eventID, newSettings.enableItemSwap); + simUI.player.setItemSwapGear(eventID, simUI.sim.db.lookupItemSwap(newSettings.itemSwap || ItemSwap.create())); simUI.player.setReactionTime(eventID, newSettings.reactionTimeMs); simUI.player.setChannelClipDelay(eventID, newSettings.channelClipDelayMs); simUI.player.setInFrontOfTarget(eventID, newSettings.inFrontOfTarget); @@ -439,6 +452,7 @@ export class SettingsTab extends SimTab { this.simUI.player.consumesChangeEmitter, this.simUI.player.raceChangeEmitter, this.simUI.player.professionChangeEmitter, + this.simUI.player.itemSwapChangeEmitter, this.simUI.player.miscOptionsChangeEmitter, this.simUI.player.inFrontOfTargetChangeEmitter, this.simUI.player.distanceFromTargetChangeEmitter, @@ -468,8 +482,6 @@ export class SettingsTab extends SimTab { new EnumPicker(sectionElem, this.simUI.player, inputConfig); } else if (inputConfig.type == 'customRotation') { new CustomRotationPicker(sectionElem, this.simUI, this.simUI.player, inputConfig); - } else if (inputConfig.type == 'itemSwap') { - new ItemSwapPicker(sectionElem, this.simUI, this.simUI.player, inputConfig) } }); }; diff --git a/ui/core/components/input_helpers.ts b/ui/core/components/input_helpers.ts index 13e3cafa05..6feced79b3 100644 --- a/ui/core/components/input_helpers.ts +++ b/ui/core/components/input_helpers.ts @@ -1,13 +1,12 @@ import { ActionId } from '../proto_utils/action_id.js'; -import { CustomRotation, ItemSwap, ItemSlot } from '../proto/common.js'; +import { CustomRotation } from '../proto/common.js'; import { Spec } from '../proto/common.js'; import { Player } from '../player.js'; import { EventID, TypedEvent } from '../typed_event.js'; import { SpecOptions, SpecRotation } from '../proto_utils/utils.js'; -import { ItemSwapPickerConfig } from './item_swap_picker.js' import { CustomRotationPickerConfig } from './individual_sim_ui/custom_rotation_picker.js'; import { IconPickerConfig } from './icon_picker.js'; -import { IconEnumPicker, IconEnumPickerConfig, IconEnumValueConfig } from './icon_enum_picker.js'; +import { IconEnumPickerConfig, IconEnumValueConfig } from './icon_enum_picker.js'; import { EnumPickerConfig, EnumValueConfig } from './enum_picker.js'; import { BooleanPickerConfig } from './boolean_picker.js'; import { NumberPickerConfig } from './number_picker.js'; @@ -478,33 +477,3 @@ export function makeCustomRotationInput(config: Wrappe values: config.values, } } - - -export interface TypedItemSwapPickerConfig extends ItemSwapPickerConfig { - type: 'itemSwap', -} - -interface WrappedItemSwapInputConfig { - fieldName: keyof SpecRotation, - values: Array, - labelTooltip?: string, - getValue?: (player: Player) => ItemSwap, - setValue?: (eventID: EventID, player: Player, newValue: ItemSwap) => void, - showWhen?: (player: Player) => boolean -} - -export function MakeItemSwapInput(config: WrappedItemSwapInputConfig): TypedItemSwapPickerConfig { - return { - type: 'itemSwap', - getValue: config.getValue || ((player: Player) => (player.getRotation()[config.fieldName] as unknown as ItemSwap) || ItemSwap.create()), - setValue: config.setValue || ((eventID: EventID, player: Player, newValue: ItemSwap) => { - const options = player.getRotation(); - (options[config.fieldName] as unknown as ItemSwap) = newValue; - player.setRotation(eventID, options); - }), - itemSlots: config.values, - changedEvent: (player: Player) => player.rotationChangeEmitter, - labelTooltip: config.labelTooltip, - showWhen: config.showWhen, - } -} diff --git a/ui/core/components/item_swap_picker.ts b/ui/core/components/item_swap_picker.ts index 79aa709e38..57fba0c075 100644 --- a/ui/core/components/item_swap_picker.ts +++ b/ui/core/components/item_swap_picker.ts @@ -1,38 +1,55 @@ -import { Spec, ItemSlot, ItemSwap } from '../proto/common.js'; +import { Spec, ItemSlot, ItemSpec } from '../proto/common.js'; import { Player } from '../player.js'; import { Component } from './component.js'; import { IconItemSwapPicker } from './gear_picker.js' -import { Input, InputConfig } from './input.js' +import { Input } from './input.js' import { SimUI } from '../sim_ui.js'; -import { TypedEvent } from '../typed_event.js'; -import tippy from 'tippy.js'; +import { EventID, TypedEvent } from '../typed_event.js'; +import { BooleanPicker } from './boolean_picker.js'; -export interface ItemSwapPickerConfig extends InputConfig, T> { +export interface ItemSwapPickerConfig { itemSlots: Array; } -export class ItemSwapPicker extends Component { +export class ItemSwapPicker extends Component { + private readonly itemSlots: Array; + private readonly enableItemSwapPicker: BooleanPicker>; - constructor(parentElem: HTMLElement, simUI: SimUI, player: Player, config: ItemSwapPickerConfig) { + constructor(parentElem: HTMLElement, simUI: SimUI, player: Player, config: ItemSwapPickerConfig) { super(parentElem, 'item-swap-picker-root'); + this.itemSlots = config.itemSlots; + + this.enableItemSwapPicker = new BooleanPicker(this.rootElem, player, { + label: 'Enable Item Swapping', + labelTooltip: 'Allows configuring an Item Swap Set which is used with the Item Swap APL action.', + extraCssClasses: ['input-inline'], + getValue: (player: Player) => player.getEnableItemSwap(), + setValue(eventID: EventID, player: Player, newValue: boolean) { + player.setEnableItemSwap(eventID, newValue); + }, + changedEvent: (player: Player) => player.itemSwapChangeEmitter, + }); - this.rootElem.classList.add('input-root', 'input-inline') - - const label = document.createElement("label") - label.classList.add('form-label') - label.textContent = "Item Swap" - this.rootElem.appendChild(label); + const swapPickerContainer = document.createElement('div'); + this.rootElem.appendChild(swapPickerContainer); + const toggleEnabled = () => { + if (!player.getEnableItemSwap()) { + swapPickerContainer.classList.add('hide'); + } else { + swapPickerContainer.classList.remove('hide'); + } + }; + player.itemSwapChangeEmitter.on(toggleEnabled); + toggleEnabled(); - if (config.labelTooltip) { - tippy(label, { - 'content': config.labelTooltip, - ignoreAttributes: true, - }); - } + const label = document.createElement("label"); + label.classList.add('form-label'); + label.textContent = "Item Swap"; + swapPickerContainer.appendChild(label); let itemSwapContainer = Input.newGroupContainer(); - itemSwapContainer.classList.add('icon-group') - this.rootElem.appendChild(itemSwapContainer); + itemSwapContainer.classList.add('icon-group'); + swapPickerContainer.appendChild(itemSwapContainer); let swapButtonFragment = document.createElement('fragment'); swapButtonFragment.innerHTML = ` @@ -45,57 +62,41 @@ export class ItemSwapPicker extends Component { > - ` + `; const swapButton = swapButtonFragment.children[0] as HTMLElement; - itemSwapContainer.appendChild(swapButton) - - swapButton.addEventListener('click', event => { this.swapWithGear(player, config) }); - - config.changedEvent(player).on(eventID => { - const show = !config.showWhen || config.showWhen(player); - if (show) { - this.rootElem.classList.remove('hide'); - } else { - this.rootElem.classList.add('hide'); - } - }); - - config.itemSlots.forEach(itemSlot => { - new IconItemSwapPicker(itemSwapContainer, simUI, player, itemSlot, config); + itemSwapContainer.appendChild(swapButton); + + swapButton.addEventListener('click', _event => { this.swapWithGear(TypedEvent.nextEventID(), player) }); + + this.itemSlots.forEach(itemSlot => { + new IconItemSwapPicker(itemSwapContainer, simUI, player, itemSlot, { + getValue: (player: Player) => player.getItemSwapGear().getEquippedItem(itemSlot)?.asSpec() || ItemSpec.create(), + setValue: (eventID: EventID, player: Player, newValue: ItemSpec) => { + let curIsg = player.getItemSwapGear(); + curIsg = curIsg.withEquippedItem(itemSlot, player.sim.db.lookupItemSpec(newValue), player.canDualWield2H()) + player.setItemSwapGear(eventID, curIsg); + }, + changedEvent: (player: Player) => player.itemSwapChangeEmitter, + }); }); } - swapWithGear(player: Player, config: ItemSwapPickerConfig) { - let gear = player.getGear() - - const gearMap = new Map(); - const itemSwapMap = new Map(); - - config.itemSlots.forEach(slot => { - const gearItem = player.getGear().getEquippedItem(slot) - const swapItem = player.getItemSwapGear().getEquippedItem(slot) + swapWithGear(eventID: EventID, player: Player) { + let newGear = player.getGear(); + let newIsg = player.getItemSwapGear(); - gearMap.set(slot, gearItem) - itemSwapMap.set(slot, swapItem) - }) + this.itemSlots.forEach(slot => { + const gearItem = player.getGear().getEquippedItem(slot); + const swapItem = player.getItemSwapGear().getEquippedItem(slot); - itemSwapMap.forEach((item, slot) => { - gear = gear.withEquippedItem(slot, item, player.canDualWield2H()) - }) - - gearMap.forEach((item, slot) => { - player.getItemSwapGear().equipItem(slot, item, player.canDualWield2H()) - }) - - let eventID = TypedEvent.nextEventID() - player.setGear(eventID, gear) + newGear = newGear.withEquippedItem(slot, swapItem, player.canDualWield2H()) + newIsg = newIsg.withEquippedItem(slot, gearItem, player.canDualWield2H()) + }); - const itemSwap = player.getItemSwapGear().toProto() as unknown as T - config.setValue(eventID, player, itemSwap) + TypedEvent.freezeAllAndDo(() => { + player.setGear(eventID, newGear); + player.setItemSwapGear(eventID, newIsg); + }); } - } - - - diff --git a/ui/core/components/stat_weights_action.ts b/ui/core/components/stat_weights_action.ts index a27f8a4ee9..6257dd2e38 100644 --- a/ui/core/components/stat_weights_action.ts +++ b/ui/core/components/stat_weights_action.ts @@ -679,7 +679,7 @@ class EpWeightsMenu extends BaseModal { if (equippedItem == null) { return; } - const item = equippedItem.item; + //const item = equippedItem.item; const socketColors = equippedItem.curSocketColors(isBlacksmithing); // Compare whether its better to match sockets + get socket bonus, or just use best gems. diff --git a/ui/core/individual_sim_ui.ts b/ui/core/individual_sim_ui.ts index 85eba58ff0..35cbc6793c 100644 --- a/ui/core/individual_sim_ui.ts +++ b/ui/core/individual_sim_ui.ts @@ -72,8 +72,7 @@ export type InputConfig = ( InputHelpers.TypedBooleanPickerConfig | InputHelpers.TypedNumberPickerConfig | InputHelpers.TypedEnumPickerConfig | - InputHelpers.TypedCustomRotationPickerConfig | - InputHelpers.TypedItemSwapPickerConfig + InputHelpers.TypedCustomRotationPickerConfig ); export interface InputSection { @@ -144,6 +143,9 @@ export interface IndividualSimUIConfig extends PlayerConf includeBuffDebuffInputs: Array, excludeBuffDebuffInputs: Array, otherInputs: InputSection; + // Currently, many classes don't support item swapping, and only in certain slots. + // So enable it only where it is supported. + itemSwapSlots?: Array, // For when extra sections are needed (e.g. Shaman totems) customSections?: Array<(parentElem: HTMLElement, simUI: IndividualSimUI) => ContentBlock>, @@ -164,6 +166,8 @@ export function registerSpecConfig(spec: SpecType, config return config; } +export let itemSwapEnabledSpecs: Array = []; + export interface Settings { raidBuffs: RaidBuffs, partyBuffs: PartyBuffs, @@ -204,6 +208,10 @@ export abstract class IndividualSimUI extends SimUI { this.prevEpIterations = 0; this.prevEpSimResult = null; + if ((config.itemSwapSlots || []).length > 0 && !itemSwapEnabledSpecs.includes(player.spec)) { + itemSwapEnabledSpecs.push(player.spec); + } + this.addWarning({ updateOn: this.player.gearChangeEmitter, getContent: () => { diff --git a/ui/core/player.ts b/ui/core/player.ts index 468b69ac3b..8b3c4cf4c8 100644 --- a/ui/core/player.ts +++ b/ui/core/player.ts @@ -90,6 +90,7 @@ import { Raid } from './raid.js'; import { Sim } from './sim.js'; import { stringComparator, sum } from './utils.js'; import { ElementalShaman_Options, ElementalShaman_Options_ThunderstormRange, ElementalShaman_Rotation, ElementalShaman_Rotation_BloodlustUse, EnhancementShaman_Rotation, EnhancementShaman_Rotation_BloodlustUse, RestorationShaman_Rotation, RestorationShaman_Rotation_BloodlustUse } from './proto/shaman.js'; +import { Database } from './proto_utils/database.js'; export interface AuraStats { data: AuraStatsProto, @@ -241,7 +242,8 @@ export class Player { private bonusStats: Stats = new Stats(); private gear: Gear = new Gear({}); //private bulkEquipmentSpec: BulkEquipmentSpec = BulkEquipmentSpec.create(); - private itemSwapGear: ItemSwapGear = new ItemSwapGear(); + private enableItemSwap: boolean = false; + private itemSwapGear: ItemSwapGear = new ItemSwapGear({}); private race: Race; private profession1: Profession = 0; private profession2: Profession = 0; @@ -282,6 +284,7 @@ export class Player { readonly consumesChangeEmitter = new TypedEvent('PlayerConsumes'); readonly bonusStatsChangeEmitter = new TypedEvent('PlayerBonusStats'); readonly gearChangeEmitter = new TypedEvent('PlayerGear'); + readonly itemSwapChangeEmitter = new TypedEvent('PlayerItemSwap'); readonly professionChangeEmitter = new TypedEvent('PlayerProfession'); readonly raceChangeEmitter = new TypedEvent('PlayerRace'); readonly rotationChangeEmitter = new TypedEvent('PlayerRotation'); @@ -334,6 +337,7 @@ export class Player { this.consumesChangeEmitter, this.bonusStatsChangeEmitter, this.gearChangeEmitter, + this.itemSwapChangeEmitter, this.professionChangeEmitter, this.raceChangeEmitter, this.rotationChangeEmitter, @@ -637,45 +641,36 @@ export class Player { return this.gear; } + setGear(eventID: EventID, newGear: Gear) { + if (newGear.equals(this.gear)) + return; + + this.gear = newGear; + this.gearChangeEmitter.emit(eventID); + } + + getEnableItemSwap(): boolean { + return this.enableItemSwap; + } + + setEnableItemSwap(eventID: EventID, newEnableItemSwap: boolean) { + if (newEnableItemSwap == this.enableItemSwap) + return; + + this.enableItemSwap = newEnableItemSwap; + this.itemSwapChangeEmitter.emit(eventID); + } + getItemSwapGear(): ItemSwapGear { return this.itemSwapGear; } - setGear(eventID: EventID, newGear: Gear) { - if (newGear.equals(this.gear)) + setItemSwapGear(eventID: EventID, newItemSwapGear: ItemSwapGear) { + if (newItemSwapGear.equals(this.itemSwapGear)) return; - // Commented for now because the UI for this is weird. - //// If trinkets have changed and there were cooldowns assigned for those trinkets, - //// try to match them up and switch to the new trinkets. - //const newCooldowns = this.getCooldowns(); - //const oldTrinketIds = this.gear.getTrinkets().map(trinket => trinket?.asActionIdProto() || ActionIdProto.create()); - //const newTrinketIds = newGear.getTrinkets().map(trinket => trinket?.asActionIdProto() || ActionIdProto.create()); - - //for (let i = 0; i < 2; i++) { - // const oldTrinketId = oldTrinketIds[i]; - // const newTrinketId = newTrinketIds[i]; - // if (ActionIdProto.equals(oldTrinketId, ActionIdProto.create())) { - // continue; - // } - // if (ActionIdProto.equals(newTrinketId, ActionIdProto.create())) { - // continue; - // } - // if (ActionIdProto.equals(oldTrinketId, newTrinketId)) { - // continue; - // } - // newCooldowns.cooldowns.forEach(cd => { - // if (ActionIdProto.equals(cd.id, oldTrinketId)) { - // cd.id = newTrinketId; - // } - // }); - //} - - TypedEvent.freezeAllAndDo(() => { - this.gear = newGear; - this.gearChangeEmitter.emit(eventID); - //this.setCooldowns(eventID, newCooldowns); - }); + this.itemSwapGear = newItemSwapGear; + this.itemSwapChangeEmitter.emit(eventID); } /* @@ -1333,23 +1328,7 @@ export class Player { private toDatabase(): SimDatabase { const dbGear = this.getGear().toDatabase() const dbItemSwapGear = this.getItemSwapGear().toDatabase(); - return SimDatabase.create({ - items: dbGear.items.concat(dbItemSwapGear.items).filter(function(elem, index, self) { - return index === self.findIndex((t) => ( - t.id === elem.id - )); - }), - enchants: dbGear.enchants.concat(dbItemSwapGear.enchants).filter(function(elem, index, self) { - return index === self.findIndex((t) => ( - t.effectId === elem.effectId - )); - }), - gems: dbGear.gems.concat(dbItemSwapGear.gems).filter(function(elem, index, self) { - return index === self.findIndex((t) => ( - t.id === elem.id - )); - }), - }) + return Database.mergeSimDatabases(dbGear, dbItemSwapGear); } toProto(forExport?: boolean, forSimming?: boolean): PlayerProto { @@ -1365,6 +1344,8 @@ export class Player { equipment: gear.asSpec(), consumes: this.getConsumes(), bonusStats: this.getBonusStats().toProto(), + enableItemSwap: this.getEnableItemSwap(), + itemSwap: this.getItemSwapGear().toProto(), buffs: this.getBuffs(), cooldowns: (aplIsLaunched || (forSimming && aplRotation.type == APLRotationType.TypeAPL)) ? Cooldowns.create({ hpPercentForDefensives: this.getCooldowns().hpPercentForDefensives }) @@ -1430,6 +1411,8 @@ export class Player { this.setName(eventID, proto.name); this.setRace(eventID, proto.race); this.setGear(eventID, proto.equipment ? this.sim.db.lookupEquipmentSpec(proto.equipment) : new Gear({})); + this.setEnableItemSwap(eventID, proto.enableItemSwap); + this.setItemSwapGear(eventID, proto.itemSwap ? this.sim.db.lookupItemSwap(proto.itemSwap) : new ItemSwapGear({})); //this.setBulkEquipmentSpec(eventID, BulkEquipmentSpec.create()); // Do not persist the bulk equipment settings. this.setConsumes(eventID, proto.consumes || Consumes.create()); this.setBonusStats(eventID, Stats.fromProto(proto.bonusStats || UnitStats.create())); @@ -1555,6 +1538,33 @@ export class Player { } } } + + if (this.spec == Spec.SpecWarlock || this.spec == Spec.SpecDeathknight) { + const rot = this.getRotation() as SpecRotation; + if (rot.enableWeaponSwap) { + this.setEnableItemSwap(eventID, rot.enableWeaponSwap); + rot.enableWeaponSwap = false; + this.setRotation(eventID, rot as SpecRotation) + } + if (rot.weaponSwap) { + this.setItemSwapGear(eventID, this.sim.db.lookupItemSwap(rot.weaponSwap)); + rot.weaponSwap = undefined; + this.setRotation(eventID, rot as SpecRotation) + } + } + if (this.spec == Spec.SpecEnhancementShaman) { + const rot = this.getRotation() as SpecRotation; + if (rot.enableItemSwap) { + this.setEnableItemSwap(eventID, rot.enableItemSwap); + rot.enableItemSwap = false; + this.setRotation(eventID, rot as SpecRotation) + } + if (rot.itemSwap) { + this.setItemSwapGear(eventID, this.sim.db.lookupItemSwap(rot.itemSwap)); + rot.itemSwap = undefined; + this.setRotation(eventID, rot as SpecRotation) + } + } }); } @@ -1566,6 +1576,8 @@ export class Player { applySharedDefaults(eventID: EventID) { TypedEvent.freezeAllAndDo(() => { + this.setEnableItemSwap(eventID, false); + this.setItemSwapGear(eventID, new ItemSwapGear({})); this.setReactionTime(eventID, 200); this.setInFrontOfTarget(eventID, isTankSpec(this.spec)); this.setHealingModel(eventID, HealingModel.create({ diff --git a/ui/core/proto_utils/database.ts b/ui/core/proto_utils/database.ts index 885631b503..e9300f61e4 100644 --- a/ui/core/proto_utils/database.ts +++ b/ui/core/proto_utils/database.ts @@ -3,8 +3,10 @@ import { GemColor, ItemSlot, ItemSpec, + ItemSwap, PresetEncounter, PresetTarget, + SimDatabase, } from '../proto/common.js'; import { GlyphID, @@ -23,8 +25,9 @@ import { } from './utils.js'; import { gemEligibleForSocket, gemMatchesSocket } from './gems.js'; import { EquippedItem } from './equipped_item.js'; -import { Gear } from './gear.js'; +import { Gear, ItemSwapGear } from './gear.js'; import { CHARACTER_LEVEL } from '../constants/mechanics.js'; +import { distinct } from '../utils.js'; const dbUrlJson = '/wotlk/assets/database/db.json'; const dbUrlBin = '/wotlk/assets/database/db.bin'; @@ -216,6 +219,14 @@ export class Database { return new Gear(gearMap); } + lookupItemSwap(itemSwap: ItemSwap): ItemSwapGear { + return new ItemSwapGear({ + [ItemSlot.ItemSlotMainHand]: itemSwap.mhItem ? this.lookupItemSpec(itemSwap.mhItem): null, + [ItemSlot.ItemSlotOffHand]: itemSwap.ohItem ? this.lookupItemSpec(itemSwap.ohItem): null, + [ItemSlot.ItemSlotRanged]: itemSwap.rangedItem ? this.lookupItemSpec(itemSwap.rangedItem): null, + }); + } + enchantSpellIdToEffectId(enchantSpellId: number): number { const enchant = Object.values(this.enchantsBySlot).flat().find(enchant => enchant.spellId == enchantSpellId); return enchant ? enchant.effectId : 0; @@ -278,4 +289,12 @@ export class Database { return IconData.create(); } } + + public static mergeSimDatabases(db1: SimDatabase, db2: SimDatabase): SimDatabase { + return SimDatabase.create({ + items: distinct(db1.items.concat(db2.items), (a, b) => a.id == b.id), + enchants: distinct(db1.enchants.concat(db2.enchants), (a, b) => a.effectId == b.effectId), + gems: distinct(db1.gems.concat(db2.gems), (a, b) => a.id == b.id), + }) + } } diff --git a/ui/core/proto_utils/equipped_item.ts b/ui/core/proto_utils/equipped_item.ts index 601d65c032..2c22f2dda1 100644 --- a/ui/core/proto_utils/equipped_item.ts +++ b/ui/core/proto_utils/equipped_item.ts @@ -1,9 +1,7 @@ import { GemColor } from '../proto/common.js'; -import { ItemSlot } from '../proto/common.js'; import { ItemSpec } from '../proto/common.js'; import { ItemType } from '../proto/common.js'; import { Profession } from '../proto/common.js'; -import { Stat } from '../proto/common.js'; import { UIEnchant as Enchant, UIGem as Gem, diff --git a/ui/core/proto_utils/gear.ts b/ui/core/proto_utils/gear.ts index 8d6c164856..1c54371112 100644 --- a/ui/core/proto_utils/gear.ts +++ b/ui/core/proto_utils/gear.ts @@ -7,8 +7,7 @@ import { SimDatabase } from '../proto/common.js'; import { SimItem } from '../proto/common.js'; import { SimEnchant } from '../proto/common.js'; import { SimGem } from '../proto/common.js'; -import { WeaponType } from '../proto/common.js'; -import { arrayEquals, equalsOrBothNull } from '../utils.js'; +import { equalsOrBothNull } from '../utils.js'; import { distinct, getEnumValues } from '../utils.js'; import { isBluntWeaponType, isSharpWeaponType } from '../proto_utils/utils.js'; import { @@ -36,15 +35,51 @@ abstract class BaseGear { this.gear = gear as InternalGear; } + abstract getItemSlots(): ItemSlot[] + + equals(other: BaseGear): boolean { + return this.asArray().every((thisItem, slot) => equalsOrBothNull(thisItem, other.getEquippedItem(slot), (a, b) => a.equals(b))); + } + getEquippedItem(slot: ItemSlot): EquippedItem | null { - return this.gear[slot]; + return this.gear[slot] || null; } asArray(): Array { return Object.values(this.gear); } - removeUniqueGems(gear: InternalGear, newItem: EquippedItem) { + asMap(): Partial { + const newInternalGear: Partial = {}; + this.getItemSlots().map(slot => Number(slot) as ItemSlot).forEach(slot => { + newInternalGear[slot] = this.getEquippedItem(slot); + }); + return newInternalGear; + } + + /** + * Returns a new Gear set with the item equipped. + * + * Checks for validity and removes/exchanges items/gems as needed. + */ + protected withEquippedItemInternal(newSlot: ItemSlot, newItem: EquippedItem | null, canDualWield2H: boolean): Partial { + // Create a new identical set of gear + const newInternalGear = this.asMap(); + + if (newItem) { + this.removeUniqueGems(newInternalGear, newItem); + this.removeUniqueItems(newInternalGear, newItem); + } + + // Actually assign the new item. + newInternalGear[newSlot] = newItem; + + BaseGear.validateWeaponCombo(newInternalGear, newSlot, canDualWield2H); + + return newInternalGear; + } + + private removeUniqueGems(gear: Partial, newItem: EquippedItem) { // If the new item has unique gems, remove matching. newItem.gems .filter(gem => gem?.unique) @@ -55,7 +90,7 @@ abstract class BaseGear { }); } - removeUniqueItems(gear: InternalGear, newItem: EquippedItem) { + private removeUniqueItems(gear: Partial, newItem: EquippedItem) { if (newItem.item.unique) { this.getItemSlots().map(slot => Number(slot) as ItemSlot).forEach(slot => { if (gear[slot]?.item.id == newItem.item.id) { @@ -65,7 +100,7 @@ abstract class BaseGear { } } - validateWeaponCombo(gear: InternalGear, newSlot: ItemSlot, canDualWield2H: boolean) { + private static validateWeaponCombo(gear: Partial, newSlot: ItemSlot, canDualWield2H: boolean) { // Check for valid weapon combos. if (!validWeaponCombo(gear[ItemSlot.ItemSlotMainHand]?.item, gear[ItemSlot.ItemSlotOffHand]?.item, canDualWield2H)) { if (newSlot == ItemSlot.ItemSlotOffHand) { @@ -76,18 +111,24 @@ abstract class BaseGear { } } - abstract toDatabase(): SimDatabase - abstract getItemSlots(): ItemSlot[] + toDatabase(): SimDatabase { + const equippedItems = this.asArray().filter(ei => ei != null) as Array; + return SimDatabase.create({ + items: distinct(equippedItems.map(ei => BaseGear.itemToDB(ei.item))), + enchants: distinct(equippedItems.filter(ei => ei.enchant).map(ei => BaseGear.enchantToDB(ei.enchant!))), + gems: distinct(equippedItems.map(ei => (ei._gems.filter(g => g != null) as Array).map(gem => BaseGear.gemToDB(gem))).flat()), + }); + } - protected static itemToDB(item: Item): SimItem { + private static itemToDB(item: Item): SimItem { return SimItem.fromJson(Item.toJson(item), { ignoreUnknownFields: true }); } - protected static enchantToDB(enchant: Enchant): SimEnchant { + private static enchantToDB(enchant: Enchant): SimEnchant { return SimEnchant.fromJson(Enchant.toJson(enchant), { ignoreUnknownFields: true }); } - protected static gemToDB(gem: Gem): SimGem { + private static gemToDB(gem: Gem): SimGem { return SimGem.fromJson(Gem.toJson(gem), { ignoreUnknownFields: true }); } } @@ -107,30 +148,8 @@ export class Gear extends BaseGear { return getEnumValues(ItemSlot); } - equals(other: Gear): boolean { - return this.asArray().every((thisItem, slot) => equalsOrBothNull(thisItem, other.getEquippedItem(slot), (a, b) => a.equals(b))); - } - - /** - * Returns a new Gear set with the item equipped. - * - * Checks for validity and removes/exchanges items/gems as needed. - */ withEquippedItem(newSlot: ItemSlot, newItem: EquippedItem | null, canDualWield2H: boolean): Gear { - // Create a new identical set of gear - const newInternalGear = this.asMap(); - - if (newItem) { - this.removeUniqueGems(newInternalGear, newItem); - this.removeUniqueItems(newInternalGear, newItem); - } - - // Actually assign the new item. - newInternalGear[newSlot] = newItem; - - this.validateWeaponCombo(newInternalGear, newSlot, canDualWield2H); - - return new Gear(newInternalGear); + return new Gear(this.withEquippedItemInternal(newSlot, newItem, canDualWield2H)); } getTrinkets(): Array { @@ -154,14 +173,6 @@ export class Gear extends BaseGear { return relicItem!.item.id == itemId; } - asMap(): InternalGear { - const newInternalGear: Partial = {}; - getEnumValues(ItemSlot).map(slot => Number(slot) as ItemSlot).forEach(slot => { - newInternalGear[slot] = this.getEquippedItem(slot); - }); - return newInternalGear as InternalGear; - } - asSpec(): EquipmentSpec { return EquipmentSpec.create({ items: this.asArray().map(ei => ei ? ei.asSpec() : ItemSpec.create()), @@ -225,8 +236,6 @@ export class Gear extends BaseGear { } const gemColorCounts = this.gemColorCounts(isBlacksmithing); - - const gems = this.getAllGems(isBlacksmithing); return isMetaGemActive( metaGem, gemColorCounts.red, gemColorCounts.yellow, gemColorCounts.blue); @@ -347,15 +356,6 @@ export class Gear extends BaseGear { .map(ei => ei.getFailedProfessionRequirements(professions)) .flat(); } - - toDatabase(): SimDatabase { - const equippedItems = this.asArray().filter(ei => ei != null) as Array; - return SimDatabase.create({ - items: distinct(equippedItems.map(ei => Gear.itemToDB(ei.item))), - enchants: distinct(equippedItems.filter(ei => ei.enchant).map(ei => Gear.enchantToDB(ei.enchant!))), - gems: distinct(equippedItems.map(ei => (ei._gems.filter(g => g != null) as Array).map(gem => Gear.gemToDB(gem))).flat()), - }); - } } /** @@ -365,22 +365,16 @@ export class Gear extends BaseGear { */ export class ItemSwapGear extends BaseGear { - constructor() { - super({}); + constructor(gear: Partial) { + super(gear); } getItemSlots(): ItemSlot[] { return [ItemSlot.ItemSlotMainHand, ItemSlot.ItemSlotOffHand, ItemSlot.ItemSlotRanged]; } - equipItem(slot: ItemSlot, equippedItem: EquippedItem | null, canDualWield2H: boolean) { - if (equippedItem) { - this.removeUniqueGems(this.gear, equippedItem); - this.removeUniqueItems(this.gear, equippedItem); - } - - this.gear[slot] = equippedItem; - this.validateWeaponCombo(this.gear, slot, canDualWield2H); + withEquippedItem(newSlot: ItemSlot, newItem: EquippedItem | null, canDualWield2H: boolean): ItemSwapGear { + return new ItemSwapGear(this.withEquippedItemInternal(newSlot, newItem, canDualWield2H)); } toProto(): ItemSwap { @@ -390,13 +384,4 @@ export class ItemSwapGear extends BaseGear { rangedItem: this.gear[ItemSlot.ItemSlotRanged]?.asSpec(), }) } - - toDatabase(): SimDatabase { - const equippedItems = this.asArray().filter(ei => ei != null) as Array; - return SimDatabase.create({ - items: distinct(equippedItems.map(ei => ItemSwapGear.itemToDB(ei.item))), - enchants: distinct(equippedItems.filter(ei => ei.enchant).map(ei => ItemSwapGear.enchantToDB(ei.enchant!))), - gems: distinct(equippedItems.map(ei => ei.curEquippedGems(true).map(gem => ItemSwapGear.gemToDB(gem))).flat()), - }); - } } diff --git a/ui/core/utils.ts b/ui/core/utils.ts index 079aa1057c..d5e0d12b5a 100644 --- a/ui/core/utils.ts +++ b/ui/core/utils.ts @@ -68,6 +68,7 @@ export function intersection(a: Array, b: Array): Array { } // Returns a new array containing only distinct elements of arr. +// comparator should return true if the two elements are considered equal, and false otherwise. export function distinct(arr: Array, comparator?: (a: T, b: T) => boolean): Array { comparator = comparator || ((a: T, b: T) => a == b); const distinctArr: Array = []; diff --git a/ui/deathknight/inputs.ts b/ui/deathknight/inputs.ts index 9a8e0cf30e..84e520a551 100644 --- a/ui/deathknight/inputs.ts +++ b/ui/deathknight/inputs.ts @@ -370,23 +370,6 @@ export const FrostCustomRotation = InputHelpers.makeCustomRotationInput) => player.getRotation().frostRotationType == FrostRotationType.Custom, }); -export const EnableWeaponSwap = InputHelpers.makeRotationBooleanInput({ - fieldName: 'enableWeaponSwap', - label: 'Enable Weapon Swapping', - showWhen: (player: Player) => !player.getRotation().autoRotation && player.getTalents().summonGargoyle && player.getRotation().useGargoyle, -}) - -export const WeaponSwapInputs = InputHelpers.MakeItemSwapInput({ - fieldName: 'weaponSwap', - values: [ - ItemSlot.ItemSlotMainHand, - ItemSlot.ItemSlotOffHand, - //ItemSlot.ItemSlotRanged, Not support yet - ], - labelTooltip: 'Berserking will be equipped when FC has procced and Berserking is not active.

Black Magic will be prioed to swap during gargoyle or if gargoyle will be on CD for full BM Icd.', - showWhen: (player: Player) => !player.getRotation().autoRotation && player.getTalents().summonGargoyle && player.getRotation().useGargoyle && player.getRotation().enableWeaponSwap, -}) - export const NewDrwInput = InputHelpers.makeSpecOptionsBooleanInput({ fieldName: 'newDrw', label: 'PTR DRW Scaling', @@ -426,8 +409,6 @@ export const DeathKnightRotationConfig = { UseAutoRotation, BloodTapGhoulFrenzy, UseGargoyle, - EnableWeaponSwap, - WeaponSwapInputs, UseEmpowerRuneWeapon, UseDancingRuneWeapon, //NewDrwInput, diff --git a/ui/deathknight/sim.ts b/ui/deathknight/sim.ts index 27e429b130..0aebcc9f37 100644 --- a/ui/deathknight/sim.ts +++ b/ui/deathknight/sim.ts @@ -199,6 +199,7 @@ const SPEC_CONFIG = registerSpecConfig(Spec.SpecDeathknight, { OtherInputs.InFrontOfTarget, ], }, + itemSwapSlots: [ItemSlot.ItemSlotMainHand, ItemSlot.ItemSlotOffHand], encounterPicker: { // Whether to include 'Execute Duration (%)' in the 'Encounter' section of the settings tab. showExecuteProportion: false, diff --git a/ui/enhancement_shaman/inputs.ts b/ui/enhancement_shaman/inputs.ts index f973b8fba1..63b07fa739 100644 --- a/ui/enhancement_shaman/inputs.ts +++ b/ui/enhancement_shaman/inputs.ts @@ -1,7 +1,3 @@ -import { BooleanPicker } from '../core/components/boolean_picker.js'; -import { EnumPicker } from '../core/components/enum_picker.js'; -import { IconEnumPicker, IconEnumPickerConfig } from '../core/components/icon_enum_picker.js'; -import { IconPickerConfig } from '../core/components/icon_picker.js'; import { AirTotem, EarthTotem, @@ -18,7 +14,7 @@ import { EnhancementShaman_Rotation, EnhancementShaman_Rotation_BloodlustUse } from '../core/proto/shaman.js'; -import { CustomSpell, Spec, ItemSwap, ItemSlot } from '../core/proto/common.js'; +import { Spec } from '../core/proto/common.js'; import { ActionId } from '../core/proto_utils/action_id.js'; import { Player } from '../core/player.js'; @@ -78,27 +74,9 @@ export const SyncTypeInput = InputHelpers.makeSpecOptionsEnumInput({ - fieldName: 'itemSwap', - values: [ - ItemSlot.ItemSlotMainHand, - ItemSlot.ItemSlotOffHand, - //ItemSlot.ItemSlotRanged, Not support yet - ], - labelTooltip: 'Start with the swapped items until Fire Elemntal has been summoned, swap back to normal gear set. Weapons come pre enchanted with FT9 and FT10. If a slot is empty it will not be used in the swap', - showWhen: (player: Player) => (player.getSpecOptions().totems?.useFireElemental && player.getRotation().enableItemSwap) || false -}) - export const EnhancementShamanRotationConfig = { inputs: [ - InputHelpers.makeRotationBooleanInput({ - fieldName: 'enableItemSwap', - label: 'Enable Item Swapping', - labelTooltip: 'Toggle on/off item swapping', - showWhen: (player: Player) => player.getSpecOptions().totems?.useFireElemental || false - }), - EnhancmentItemSwapInputs, InputHelpers.makeRotationEnumInput({ fieldName: 'rotationType', label: 'Type', diff --git a/ui/enhancement_shaman/sim.ts b/ui/enhancement_shaman/sim.ts index fd7dd4746a..92e3cd8466 100644 --- a/ui/enhancement_shaman/sim.ts +++ b/ui/enhancement_shaman/sim.ts @@ -2,6 +2,7 @@ import { Class, Faction, IndividualBuffs, + ItemSlot, PartyBuffs, PseudoStat, Race, @@ -142,6 +143,7 @@ const SPEC_CONFIG = registerSpecConfig(Spec.SpecEnhancementShaman, { OtherInputs.InFrontOfTarget, ], }, + itemSwapSlots: [ItemSlot.ItemSlotMainHand, ItemSlot.ItemSlotOffHand], customSections: [ TotemsSection, FireElementalSection diff --git a/ui/warlock/inputs.ts b/ui/warlock/inputs.ts index 29f2380eb5..9368ca7997 100644 --- a/ui/warlock/inputs.ts +++ b/ui/warlock/inputs.ts @@ -11,7 +11,7 @@ import { Warlock_Options_Summon as Summon, } from '../core/proto/warlock.js'; -import { Spec, Glyphs, ItemSlot } from '../core/proto/common.js'; +import { Spec, Glyphs } from '../core/proto/common.js'; import { ActionId } from '../core/proto_utils/action_id.js'; import { Player } from '../core/player.js'; import { EventID, TypedEvent } from '../core/typed_event.js'; @@ -259,21 +259,5 @@ export const WarlockRotationConfig = { labelTooltip: 'Simulates raid doing damage to targets such that seed detonates immediately on cast.', showWhen: (player: Player) => player.getRotation().primarySpell == PrimarySpell.Seed, }), - InputHelpers.makeRotationBooleanInput({ - fieldName: 'enableWeaponSwap', - label: 'Enable Weapon Swapping', - labelTooltip: 'Toggle on/off item swapping', - showWhen: (player: Player) => player.getRotation().type == RotationType.Affliction - }), - InputHelpers.MakeItemSwapInput({ - fieldName: 'weaponSwap', - values: [ - ItemSlot.ItemSlotMainHand, - ItemSlot.ItemSlotOffHand, - ItemSlot.ItemSlotRanged, - ], - labelTooltip: 'Start with the swapped items until Corruption has been cast, then swap back to normal gear set. If a slot is empty it will not be used in the swap', - showWhen: (player: Player) => (player.getRotation().type == RotationType.Affliction && player.getRotation().enableWeaponSwap) || false - }), ], }; diff --git a/ui/warlock/sim.ts b/ui/warlock/sim.ts index 7a44e66465..efb04a3d60 100644 --- a/ui/warlock/sim.ts +++ b/ui/warlock/sim.ts @@ -1,6 +1,7 @@ import { Class, Faction, + ItemSlot, PartyBuffs, Race, Spec, @@ -131,6 +132,7 @@ const SPEC_CONFIG = registerSpecConfig(Spec.SpecWarlock, { OtherInputs.nibelungAverageCasts, ], }, + itemSwapSlots: [ItemSlot.ItemSlotMainHand, ItemSlot.ItemSlotOffHand, ItemSlot.ItemSlotRanged], encounterPicker: { // Whether to include 'Execute Duration (%)' in the 'Encounter' section of the settings tab. showExecuteProportion: false,