diff --git a/spec/System/TestAttacks_spec.lua b/spec/System/TestAttacks_spec.lua index d1608b3a55..a65a117053 100644 --- a/spec/System/TestAttacks_spec.lua +++ b/spec/System/TestAttacks_spec.lua @@ -441,12 +441,10 @@ describe("TestAttacks", function() build.itemsTab:CreateDisplayItemFromRaw(slowHighDmgMace) build.itemsTab:AddDisplayItem() - runCallback("OnFrame") build.itemsTab.slots["Weapon 1"]:SetSelItemId(build.itemsTab.items[1].id) build.itemsTab:CreateDisplayItemFromRaw(fastLowDmgMace) build.itemsTab:AddDisplayItem() - runCallback("OnFrame") build.itemsTab.slots["Weapon 2"]:SetSelItemId(build.itemsTab.items[2].id) build.configTab.input.customMods = [[ @@ -454,7 +452,6 @@ describe("TestAttacks", function() your hits can't be evaded ]] build.configTab:BuildModList() - runCallback("OnFrame") end local function harmonicMean(a, b) diff --git a/spec/System/TestImportReimport_spec.lua b/spec/System/TestImportReimport_spec.lua index 7f91e661fd..d06d293af5 100644 --- a/spec/System/TestImportReimport_spec.lua +++ b/spec/System/TestImportReimport_spec.lua @@ -262,6 +262,31 @@ Fireball 20/0 1 assert.are.equal(0, build.itemsTab.slots["Gloves Jewel Socket 2"].selItemId) end) + it("resolves imported default attack variants from equipment when weapon requirements are absent", function() + for _, case in ipairs({ + { false, false, "1HMace" }, -- Unarmed Facebreaker fallback + { "Wooden Club", false, "1HMace" }, + { "Wooden Club", "Splintered Tower Shield", "1HMace" }, + { "Wooden Club", "Wooden Club", "MaceMace" }, + { "Ironwood Greathammer", false, "2HMace" }, + { "Hardwood Spear", false, "Spear", "Spear Stab" }, + { "Hardwood Spear", "Leather Buckler", "SpearOffHand", "Spear Stab" }, + { "Hardwood Spear", "Splintered Tower Shield", "Spear", "Spear Stab" }, + }) do + newBuild() + local equipment = { } + if case[1] then table.insert(equipment, makeImportItem(case[1], "Weapon", "main-hand")) end + if case[2] then table.insert(equipment, makeImportItem(case[2], "Offhand", "off-hand")) end + local skill = makeGemEntry(false, case[4] or "Mace Strike", 1, { makeGemEntry(true, "Minion Pact I", 1) }) + build.importTab:ImportItemsAndSkills(buildImportPayload(equipment, { skill })) + local group = build.skillsTab.socketGroupList[1] + assert.are.equals(case[4] and 2 or 1, #build.skillsTab.socketGroupList) -- Spears also grant Spear Throw. + assert.are.equals("Metadata/Items/Gems/SkillGemPlayerDefault" .. case[3], group.gemList[1].gemData.id) + assert.are.equals(2, #group.gemList) + assert.are.equals("Minion Pact I", group.gemList[2].nameSpec) + end + end) + it("keeps an imported shield equipped with Bringer of Rain and a two-handed mace", function() build.importTab.controls.charImportItemsClearItems.state = true build.importTab.controls.charImportItemsClearSkills.state = true @@ -283,7 +308,133 @@ Fireball 20/0 1 assert.are_not.equal(0, build.itemsTab.slots["Weapon 1 Swap"].selItemId) assert.are_not.equal(0, build.itemsTab.slots["Weapon 2 Swap"].selItemId) - assert.are.equal("Metadata/Items/Gems/SkillGemPlayerDefault2HMace", build.skillsTab.socketGroupList[1].gemList[1].gemId) + assert.are.equal("Metadata/Items/Gems/SkillGemPlayerDefault2HMace", build.skillsTab.socketGroupList[1].gemList[1].gemData.id) + end) + + it("imports level-one Spear Throw entries into auto-levelled default attack groups", function() + local spear1 = makeImportItem("Soaring Spear", "Weapon", "spear-1") + local spear2 = makeImportItem("Grand Spear", "Weapon2", "spear-2") + for _, spear in ipairs({ spear1, spear2 }) do + spear.grantedSkills = { { name = "Grants Skill", values = { { "Spear Throw", 25 } } } } + end + local payload = buildImportPayload({ spear1, spear2 }, { + makeGemEntry(false, "Spear Throw", 1, { makeGemEntry(true, "Minion Pact I", 1) }), + makeGemEntry(false, "Spear Throw", 1), + }) + payload.level = 90 + build.importTab.controls.charImportItemsClearSkills.state = true + for _ = 1, 2 do + build.importTab:ImportItemsAndSkills(payload) + runCallback("OnFrame") + local groups, supports = 0, 0 + local slots = { } + for _, group in ipairs(build.skillsTab.socketGroupList) do + if group.gemList[1].skillId == "SpearThrowPlayer" then + groups = groups + 1 + supports = supports + #group.gemList - 1 + assert.are.equals("Default Attack", group.source) + assert.are.equals(20, group.gemList[1].level) + slots[group.slot] = true + end + end + assert.are.equals(2, groups) + assert.are.equals(1, supports) + assert.is_true(slots["Weapon 1"]) + assert.is_true(slots["Weapon 1 Swap"]) + end + end) + + it("assigns imported skills to their only valid weapon set immediately", function() + build.importTab.controls.charImportItemsClearItems.state = true + build.importTab.controls.charImportItemsClearSkills.state = true + + build.importTab:ImportItemsAndSkills(buildImportPayload({ + makeImportItem("Crude Bow", "Weapon", "test-import-bow"), + }, { + makeGemEntry(false, "Mirage Archer", 20, { + makeGemEntry(false, "Ice Shot", 20), + }), + })) + + local mirageArcher = build.skillsTab.socketGroupList[1] + assert.is_true(mirageArcher.set1) + assert.is_false(mirageArcher.set2) + end) + + it("attaches imported item-granted skills to their generated source group", function() + build.importTab.controls.charImportItemsClearItems.state = true + build.importTab.controls.charImportItemsClearSkills.state = true + + local sceptre = makeImportItem("Stoic Sceptre", "Offhand") + sceptre.explicitMods = { "Grants Skill: Level 20 Azmerian Wolf" } + build.importTab:ImportItemsAndSkills(buildImportPayload({ sceptre }, { + makeGemEntry(false, "Azmerian Wolf", 20, { + makeGemEntry(true, "Feeding Frenzy II", 1), + }), + })) + runCallback("OnFrame") + + local wolfGroups = { } + for _, socketGroup in ipairs(build.skillsTab.socketGroupList) do + if socketGroup.gemList[1] and socketGroup.gemList[1].nameSpec == "Azmerian Wolf" then + table.insert(wolfGroups, socketGroup) + end + end + assert.are.equal(1, #wolfGroups) + assert.are.equal("Weapon 2", wolfGroups[1].slot) + assert.is_not_nil(wolfGroups[1].sourceItem) + assert.is_true(wolfGroups[1].gemList[1].fromItem) + assert.are.equal("Feeding Frenzy II", wolfGroups[1].gemList[2].nameSpec) + end) + + it("downlevels item-granted skills to meet attribute requirements", function() + build.importTab.controls.charImportItemsClearItems.state = true + build.importTab.controls.charImportItemsClearSkills.state = true + build.configTab.modList:NewMod("Str", "BASE", 98 - build.calcsTab.mainOutput.Str, "Test") + build.configTab.modList:NewMod("Int", "BASE", 107 - build.calcsTab.mainOutput.Int, "Test") + local sceptre = makeImportItem("Stoic Sceptre", "Offhand") + sceptre.explicitMods = { "Grants Skill: Level 19 Discipline" } + local payload = buildImportPayload({ sceptre }, { + makeGemEntry(false, "Discipline", 19), + }) + payload.level = 94 + build.importTab:ImportItemsAndSkills(payload) + runCallback("OnFrame") + + local discipline = build.skillsTab.socketGroupList[1].gemList[1] + assert.are.equal(19, discipline.sourceLevel) + assert.are.equal(18, discipline.level) + end) + + it("attaches imported auto-levelled tree skills to their generated source group", function() + build.importTab.controls.charImportItemsClearItems.state = true + build.importTab.controls.charImportItemsClearSkills.state = true + build.characterLevel = 94 + local wildProtectorNode = build.spec.nodes[62743] + wildProtectorNode.alloc = true + wildProtectorNode.allocMode = 0 + build.spec.allocNodes[wildProtectorNode.id] = wildProtectorNode + + local payload = buildImportPayload({}, { + makeGemEntry(false, "Wild Protector", 20, { + makeGemEntry(true, "Feeding Frenzy II", 1), + }), + }) + payload.level = 94 + build.importTab:ImportItemsAndSkills(payload) + runCallback("OnFrame") + + local protectorGroups = { } + for _, socketGroup in ipairs(build.skillsTab.socketGroupList) do + if socketGroup.gemList[1] and socketGroup.gemList[1].nameSpec == "Wild Protector" then + table.insert(protectorGroups, socketGroup) + end + end + assert.are.equal(1, #protectorGroups) + assert.are.equal(wildProtectorNode, protectorGroups[1].sourceNode) + assert.is_true(protectorGroups[1].gemList[1].fromTree) + assert.are.equal(20, protectorGroups[1].gemList[1].level) + assert.are.equal("Feeding Frenzy II", protectorGroups[1].gemList[2].nameSpec) end) it("uses unique database and rune levels when importing unique items from account data", function() diff --git a/spec/System/TestItemMods_spec.lua b/spec/System/TestItemMods_spec.lua index 18c1975a11..64c59511da 100644 --- a/spec/System/TestItemMods_spec.lua +++ b/spec/System/TestItemMods_spec.lua @@ -639,9 +639,8 @@ describe("TetsItemMods", function() ]]) build.itemsTab:AddDisplayItem() runCallback("OnFrame") - build.skillsTab:PasteSocketGroup("Leap Slam 20/0 1") - runCallback("OnFrame") - assert.True(build.calcsTab.calcsOutput.ChillEffectMod ~= nil) + local skill = build.calcsTab.mainEnv.player.mainSkill + assert.is_true(skill.skillModList:Flag(skill.weapon1Cfg, "CanChill")) end) it("ironbound", function() diff --git a/spec/System/TestItemParse_spec.lua b/spec/System/TestItemParse_spec.lua index eb2763fe38..f933272e66 100644 --- a/spec/System/TestItemParse_spec.lua +++ b/spec/System/TestItemParse_spec.lua @@ -15,6 +15,18 @@ describe("TestItemParse", function() assert.are.equals("UNIQUE", item.rarity) end) + it("ignores display-only Spear Throw grants without affecting levelled item skills", function() + for _, line in ipairs({ "Grants Skill: Spear Throw", "grants skill: spear throw" }) do + local mods, extra = modLib.parseMod(line) + assert.are.same({ }, mods) + assert.is_nil(extra) + end + local item = new("Item"):Item(raw("Grants Skill: Spear Throw\nGrants Skill: Level 5 Fireball", "Hardwood Spear")) + assert.are.equals(1, #item.grantedSkills) + assert.are.equals("FireballPlayer", item.grantedSkills[1].skillId) + assert.are.equals(5, item.grantedSkills[1].level) + end) + --it("Defence", function() -- local item = new("Item"):Item(raw("Armour: 25")) -- assert.are.equals(25, item.armourData.Armour) @@ -220,8 +232,7 @@ describe("TestItemParse", function() assert.are.equals(2, #item.implicitModLines) assert.are.equals("Bleeding you inflict deals Damage 11% faster", item.implicitModLines[1].line) assert.are.equals("Grants Skill: Spear Throw", item.implicitModLines[2].line) - assert.are.equals(1, #item.grantedSkills) - assert.are.equals("SpearThrowPlayer", item.grantedSkills[1].skillId) + assert.are.equals(0, #item.grantedSkills) assert.are.equals("Adds 39 to 62 Fire Damage", item.explicitModLines[1].line) assert.are.equals("Grants Skill: Level (1-20) Volatile Dead", data.itemBases["Volatile Wand"].implicit) diff --git a/spec/System/TestItemsTab_spec.lua b/spec/System/TestItemsTab_spec.lua index 19c6d80020..f8c3fc6b73 100644 --- a/spec/System/TestItemsTab_spec.lua +++ b/spec/System/TestItemsTab_spec.lua @@ -16,6 +16,41 @@ describe("TestItemsTab", function() runCallback("OnFrame") end) + it("compares weapons in the skill's assigned set, with Both following the Items tab", function() + build.skillsTab:PasteSocketGroup("Spark 20/0 1") + local group = build.skillsTab.displayGroup + build.mainSocketGroup = isValueInArray(build.skillsTab.socketGroupList, group) + local staff = new("Item"):Item("New Item\nWrapped Quarterstaff") + local slots + build.calcsTab.GetMiscCalculator = function() + return function(override) + table.insert(slots, override.repSlotName) + return { } + end, { } + end + build.AddStatComparesToTooltip = function() end + local slotOnlyTooltips = main.slotOnlyTooltips + for _, case in ipairs({ + { true, true, false, "Weapon 1" }, + { true, true, true, "Weapon 1 Swap" }, + { true, false, true, "Weapon 1" }, + { false, true, false, "Weapon 1 Swap" }, + }) do + group.set1, group.set2 = case[1], case[2] + build.itemsTab.activeItemSet.useSecondWeaponSet = case[3] + build.buildFlag = true + runCallback("OnFrame") + for _, slotOnly in ipairs({ false, true }) do + main.slotOnlyTooltips = slotOnly + slots = { } + local shownSlot = case[3] and "Weapon 1 Swap" or "Weapon 1" + build.itemsTab:AddItemTooltip(new("Tooltip"):Tooltip(), staff, slotOnly and shownSlot or nil, true) + main.slotOnlyTooltips = slotOnlyTooltips + assert.are.same(slotOnly and { case[4] } or { case[4], case[4] }, slots) + end + end + end) + it("keeps item tooltips for socket slots without note buttons", function() local item = new("Item"):Item([[Rarity: RARE Test Jewel diff --git a/spec/System/TestPassiveSpec_spec.lua b/spec/System/TestPassiveSpec_spec.lua index 416fff0a31..81c8172e1e 100644 --- a/spec/System/TestPassiveSpec_spec.lua +++ b/spec/System/TestPassiveSpec_spec.lua @@ -190,6 +190,36 @@ Item Level: 80 end end) + it("builds opposite-set skill contexts with radius jewels", function() + local nodeId, node = firstNormalJewelSocket(build.spec) + build.spec:AllocNode(node) + socketJewel(nodeId, [[ +Rarity: RARE +Test Mind +Time-Lost Sapphire +-------- +Radius: Large +-------- +Item Level: 80 +-------- ++10 to Intelligence +]]) + build.skillsTab:PasteSocketGroup("Spark 20/0 1") + build.skillsTab:PasteSocketGroup("Elemental Weakness 20/0 1") + build.skillsTab.socketGroupList[1].set1 = true + build.skillsTab.socketGroupList[1].set2 = false + build.skillsTab.socketGroupList[2].set1 = false + build.skillsTab.socketGroupList[2].set2 = true + build.mainSocketGroup = 1 + build.buildFlag = true + + assert.has_no.errors(function() + runCallback("OnFrame") + end) + assert.True(#build.calcsTab.mainEnv.radiusJewelList > 0) + assert.are.equals(2, build.calcsTab.mainEnv.weaponSetEnvs[2].weaponSet) + end) + it("does not apply jewel socket passive skill effect to jewels in item-granted Zarokh's Gift", function() local normalNodeId, normalNode = firstNormalJewelSocket(build.spec) build.spec:AllocNode(normalNode) diff --git a/spec/System/TestSkillsTab_spec.lua b/spec/System/TestSkillsTab_spec.lua index bc7371b949..9beea57621 100644 --- a/spec/System/TestSkillsTab_spec.lua +++ b/spec/System/TestSkillsTab_spec.lua @@ -382,6 +382,7 @@ describe("TestSkillsTab", function() it("Initializes new skill sets with default structure", function() assert.is_not_nil(build.skillsTab.skillSets[1]) assert.is_not_nil(build.skillsTab.skillSets[1].socketGroupList) + assert.is_not_nil(build.skillsTab.skillSets[1].removedSocketGroupList) assert.is_not_nil(build.skillsTab.skillSets[1].id) end) @@ -392,11 +393,213 @@ describe("TestSkillsTab", function() gemList = {} } build.skillsTab.skillSets[1].socketGroupList[1] = testSocketGroup + build.skillsTab.skillSets[1].removedSocketGroupList.cached = { + label = "Cached", + gemList = { { nameSpec = "Arcane Tempo I", level = 1 } }, + } local newSkillSet = build.skillsTab:CopySkillSet(1, "Copy Test") assert.are.equals("Original", newSkillSet.socketGroupList[1].label) assert.is_false(newSkillSet.socketGroupList[1].enabled) + assert.are.equals("Cached", newSkillSet.removedSocketGroupList.cached.label) + assert.are_not.equals(build.skillsTab.skillSets[1].removedSocketGroupList.cached, newSkillSet.removedSocketGroupList.cached) + end) + + it("Clones removed granted-skill groups in undo states", function() + local cachedGroup = { + label = "Granted", + enabled = true, + gemList = { { skillId = "GrantedSkill", level = 1, enabled = true } }, + } + build.skillsTab.skillSets[1].removedSocketGroupList.granted = cachedGroup + + local state = build.skillsTab:CreateUndoState() + local savedGroup = state.skillSets[1].removedSocketGroupList.granted + build.skillsTab.skillSets[1].removedSocketGroupList.granted = nil + cachedGroup.gemList[1].level = 2 + + assert.are_not.equal(cachedGroup, savedGroup) + assert.are_not.equal(cachedGroup.gemList[1], savedGroup.gemList[1]) + assert.are.equals(1, savedGroup.gemList[1].level) + end) + + it("persists removed granted-skill groups", function() + local key = "Item:Test\0Weapon 1\0Fireball" + build.skillsTab.skillSets[1].removedSocketGroupList[key] = { + label = "Cached", + enabled = true, + set1 = true, + set2 = false, + gemList = { { nameSpec = "Arcane Tempo I", level = 1, quality = 0, enabled = true, count = 1 } }, + } + local xml = { } + build.skillsTab:Save(xml) + build.skillsTab:Load(xml) + + local restored = build.skillsTab.skillSets[1].removedSocketGroupList[key] + assert.is_not_nil(restored) + assert.are.equals("Cached", restored.label) + assert.are.equals("Arcane Tempo I", restored.gemList[1].nameSpec) + assert.is_false(restored.set2) + end) + + it("keeps special generated source gems immutable", function() + local group = { + source = "Thorns", + enabled = true, + gemList = { { skillId = "ThornsPlayer", level = 1, quality = 0, enabled = true } }, + } + build.skillsTab:SetDisplayGroup(group) + + assert.is_false(build.skillsTab.gemSlots[1].delete:IsEnabled()) + assert.is_false(build.skillsTab.gemSlots[1].nameSpec:IsEnabled()) + end) + end) + + describe("Weapon set assignments", function() + it("does not calculate weapon-set validity before the first output revision", function() + build.skillsTab:PasteSocketGroup("Spark 20/0 1") + local group = build.skillsTab.socketGroupList[1] + build.outputRevision = nil + build.skillsTab.weaponSetValidityRevision = nil + build.skillsTab.weaponSetValidityCache = nil + + assert.has_no.errors(function() + build.skillsTab:SetDisplayGroup(group) + end) + assert.is_nil(build.skillsTab.weaponSetValidityCache) + end) + + it("calculates the inactive context only when checkbox validity is requested", function() + build.skillsTab:PasteSocketGroup("Spark 20/0 1") + local group = build.skillsTab.socketGroupList[1] + build.mainSocketGroup = 1 + build.buildFlag = true + runCallback("OnFrame") + build.skillsTab.weaponSetValidityCache = nil + build.skillsTab.weaponSetValidityRevision = nil + + local calcs = build.calcsTab.calcs + local initEnv = calcs.initEnv + local initCount = 0 + calcs.initEnv = function(...) + initCount = initCount + 1 + return initEnv(...) + end + local set1Valid = build.skillsTab:IsSocketGroupWeaponSetValid(group, 1) + local set2Valid = build.skillsTab:IsSocketGroupWeaponSetValid(group, 2) + calcs.initEnv = initEnv + + assert.is_true(set1Valid) + assert.is_true(set2Valid) + assert.are.equals(1, initCount) + end) + + it("keeps a set selectable when validity calculation fails", function() + build.skillsTab:PasteSocketGroup("Spark 20/0 1") + local group = build.skillsTab.socketGroupList[1] + local calcs = build.calcsTab.calcs + local initEnv = calcs.initEnv + local mainEnv = build.calcsTab.mainEnv + build.calcsTab.mainEnv = nil + build.skillsTab.weaponSetValidityCache = nil + build.skillsTab.weaponSetValidityRevision = nil + calcs.initEnv = function() + error("validation failed") + end + + local valid = build.skillsTab:IsSocketGroupWeaponSetValid(group, 1) + + calcs.initEnv = initEnv + build.calcsTab.mainEnv = mainEnv + assert.is_true(valid) + end) + + it("shows the selected assignment in sidebar and Calcs headings", function() + build.skillsTab:PasteSocketGroup("Spark 20/0 1") + local group = build.skillsTab.socketGroupList[1] + group.set1 = false + group.set2 = true + build.mainSocketGroup = 1 + build.calcsTab.input.skill_number = 1 + build.buildFlag = true + runCallback("OnFrame") + + assert.are.equals("^7Main Skill: Set 2", build.controls.mainSkillLabel:GetProperty("label")) + assert.are.equals("Socket Group: Set 2", build.calcsTab.socketGroupRow.label) + end) + + it("defaults legacy groups to both sets and preserves explicit false values", function() + build.skillsTab:LoadSkill({ elem = "Skill", attrib = { enabled = "true" } }, 1) + local legacy = build.skillsTab.skillSets[1].socketGroupList[#build.skillsTab.skillSets[1].socketGroupList] + assert.is_true(legacy.set1) + assert.is_true(legacy.set2) + + legacy.set1 = false + local xml = { } + build.skillsTab:Save(xml) + local savedGroup = xml[1][#xml[1]] + assert.are.equals("false", savedGroup.attrib.set1) + assert.are.equals("true", savedGroup.attrib.set2) + end) + + it("persists the fixed set of generated default attacks", function() + build.skillsTab:LoadSkill({ elem = "Skill", attrib = { + enabled = "true", + source = "Default Attack", + slot = "Weapon 1 Swap", + } }, 1) + local group = build.skillsTab.skillSets[1].socketGroupList[#build.skillsTab.skillSets[1].socketGroupList] + assert.is_false(group.set1) + assert.is_true(group.set2) + assert.is_true(build.skillsTab:IsSocketGroupWeaponSetLocked(group)) + + local xml = { } + build.skillsTab:Save(xml) + assert.are.equals("Default Attack", xml[1][#xml[1]].attrib.source) + assert.are.equals("Weapon 1 Swap", xml[1][#xml[1]].attrib.slot) + end) + + it("normalizes persisted groups with neither weapon set selected", function() + build.skillsTab:LoadSkill({ elem = "Skill", attrib = { enabled = "true", set1 = "false", set2 = "false" } }, 1) + local socketGroup = build.skillsTab.skillSets[1].socketGroupList[#build.skillsTab.skillSets[1].socketGroupList] + assert.is_true(socketGroup.set1) + assert.is_true(socketGroup.set2) + end) + + it("does not allow both weapon sets to be unchecked", function() + local group = { enabled = true, set1 = true, set2 = false, gemList = { } } + build.skillsTab:SetDisplayGroup(group) + build.skillsTab.controls.set1Enabled.state = false + build.skillsTab.controls.set1Enabled.changeFunc(false) + assert.is_true(build.skillsTab.controls.set1Enabled.state) + assert.is_true(group.set1) + assert.is_false(group.set2) + end) + + it("forces skills with the all-weapon-sets reservation stat to both", function() + local group = { + enabled = true, + set1 = true, + set2 = false, + gemList = { { skillId = "BlinkReservationPlayer", level = 1, quality = 0, enabled = true } }, + } + build.skillsTab:ProcessSocketGroup(group) + table.insert(build.skillsTab.socketGroupList, group) + build.mainSocketGroup = #build.skillsTab.socketGroupList + build.buildFlag = true + runCallback("OnFrame") + assert.is_true(group.forcedBoth) + assert.is_true(group.set1) + assert.is_true(group.set2) + end) + + it("hides item and tree granted effects from the gem selector", function() + for _, gemData in pairs(build.skillsTab.gemSlots[1].nameSpec.gems) do + assert.is_not_true(gemData.grantedEffect.fromItem) + assert.is_not_true(gemData.grantedEffect.fromTree) + end end) end) end) diff --git a/spec/System/TestSkills_spec.lua b/spec/System/TestSkills_spec.lua index 4c07b0a3c8..18b4862a7f 100644 --- a/spec/System/TestSkills_spec.lua +++ b/spec/System/TestSkills_spec.lua @@ -39,6 +39,24 @@ describe("TestSkills", function() assert.are.equals(expectedCount, count) end + local function assignWeaponSet(socketGroup, weaponSet) + socketGroup.set1 = weaponSet ~= 2 + socketGroup.set2 = weaponSet ~= 1 + end + + local function recalculate() + build.buildFlag = true + runCallback("OnFrame") + end + + local function findGrantedGroup(sourceType, source) + for _, socketGroup in ipairs(build.skillsTab.socketGroupList) do + if socketGroup[sourceType] == source then + return socketGroup + end + end + end + it("evaluates GemTag mod tags against active skill gem tags", function() local modDB = build.calcsTab.mainEnv.modDB @@ -295,6 +313,24 @@ describe("TestSkills", function() assert.is_true(#build.controls.mainSkillMinionSkill.list > 0, "minion skill dropdown should have entries") end) + it("shows minion skill controls for a skill assigned to the inactive weapon set", function() + build.skillsTab:PasteSocketGroup("Skeletal Sniper 20/0 1") + local socketGroup = build.skillsTab.socketGroupList[1] + socketGroup.set1 = false + socketGroup.set2 = true + build.skillsTab:PasteSocketGroup("Fireball 20/0 1") + build.skillsTab.socketGroupList[2].set2 = false + build.mainSocketGroup = 1 + build.itemsTab.activeItemSet.useSecondWeaponSet = false + + runCallback("OnFrame") + + assert.are.equals(2, build.calcsTab.mainEnv.weaponSet) + assert.is_not_nil(socketGroup.displaySkillList[1].minion) + assert.is_true(build.controls.mainSkillMinionSkill.shown) + assert.is_true(build.controls.mainSkillMinionSkillStatSet.shown) + end) + it("does not crash rendering socket tooltip when minion skill selection is missing", function() build.skillsTab:PasteSocketGroup("Skeletal Sniper 20/0 1") runCallback("OnFrame") @@ -649,7 +685,6 @@ describe("TestSkills", function() Warmonger Bow ]]) build.itemsTab:AddDisplayItem() - runCallback("OnFrame") build.skillsTab:PasteSocketGroup("Spiral Volley 20/0 1") runCallback("OnFrame") build.configTab.input.useFrenzyCharges = true @@ -672,7 +707,6 @@ describe("TestSkills", function() Warmonger Bow ]]) build.itemsTab:AddDisplayItem() - runCallback("OnFrame") build.skillsTab:PasteSocketGroup("Spiral Volley 20/0 1\nHeightened Charges 1/0 1") runCallback("OnFrame") build.configTab.input.useFrenzyCharges = true @@ -698,7 +732,6 @@ describe("TestSkills", function() Quality: 0 ]]) build.itemsTab:AddDisplayItem() - runCallback("OnFrame") build.skillsTab:PasteSocketGroup("Unearth 20/0 1") build.skillsTab:PasteSocketGroup("Leap Slam 20/0 1\nRage I 1/0 1") @@ -733,7 +766,6 @@ describe("TestSkills", function() Increases and Reductions to Minion Damage also affect you ]]) build.itemsTab:AddDisplayItem() - runCallback("OnFrame") build.skillsTab:PasteSocketGroup("Leap Slam 20/0 1\nRage I 1/0 1") runCallback("OnFrame") @@ -1055,7 +1087,6 @@ describe("TestSkills", function() Quality: 0 ]]) build.itemsTab:AddDisplayItem() - runCallback("OnFrame") build.skillsTab:PasteSocketGroup("Spiral Volley 20/0 1") runCallback("OnFrame") @@ -1073,7 +1104,6 @@ describe("TestSkills", function() Quality: 0 ]]) build.itemsTab:AddDisplayItem() - runCallback("OnFrame") build.skillsTab:PasteSocketGroup("Ice Shot 20/0 1") runCallback("OnFrame") @@ -1151,7 +1181,6 @@ describe("TestSkills", function() Quality: 0 ]]) build.itemsTab:AddDisplayItem() - runCallback("OnFrame") build.skillsTab:PasteSocketGroup("Lightning Arrow 1/0 1\nMinion Pact I 1/0 1") runCallback("OnFrame") @@ -1191,7 +1220,6 @@ describe("TestSkills", function() Quality: 0 ]]) build.itemsTab:AddDisplayItem() - runCallback("OnFrame") build.skillsTab:PasteSocketGroup("Spark 20/0 1") build.skillsTab:PasteSocketGroup("Killing Palm 20/0 1\nLightning Attunement 1/0 1\nLightning Exposure 1/0 1") @@ -1305,6 +1333,589 @@ describe("TestSkills", function() assert.True(build.configTab.varControls.conditionEnemyFireExposure:shown()) end) + it("uses an auxiliary curse's assigned weapon-set passives for a different-set main skill", function() + build.skillsTab:PasteSocketGroup("Spark 20/0 1") + build.skillsTab:PasteSocketGroup("Elemental Weakness 20/0 1") + local sparkGroup = build.skillsTab.socketGroupList[1] + local curseGroup = build.skillsTab.socketGroupList[2] + assignWeaponSet(sparkGroup, 1) + assignWeaponSet(curseGroup, 2) + build.mainSocketGroup = 1 + runCallback("OnFrame") + local baseDamage = build.calcsTab.mainOutput.AverageDamage + + local curseMagnitudeNode = build.spec.nodes[37991] + assert.are.equals("Curse Effect", curseMagnitudeNode.dn) + curseMagnitudeNode.alloc = true + curseMagnitudeNode.allocMode = 2 + build.spec.allocNodes[curseMagnitudeNode.id] = curseMagnitudeNode + recalculate() + + assert.True(build.calcsTab.mainOutput.AverageDamage > baseDamage) + assert.are.equals(1, build.calcsTab.mainEnv.weaponSet) + assert.are.equals(2, build.calcsTab.mainEnv.weaponSetEnvs[2].weaponSet) + end) + + it("uses the Items-tab context for a Both auxiliary skill", function() + build.skillsTab:PasteSocketGroup("Spark 20/0 1") + build.skillsTab:PasteSocketGroup("Elemental Weakness 20/0 1") + local sparkGroup = build.skillsTab.socketGroupList[1] + local curseGroup = build.skillsTab.socketGroupList[2] + assignWeaponSet(sparkGroup, 2) + assignWeaponSet(curseGroup) + build.itemsTab.activeItemSet.useSecondWeaponSet = false + build.mainSocketGroup = 1 + recalculate() + local baseDamage = build.calcsTab.mainOutput.AverageDamage + + local curseMagnitudeNode = build.spec.nodes[37991] + curseMagnitudeNode.alloc = true + curseMagnitudeNode.allocMode = 1 + build.spec.allocNodes[curseMagnitudeNode.id] = curseMagnitudeNode + recalculate() + + assert.are.equals(2, build.calcsTab.mainEnv.weaponSet) + assert.are.equals(1, curseGroup.usingSkillSet) + assert.True(build.calcsTab.mainOutput.AverageDamage > baseDamage) + end) + + it("only reserves resources for skills assigned to the main weapon set", function() + build.skillsTab:PasteSocketGroup("Spark 20/0 1") + build.skillsTab:PasteSocketGroup("War Banner 20/0 1") + local sparkGroup = build.skillsTab.socketGroupList[1] + local bannerGroup = build.skillsTab.socketGroupList[2] + assignWeaponSet(sparkGroup, 1) + assignWeaponSet(bannerGroup, 2) + build.mainSocketGroup = 1 + recalculate() + + assert.are.equals(1, build.calcsTab.mainEnv.weaponSet) + assert.are.equals(0, build.calcsTab.mainOutput.SpiritReserved) + + build.mainSocketGroup = 2 + recalculate() + assert.are.equals(2, build.calcsTab.mainEnv.weaponSet) + assert.True(build.calcsTab.mainOutput.SpiritReserved > 0) + end) + + it("resolves Both reservations against the Items-tab weapon set", function() + build.skillsTab:PasteSocketGroup("Spark 20/0 1") + build.skillsTab:PasteSocketGroup("War Banner 20/0 1") + local sparkGroup = build.skillsTab.socketGroupList[1] + local bannerGroup = build.skillsTab.socketGroupList[2] + assignWeaponSet(sparkGroup, 2) + assignWeaponSet(bannerGroup) + build.itemsTab.activeItemSet.useSecondWeaponSet = false + build.mainSocketGroup = 1 + recalculate() + + assert.are.equals(2, build.calcsTab.mainEnv.weaponSet) + assert.are.equals(1, bannerGroup.usingSkillSet) + assert.are.equals(0, build.calcsTab.mainOutput.SpiritReserved) + + build.itemsTab.activeItemSet.useSecondWeaponSet = true + recalculate() + assert.True(build.calcsTab.mainOutput.SpiritReserved > 0) + end) + + it("builds one fresh paired context per cross-set Full DPS pass", function() + build.skillsTab:PasteSocketGroup("Spark 20/0 1") + build.skillsTab:PasteSocketGroup("Fireball 20/0 1") + local sparkGroup = build.skillsTab.socketGroupList[1] + local fireballGroup = build.skillsTab.socketGroupList[2] + assignWeaponSet(sparkGroup, 1) + assignWeaponSet(fireballGroup, 2) + sparkGroup.includeInFullDPS = true + fireballGroup.includeInFullDPS = true + build.mainSocketGroup = 1 + recalculate() + + local calcs = build.calcsTab.calcs + local initEnv = calcs.initEnv + local totalContextCount = 0 + local setSpecificContextCount = 0 + calcs.initEnv = function(buildArg, mode, override, specEnv) + totalContextCount = totalContextCount + 1 + if override and override.weaponSet and not override.skipWeaponSetContexts then + setSpecificContextCount = setSpecificContextCount + 1 + end + return initEnv(buildArg, mode, override, specEnv) + end + local ok, err = pcall(calcs.calcFullDPS, build, "CALCULATOR", { }, { }) + calcs.initEnv = initEnv + assert.is_true(ok, err) + assert.are.equals(4, totalContextCount) + assert.are.equals(1, setSpecificContextCount) + end) + + it("evaluates Both Full DPS groups in the Items-tab weapon set", function() + build.skillsTab:PasteSocketGroup("Spark 20/0 1") + build.skillsTab:PasteSocketGroup("Fireball 20/0 1") + local sparkGroup = build.skillsTab.socketGroupList[1] + local fireballGroup = build.skillsTab.socketGroupList[2] + assignWeaponSet(sparkGroup, 2) + assignWeaponSet(fireballGroup) + fireballGroup.includeInFullDPS = true + build.itemsTab.activeItemSet.useSecondWeaponSet = false + build.mainSocketGroup = 1 + recalculate() + + local calcs = build.calcsTab.calcs + local perform = calcs.perform + local evaluatedSet + calcs.perform = function(env, ...) + if env.player.mainSkill and env.player.mainSkill.socketGroup == fireballGroup then + evaluatedSet = env.weaponSet + end + return perform(env, ...) + end + local ok, err = pcall(calcs.calcFullDPS, build, "CALCULATOR", { }, { }) + calcs.perform = perform + assert.is_true(ok, err) + assert.are.equals(1, evaluatedSet) + end) + + it("keeps unsupported auxiliary effects in their assigned weapon-set context", function() + build.skillsTab:PasteSocketGroup("Spark 20/0 1") + build.skillsTab:PasteSocketGroup("skillId:HisFoulEmergencePlayer His Foul Emergence 1/0 1") + local sparkGroup = build.skillsTab.socketGroupList[1] + local auxiliaryGroup = build.skillsTab.socketGroupList[2] + assignWeaponSet(sparkGroup, 1) + assignWeaponSet(auxiliaryGroup, 2) + build.mainSocketGroup = 1 + recalculate() + + local set2Env = build.calcsTab.mainEnv.weaponSetEnvs[2] + local auxiliarySkill + for _, activeSkill in ipairs(build.calcsTab.mainEnv.player.activeSkillList) do + if activeSkill.socketGroup == auxiliaryGroup then + auxiliarySkill = activeSkill + break + end + end + assert.is_not_nil(auxiliarySkill) + assert.are.equals(set2Env.player, auxiliarySkill.actor) + end) + + it("keeps auxiliary skills in their source context for cross-set Full DPS", function() + build.skillsTab:PasteSocketGroup("Spark 20/0 1") + build.skillsTab:PasteSocketGroup("Elemental Weakness 20/0 1") + build.skillsTab:PasteSocketGroup("Fireball 20/0 1") + local sparkGroup = build.skillsTab.socketGroupList[1] + local curseGroup = build.skillsTab.socketGroupList[2] + local fireballGroup = build.skillsTab.socketGroupList[3] + assignWeaponSet(sparkGroup, 1) + assignWeaponSet(curseGroup, 1) + assignWeaponSet(fireballGroup, 2) + fireballGroup.includeInFullDPS = true + build.mainSocketGroup = 1 + recalculate() + local baseFullDPS = build.calcsTab.mainOutput.FullDPS + + local curseMagnitudeNode = build.spec.nodes[37991] + curseMagnitudeNode.alloc = true + curseMagnitudeNode.allocMode = 1 + build.spec.allocNodes[curseMagnitudeNode.id] = curseMagnitudeNode + recalculate() + + assert.True(build.calcsTab.mainOutput.FullDPS > baseFullDPS) + end) + + it("uses the main skill's weapon set for non-skill sidebar values", function() + build.skillsTab:PasteSocketGroup("Spark 20/0 1") + local group = build.skillsTab.socketGroupList[1] + assignWeaponSet(group, 1) + build.itemsTab.activeItemSet.useSecondWeaponSet = false + runCallback("OnFrame") + local set1Strength = build.calcsTab.mainOutput.Str + + local strengthNode = build.spec.nodes[61472] + assert.are.equals("Strength", strengthNode.dn) + strengthNode.alloc = true + strengthNode.allocMode = 2 + build.spec.allocNodes[strengthNode.id] = strengthNode + assignWeaponSet(group, 2) + recalculate() + + assert.are.equals(2, build.calcsTab.mainEnv.weaponSet) + assert.are.equals(set1Strength + 8, build.calcsTab.mainOutput.Str) + assert.is_false(build.itemsTab.activeItemSet.useSecondWeaponSet) + + assignWeaponSet(group) + build.itemsTab.activeItemSet.useSecondWeaponSet = false + recalculate() + assert.are.equals(set1Strength, build.calcsTab.mainOutput.Str) + build.itemsTab.activeItemSet.useSecondWeaponSet = true + recalculate() + assert.are.equals(set1Strength + 8, build.calcsTab.mainOutput.Str) + assert.is_true(group.set1) + assert.is_true(group.set2) + end) + + it("disables unusable weapon sets and selects the only valid set", function() + local quarterstaff = new("Item"):Item("New Item\nRazor Quarterstaff") + build.itemsTab:AddItem(quarterstaff, true) + build.itemsTab.slots["Weapon 1"]:SetSelItemId(quarterstaff.id) + local bow = new("Item"):Item("New Item\nCrude Bow") + build.itemsTab:AddItem(bow, true) + build.itemsTab.slots["Weapon 1 Swap"]:SetSelItemId(bow.id) + build.skillsTab:PasteSocketGroup("Falling Thunder 20/0 1") + local group = build.skillsTab.socketGroupList[1] + assignWeaponSet(group) + recalculate() + + assert.is_true(group.set1) + assert.is_true(group.set2) + assert.is_nil(build.calcsTab.mainEnv.weaponSetEnvs) + assert.is_true(build.skillsTab:ReconcileSocketGroupWeaponSets(build.calcsTab.mainEnv, group)) + assert.is_false(group.set2) + assert.is_true(build.skillsTab:IsSocketGroupWeaponSetValid(group, 1)) + assert.is_false(build.skillsTab:IsSocketGroupWeaponSetValid(group, 2)) + build.skillsTab:SetDisplayGroup(group) + assert.is_true(group.set1) + assert.is_false(group.set2) + assert.is_false(build.skillsTab.controls.set2Enabled:IsEnabled()) + end) + + it("reconciles weapon-set assignments after equipment changes", function() + local quarterstaff = new("Item"):Item("New Item\nRazor Quarterstaff") + build.itemsTab:AddItem(quarterstaff, true) + local bow = new("Item"):Item("New Item\nCrude Bow") + build.itemsTab:AddItem(bow, true) + build.itemsTab.slots["Weapon 1"]:SetSelItemId(quarterstaff.id) + build.itemsTab.slots["Weapon 1 Swap"]:SetSelItemId(bow.id) + build.skillsTab:PasteSocketGroup("Falling Thunder 20/0 1") + local group = build.skillsTab.socketGroupList[1] + assignWeaponSet(group) + build.mainSocketGroup = 1 + recalculate() + assert.is_true(group.set1) + assert.is_true(group.set2) + assert.is_true(build.skillsTab:ReconcileSocketGroupWeaponSets(build.calcsTab.mainEnv, group)) + assert.is_true(group.set1) + assert.is_false(group.set2) + assert.are.equals(1, build.calcsTab.mainEnv.weaponSet) + + build.itemsTab.slots["Weapon 1"]:SetSelItemId(bow.id) + build.itemsTab.slots["Weapon 1 Swap"]:SetSelItemId(quarterstaff.id) + recalculate() + assert.is_false(group.set1) + assert.is_true(group.set2) + assert.are.equals(2, build.calcsTab.mainEnv.weaponSet) + + build.itemsTab.slots["Weapon 1 Swap"]:SetSelItemId(bow.id) + recalculate() + assert.is_true(group.set1) + assert.is_true(group.set2) + end) + + it("uses the fixed set immediately for a newly generated swapped-weapon skill", function() + local item = new("Item"):Item("New Item\nRazor Quarterstaff\nGrants Skill: Level 1 Fireball\n+2 to Level of all Spell Skills") + build.itemsTab:AddItem(item, true) + build.itemsTab.slots["Weapon 1 Swap"]:SetSelItemId(item.id) + build.itemsTab.activeItemSet.useSecondWeaponSet = false + recalculate() + + local grantedGroup = build.skillsTab.socketGroupList[1] + assert.is_not_nil(grantedGroup) + assert.are.equals(item, grantedGroup.sourceItem) + assert.is_false(grantedGroup.set1) + assert.is_true(grantedGroup.set2) + assert.are.equals(2, build.calcsTab.mainEnv.weaponSet) + assert.are.equals(3, grantedGroup.displaySkillList[1].activeEffect.level) + end) + + it("keeps both sets' default attacks and treats a quiver as unarmed", function() + local bow = new("Item"):Item("New Item\nCrude Bow") + local quiver = new("Item"):Item("New Item\nBroadhead Quiver") + local quarterstaff = new("Item"):Item("New Item\nRazor Quarterstaff") + build.itemsTab:AddItem(bow, true) + build.itemsTab:AddItem(quiver, true) + build.itemsTab:AddItem(quarterstaff, true) + build.itemsTab.slots["Weapon 1"]:SetSelItemId(bow.id) + build.itemsTab.slots["Weapon 2"]:SetSelItemId(quiver.id) + build.itemsTab.slots["Weapon 1 Swap"]:SetSelItemId(quarterstaff.id) + recalculate() + + local defaultAttack = findGrantedGroup("slot", "Weapon 1") + local mainGroup = build.skillsTab.socketGroupList[build.mainSocketGroup] + assert.are.equals("Bow Shot", defaultAttack.gemList[1].nameSpec) + assert.is_true(defaultAttack.set1) + assert.is_false(defaultAttack.set2) + assert.is_true(build.skillsTab:IsSocketGroupWeaponSetLocked(defaultAttack)) + table.insert(defaultAttack.gemList, { + nameSpec = "Minion Pact I", + level = 1, + quality = 0, + enabled = true, + enableGlobal1 = true, + count = 1, + }) + build.skillsTab:ProcessSocketGroup(defaultAttack) + + build.itemsTab.activeItemSet.useSecondWeaponSet = true + recalculate() + assert.are.equals(defaultAttack, findGrantedGroup("slot", "Weapon 1")) + assert.are.equals(mainGroup, build.skillsTab.socketGroupList[build.mainSocketGroup]) + assert.are.equals("Quarterstaff Strike", findGrantedGroup("slot", "Weapon 1 Swap").gemList[1].nameSpec) + + build.itemsTab.slots["Weapon 1"]:SetSelItemId(0) + recalculate() + assert.is_nil(findGrantedGroup("slot", "Weapon 1")) + + build.itemsTab.activeItemSet.useSecondWeaponSet = false + build.itemsTab.slots["Weapon 1"]:SetSelItemId(bow.id) + recalculate() + local restoredAttack = findGrantedGroup("slot", "Weapon 1") + assert.are.equals("Bow Shot", restoredAttack.gemList[1].nameSpec) + assert.are.equals("Minion Pact I", restoredAttack.gemList[2].nameSpec) + end) + + it("merges default attacks into the user group without losing settings or other active gems", function() + local bow = new("Item"):Item("New Item\nCrude Bow") + build.itemsTab:AddItem(bow, true) + build.itemsTab.slots["Weapon 1"]:SetSelItemId(bow.id) + recalculate() + local generated = findGrantedGroup("source", "Default Attack") + table.insert(generated.gemList, { nameSpec = "Spark", level = 1, quality = 0, enabled = true }) + build.skillsTab:ProcessSocketGroup(generated) + build.skillsTab:PasteSocketGroup("Label: My attack\nBow Shot 20/0 1\nMinion Pact I 1/0 1\nFireball 1/0 1") + local defaultAttack = build.skillsTab.displayGroup + defaultAttack.enabled = false + defaultAttack.includeInFullDPS = true + defaultAttack.groupCount = 3 + build.calcsTab.input.skill_number = #build.skillsTab.socketGroupList + recalculate() + + assert.are.equals(1, #build.skillsTab.socketGroupList) + assert.are.equals(defaultAttack, build.skillsTab.socketGroupList[build.mainSocketGroup]) + assert.are.equals(defaultAttack, build.skillsTab.socketGroupList[build.calcsTab.input.skill_number]) + assert.are.equals(defaultAttack, build.skillsTab.displayGroup) + assert.are.equals("My attack", defaultAttack.label) + assert.is_false(defaultAttack.enabled) + assert.is_true(defaultAttack.includeInFullDPS) + assert.are.equals(3, defaultAttack.groupCount) + assert.are.equals(4, #defaultAttack.gemList) + assert.are.equals("Minion Pact I", defaultAttack.gemList[2].nameSpec) + assert.are.equals("Fireball", defaultAttack.gemList[3].nameSpec) + assert.are.equals("Spark", defaultAttack.gemList[4].nameSpec) + recalculate() + assert.are.equals(4, #defaultAttack.gemList) + end) + + it("defers alternate-set default attacks until inspected or included in Full DPS", function() + build.characterLevel = 90 + local bow = new("Item"):Item("New Item\nCrude Bow") + local staff = new("Item"):Item("New Item\nWrapped Quarterstaff") + build.itemsTab:AddItem(bow, true) + build.itemsTab:AddItem(staff, true) + build.itemsTab.slots["Weapon 1"]:SetSelItemId(bow.id) + build.itemsTab.slots["Weapon 1 Swap"]:SetSelItemId(staff.id) + local calcs = build.calcsTab.calcs + local initEnv = calcs.initEnv + local otherSetCalls = 0 + calcs.initEnv = function(buildArg, mode, override, specEnv) + if override and override.weaponSet == 2 then otherSetCalls = otherSetCalls + 1 end + return initEnv(buildArg, mode, override, specEnv) + end + local ok, err = pcall(recalculate) + calcs.initEnv = initEnv + assert.is_true(ok, err) + assert.are.equals(0, otherSetCalls) + local env = build.calcsTab.mainEnv + assert.is_nil(env.weaponSetEnvs) + local staffGroup = findGrantedGroup("slot", "Weapon 1 Swap") + assert.are.equals(0, #staffGroup.displaySkillList) + build.skillsTab:AddSocketGroupTooltip(new("Tooltip"):Tooltip(), staffGroup) + assert.are.equals("Quarterstaff Strike", staffGroup.displaySkillList[1].activeEffect.grantedEffect.name) + local cachedDisplaySkills = staffGroup.displaySkillList + build.skillsTab:SetDisplayGroup(staffGroup) + assert.are.equals(cachedDisplaySkills, staffGroup.displaySkillList) + + build.characterLevel = 1 + recalculate() + build.skillsTab:SetDisplayGroup(staffGroup) + assert.are.equals(1, staffGroup.displaySkillList[1].activeEffect.level) + staffGroup.includeInFullDPS = true + recalculate() + assert.is_not_nil(build.calcsTab.mainEnv.weaponSetEnvs[2]) + end) + + it("preserves supports on item-granted skill groups when the item is re-equipped", function() + local item = new("Item"):Item("New Item\nRazor Quarterstaff\nGrants Skill: Level 1 Fireball\n+2 to Level of all Spell Skills") + build.itemsTab:AddItem(item, true) + build.itemsTab.slots["Weapon 1"]:SetSelItemId(item.id) + recalculate() + + local grantedGroup = findGrantedGroup("sourceItem", item) + assert.is_not_nil(grantedGroup) + assert.are.equals(3, grantedGroup.displaySkillList[1].activeEffect.level) + build.skillsTab:SetDisplayGroup(grantedGroup) + assert.are.equals("Fireball", grantedGroup.gemList[1].nameSpec) + assert.are.equals("Fireball", build.skillsTab.gemSlots[1].nameSpec.buf) + assert.is_false(build.skillsTab.gemSlots[1].nameSpec:IsEnabled()) + assert.is_false(build.skillsTab.controls.set1Enabled:IsEnabled()) + assert.is_false(build.skillsTab.controls.set2Enabled:IsEnabled()) + local sourceGem = grantedGroup.gemList[1] + sourceGem.quality = 20 + sourceGem.enabled = false + sourceGem.count = 2 + sourceGem.corrupted = true + sourceGem.corruptLevel = 1 + sourceGem.enableGlobal1 = false + sourceGem.enableGlobal2 = false + table.insert(grantedGroup.gemList, { + nameSpec = "Arcane Tempo I", + level = 1, + quality = 0, + enabled = true, + enableGlobal1 = true, + count = 1, + }) + build.skillsTab:ProcessSocketGroup(grantedGroup) + recalculate() + assert.are.equals(2, #grantedGroup.gemList) + + build.itemsTab.slots["Weapon 1"]:SetSelItemId(0) + recalculate() + for _, group in ipairs(build.skillsTab.socketGroupList) do + assert.are_not.equals(grantedGroup, group) + end + local _, cachedState = next(build.skillsTab.skillSets[build.skillsTab.activeSkillSetId].removedSocketGroupList) + assert.is_not_nil(cachedState) + assert.is_nil(cachedState.sourceItem) + assert.is_nil(cachedState.displaySkillList) + assert.are.equals("Arcane Tempo I", cachedState.gemList[1].nameSpec) + assert.is_nil(cachedState.gemList[1].displayEffect) + build.itemsTab.slots["Weapon 1"]:SetSelItemId(item.id) + recalculate() + + local restoredGroup = findGrantedGroup("sourceItem", item) + local restoredSourceGem = restoredGroup.gemList[1] + assert.are_not.equals(grantedGroup, restoredGroup) + assert.are.equals(2, #restoredGroup.gemList) + assert.are.equals("Arcane Tempo I", restoredGroup.gemList[2].nameSpec) + assert.same({ 0, true, 1, false, 0, true, true }, { + restoredSourceGem.quality, + restoredSourceGem.enabled, + restoredSourceGem.count, + restoredSourceGem.corrupted, + restoredSourceGem.corruptLevel, + restoredSourceGem.enableGlobal1, + restoredSourceGem.enableGlobal2, + }) + end) + + it("does not cache untouched generated skill groups", function() + local item = new("Item"):Item("New Item\nRazor Quarterstaff\nGrants Skill: Level 1 Fireball") + build.itemsTab:AddItem(item, true) + build.itemsTab.slots["Weapon 1"]:SetSelItemId(item.id) + recalculate() + build.itemsTab.slots["Weapon 1"]:SetSelItemId(0) + recalculate() + + assert.is_nil(next(build.skillsTab.skillSets[build.skillsTab.activeSkillSetId].removedSocketGroupList)) + end) + + it("preserves generated supports when the granted skill level changes", function() + build.characterLevel = 90 + build.characterLevelAutoMode = false + local item = new("Item"):Item("New Item\nRazor Quarterstaff\nGrants Skill: Level 1 Fireball\n+100 to Intelligence") + build.itemsTab:AddItem(item, true) + build.itemsTab.slots["Weapon 1"]:SetSelItemId(item.id) + recalculate() + + local grantedGroup = findGrantedGroup("sourceItem", item) + assert.are.equals(1, grantedGroup.gemList[1].sourceLevel) + assert.are.equals(1, grantedGroup.gemList[1].level) + table.insert(grantedGroup.gemList, { + nameSpec = "Arcane Tempo I", + level = 1, + quality = 0, + enabled = true, + enableGlobal1 = true, + count = 1, + }) + build.skillsTab:ProcessSocketGroup(grantedGroup) + item.grantedSkills[1].level = 2 + recalculate() + + assert.are.equals(grantedGroup, findGrantedGroup("sourceItem", item)) + assert.are.equals(2, grantedGroup.gemList[1].level) + assert.are.equals("Arcane Tempo I", grantedGroup.gemList[2].nameSpec) + end) + + it("updates the displayed level of a tree-granted skill when the character levels up", function() + build.characterLevel = 1 + build.characterLevelAutoMode = false + local node = build.spec.nodes[11641] + node.alloc = true + build.spec.allocNodes[node.id] = node + recalculate() + + local grantedGroup = findGrantedGroup("sourceNode", node) + build.skillsTab:SetDisplayGroup(grantedGroup) + assert.are.equals("1", build.skillsTab.gemSlots[1].level.buf) + + build.characterLevel = 3 + recalculate() + assert.are.equals(2, grantedGroup.gemList[1].level) + build.skillsTab:UpdateGemSlots() + assert.are.equals("2", build.skillsTab.gemSlots[1].level.buf) + end) + + it("allows weapon-set selection for skills granted by non-weapon items", function() + local item = new("Item"):Item("New Item\nChain Mail\nGrants Skill: Level 1 Fireball") + build.itemsTab:AddItem(item, true) + build.itemsTab.slots["Body Armour"]:SetSelItemId(item.id) + recalculate() + + local grantedGroup = findGrantedGroup("sourceItem", item) + assert.is_not_nil(grantedGroup) + build.skillsTab:SetDisplayGroup(grantedGroup) + assert.is_true(build.skillsTab.controls.set1Enabled:IsEnabled()) + assert.is_true(build.skillsTab.controls.set2Enabled:IsEnabled()) + + build.skillsTab.controls.set2Enabled.state = false + build.skillsTab.controls.set2Enabled.changeFunc(false) + recalculate() + + assert.is_true(grantedGroup.set1) + assert.is_false(grantedGroup.set2) + end) + + it("preserves supports on tree-granted skill groups when the node is reallocated", function() + local node = build.spec.nodes[11641] + node.alloc = true + build.spec.allocNodes[node.id] = node + recalculate() + + local grantedGroup = findGrantedGroup("sourceNode", node) + assert.is_not_nil(grantedGroup) + table.insert(grantedGroup.gemList, { + nameSpec = "Arcane Tempo I", + level = 1, + quality = 0, + enabled = true, + enableGlobal1 = true, + count = 1, + }) + build.skillsTab:ProcessSocketGroup(grantedGroup) + recalculate() + assert.are.equals(2, #grantedGroup.gemList) + + node.alloc = false + build.spec.allocNodes[node.id] = nil + recalculate() + node.alloc = true + build.spec.allocNodes[node.id] = node + recalculate() + + local restoredGroup = findGrantedGroup("sourceNode", node) + assert.are_not.equals(grantedGroup, restoredGroup) + assert.are.equals(2, #restoredGroup.gemList) + end) + it("Test Refraction III exposure scales from player armour", function() build.configTab.input.customMods = "+30000 to Armour" build.configTab.input.bannerPlanted = true @@ -1485,7 +2096,6 @@ describe("TestSkills", function() Quality: 0 ]]) build.itemsTab:AddDisplayItem() - runCallback("OnFrame") build.skillsTab:PasteSocketGroup("Boneshatter 20/0 1\nAncestral Call I 1/0 1") runCallback("OnFrame") @@ -1521,7 +2131,6 @@ describe("TestSkills", function() Quality: 0 ]]) build.itemsTab:AddDisplayItem() - runCallback("OnFrame") build.skillsTab:PasteSocketGroup("Leap Slam 20/0 1\nFist of War I 1/0 1") runCallback("OnFrame") build.configTab.input.customMods = "every second slam skill you use yourself is ancestrally boosted" @@ -1541,7 +2150,6 @@ describe("TestSkills", function() Quality: 0 ]]) build.itemsTab:AddDisplayItem() - runCallback("OnFrame") build.skillsTab:PasteSocketGroup("Leap Slam 20/0 1\nFist of War III 1/0 1") runCallback("OnFrame") build.configTab.input.customMods = "every second slam skill you use yourself is ancestrally boosted" diff --git a/src/Classes/CalcsTab.lua b/src/Classes/CalcsTab.lua index 4ed3fe035d..2efd2ac606 100644 --- a/src/Classes/CalcsTab.lua +++ b/src/Classes/CalcsTab.lua @@ -39,21 +39,22 @@ function CalcsTabClass:CalcsTab(build) t_insert(self.controls, self.controls.search) -- Special section for skill/mode selection - self:NewSection(3, "SkillSelect", 1, colorCodes.NORMAL, {{ defaultCollapsed = false, label = "View Skill Details", data = { - { label = "Socket Group", { controlName = "mainSocketGroup", - control = new("DropDownControl"):DropDownControl(nil, { 0, 0, 300, 16 }, nil, function(index, value) - self.input.skill_number = index - self:AddUndoState() - self.build.buildFlag = true - end) { - tooltipFunc = function(tooltip, mode, index, value) - local socketGroup = self.build.skillsTab.socketGroupList[index] - if socketGroup and tooltip:CheckForUpdate(socketGroup, self.build.outputRevision) then - self.build.skillsTab:AddSocketGroupTooltip(tooltip, socketGroup) - end + self.socketGroupRow = { label = "Socket Group: Both", { controlName = "mainSocketGroup", + control = new("DropDownControl"):DropDownControl(nil, { 0, 0, 300, 16 }, nil, function(index, value) + self.input.skill_number = index + self:AddUndoState() + self.build.buildFlag = true + end) { + tooltipFunc = function(tooltip, mode, index, value) + local socketGroup = self.build.skillsTab.socketGroupList[index] + if socketGroup and tooltip:CheckForUpdate(socketGroup, self.build.outputRevision) then + self.build.skillsTab:AddSocketGroupTooltip(tooltip, socketGroup) end - } - }, }, + end + } + } } + self:NewSection(3, "SkillSelect", 1, colorCodes.NORMAL, {{ defaultCollapsed = false, label = "View Skill Details", data = { + self.socketGroupRow, { label = "Active Skill", { controlName = "mainSkill", control = new("DropDownControl"):DropDownControl(nil, { 0, 0, 300, 16 }, nil, function(index, value) local mainSocketGroup = self.build.skillsTab.socketGroupList[self.input.skill_number] @@ -483,7 +484,7 @@ function CalcsTabClass:SearchMatch(txt) end -- Build the calculation output tables -function CalcsTabClass:BuildOutput() +function CalcsTabClass:BuildOutput(validateWeaponSets) self.powerBuildFlag = true --[[ @@ -502,9 +503,16 @@ function CalcsTabClass:BuildOutput() end self.mainEnv = self.calcs.buildOutput(self.build, "MAIN") + if self.build.skillsTab:ReconcileSocketGroupWeaponSets(self.mainEnv, validateWeaponSets) then + wipeGlobalCache() + self.mainEnv = self.calcs.buildOutput(self.build, "MAIN") + self.build.skillsTab:CacheSocketGroupWeaponSetValidity(self.mainEnv) + end self.mainOutput = self.mainEnv.player.output self.calcsEnv = self.calcs.buildOutput(self.build, "CALCS") self.calcsOutput = self.calcsEnv.player.output + self.build.controls.mainSkillLabel.label = "^7Main Skill: " .. self.build.skillsTab:GetSocketGroupWeaponSetLabel(self.build.skillsTab.socketGroupList[self.build.mainSocketGroup]) + self.socketGroupRow.label = "Socket Group: " .. self.build.skillsTab:GetSocketGroupWeaponSetLabel(self.build.skillsTab.socketGroupList[self.input.skill_number]) if self.displayData then self.controls.breakdown:SetBreakdownData() diff --git a/src/Classes/EditControl.lua b/src/Classes/EditControl.lua index ff60d9dedd..f4494124c5 100644 --- a/src/Classes/EditControl.lua +++ b/src/Classes/EditControl.lua @@ -281,27 +281,26 @@ function EditClass:Draw(viewPort, noTooltip) end textX = textX + DrawStringWidth(textHeight, self.font, self.prompt) + textHeight/2 end - if not enabled then - return - end - if mOver and not noTooltip then + if enabled and mOver and not noTooltip then SetDrawLayer(nil, 100) self:DrawTooltip(x, y, width, height, viewPort) SetDrawLayer(nil, 0) end - self:UpdateScrollBars() + if enabled then + self:UpdateScrollBars() + end local marginL = textX - x - 2 local marginR = self.controls.scrollBarV:IsShown() and 14 or 0 local marginB = self.controls.scrollBarH:IsShown() and 14 or 0 SetViewport(textX, textY, width - 4 - marginL - marginR, height - 4 - marginB) - if not self.hasFocus then + if not enabled or not self.hasFocus then if self.buf == '' and self.placeholder then SetDrawColor(self.disableCol) DrawString(-self.controls.scrollBarH.offset, -self.controls.scrollBarV.offset, "LEFT", textHeight, self.font, self.placeholder) else SetDrawColor(self.inactiveCol) if self.inactiveText then - local inactiveText = type(inactiveText) == "string" and self.inactiveText or self.inactiveText(self.buf) + local inactiveText = type(self.inactiveText) == "string" and self.inactiveText or self.inactiveText(self.buf) DrawString(-self.controls.scrollBarH.offset, -self.controls.scrollBarV.offset, "LEFT", textHeight, self.font, inactiveText) elseif self.protected then DrawString(-self.controls.scrollBarH.offset, -self.controls.scrollBarV.offset, "LEFT", textHeight, self.font, string.rep(protected_replace, #self.buf)) diff --git a/src/Classes/GemSelectControl.lua b/src/Classes/GemSelectControl.lua index 6fe319f3b4..0d7800ca33 100644 --- a/src/Classes/GemSelectControl.lua +++ b/src/Classes/GemSelectControl.lua @@ -110,7 +110,7 @@ function GemSelectClass:PopulateGemList() local characterLevel = self.skillsTab.build and self.skillsTab.build.characterLevel or 1 for gemId, gemData in pairs(self.skillsTab.build.data.gems) do - if (self.sortGemsBy and gemData.tags[self.sortGemsBy] == true or not self.sortGemsBy) then + if not gemData.grantedEffect.fromItem and not gemData.grantedEffect.fromTree and (self.sortGemsBy and gemData.tags[self.sortGemsBy] == true or not self.sortGemsBy) then local levelRequirement = (gemData.grantedEffect.levels and gemData.grantedEffect.levels[1] and gemData.grantedEffect.levels[1].levelRequirement) or 1 if characterLevel >= levelRequirement or not matchLevel then if self.skillsTab.showLegacyGems or not (self.skillsTab.showLegacyGems and gemData.grantedEffect.legacy) then @@ -496,15 +496,16 @@ function GemSelectClass:IsHoverSelectionReady() end function GemSelectClass:Draw(viewPort, noTooltip) + local enabled = self:IsEnabled() self.sortPercentage = self.sortPercentage or "" - if self.dpsBuildFlag then + if enabled and self.dpsBuildFlag then self.dpsBuildFlag = false self.dpsBuilder = coroutine.create(self.DPSBuilder) self.dpsBuilderCallback = function(percentage) self.sortPercentage = ("%d%%"):format(percentage) end end - if self.dpsBuilder then + if enabled and self.dpsBuilder then local res, errMsg = coroutine.resume(self.dpsBuilder, self) if launch.devMode and not res then error(errMsg) @@ -517,7 +518,6 @@ function GemSelectClass:Draw(viewPort, noTooltip) self.EditControl:Draw(viewPort, noTooltip and not self.forceTooltip) local x, y = self:GetPos() local width, height = self:GetSize() - local enabled = self:IsEnabled() local mOver, mOverComp = self:IsMouseOver() local dropHeight = (height - 4) * m_min(#self.list, 15) local scrollBar = self.controls.scrollBar diff --git a/src/Classes/ImportTab.lua b/src/Classes/ImportTab.lua index 3c93b7f5dd..3b1fea2375 100644 --- a/src/Classes/ImportTab.lua +++ b/src/Classes/ImportTab.lua @@ -1152,28 +1152,18 @@ function ImportTabClass:ImportItemsAndSkills(charData) if typeLine:match("Mace Strike") then local weaponRequirement = skillData.weaponRequirements and skillData.weaponRequirements[1] local requiredWeaponType = weaponRequirement and escapeGGGString(weaponRequirement.values[1][1]) - local weapon1Sel = self.build.itemsTab.activeItemSet["Weapon 1"] and self.build.itemsTab.activeItemSet["Weapon 1"].selItemId or 0 - local weapon2Sel = self.build.itemsTab.activeItemSet["Weapon 2"] and self.build.itemsTab.activeItemSet["Weapon 2"].selItemId or 0 - if requiredWeaponType == "Two Hand Mace" then - gemId = "Metadata/Items/Gems/SkillGemPlayerDefault2HMace" - elseif weapon2Sel == 0 then - if weapon1Sel == 0 or self.build.itemsTab.items[weapon1Sel].base.type == "One Hand Mace" then -- Facebreaker uses single handed mace strike - gemId = "Metadata/Items/Gems/SkillGemPlayerDefault1HMace" - elseif self.build.itemsTab.items[weapon1Sel].base.type == "Two Hand Mace" then - gemId = "Metadata/Items/Gems/SkillGemPlayerDefault2HMace" - end - else - if self.build.itemsTab.items[weapon2Sel].base.type == "One Hand Mace" or self.build.itemsTab.items[weapon2Sel].base.type == "Two Hand Mace" then - gemId = "Metadata/Items/Gems/SkillGemPlayerDefaultMaceMace" -- Dual wielding maces - elseif self.build.itemsTab.items[weapon1Sel].base.type == "One Hand Mace" then - gemId = "Metadata/Items/Gems/SkillGemPlayerDefault1HMace" - elseif self.build.itemsTab.items[weapon1Sel].base.type == "Two Hand Mace" then - gemId = "Metadata/Items/Gems/SkillGemPlayerDefault2HMace" - end - end - end - if typeLine:match("Spear Stab") and (self.build.itemsTab.activeItemSet["Weapon 2"].selItemId or 0) ~= 0 then - gemId = "Metadata/Items/Gems/SkillGemPlayerDefaultSpearOffHand" + local mainItem = self.build.itemsTab.items[self.build.itemsTab.activeItemSet["Weapon 1"].selItemId] + local offItem = self.build.itemsTab.items[self.build.itemsTab.activeItemSet["Weapon 2"].selItemId] + -- Facebreaker uses the one-handed variant when no mace is equipped. + local mainType = requiredWeaponType == "Two Hand Mace" and requiredWeaponType + or mainItem and mainItem.base.type == "Two Hand Mace" and mainItem.base.type or "One Hand Mace" + local offType = requiredWeaponType ~= "Two Hand Mace" and offItem and offItem.base.type or "Unarmed" + local maceSkills = self.build.data.characterMeleeSkills[mainType] + gemId = (maceSkills[offType] or maceSkills.Unarmed)[1].id + elseif typeLine:match("Spear Stab") then + local offItem = self.build.itemsTab.items[self.build.itemsTab.activeItemSet["Weapon 2"].selItemId] + local offType = offItem and offItem.base.tags.buckler and "Buckler" or "Unarmed" + gemId = self.build.data.characterMeleeSkills.Spear[offType][1].id end if gemId then @@ -1326,7 +1316,7 @@ function ImportTabClass:ImportItemsAndSkills(charData) if mainSkillEmpty then self.build.mainSocketGroup = self:GuessMainSocketGroup() end - self.build.calcsTab:BuildOutput() + self.build.calcsTab:BuildOutput(true) self.build.itemsTab:PopulateSlots() self.build.itemsTab:AddUndoState() self.build.skillsTab:AddUndoState() diff --git a/src/Classes/ItemDBControl.lua b/src/Classes/ItemDBControl.lua index fbb004f766..f67333cf91 100644 --- a/src/Classes/ItemDBControl.lua +++ b/src/Classes/ItemDBControl.lua @@ -251,10 +251,11 @@ function ItemDBClass:ListBuilder() local useFullDPS = self.sortDetail.stat == "FullDPS" local start = GetTime() local calcFunc, calcBase = self.itemsTab.build.calcsTab:GetMiscCalculator(self.build) + local weaponSet = self.itemsTab.build.calcsTab.mainEnv and self.itemsTab.build.calcsTab.mainEnv.weaponSet or (self.itemsTab.activeItemSet.useSecondWeaponSet and 2 or 1) for itemIndex, item in ipairs(list) do item.measuredPower = nil for slotName, slot in pairs(self.itemsTab.slots) do - if self.itemsTab:IsItemValidForSlot(item, slotName) and not slot.inactive and (not slot.weaponSet or slot.weaponSet == (self.itemsTab.activeItemSet.useSecondWeaponSet and 2 or 1)) then + if self.itemsTab:IsItemValidForSlot(item, slotName) and not slot.inactive and (not slot.weaponSet or slot.weaponSet == weaponSet) then local output = calcFunc(item.base.flask and { toggleFlask = item } or item.base.charm and { toggleCharm = item } or { repSlotName = slotName, repItem = item }, useFullDPS) local measuredPower = data.powerStatList.GetFromOutput(output, self.sortDetail) item.measuredPower = item.measuredPower and m_max(item.measuredPower, measuredPower) or measuredPower diff --git a/src/Classes/ItemsTab.lua b/src/Classes/ItemsTab.lua index d6fe6a271f..b6e9b1e56f 100644 --- a/src/Classes/ItemsTab.lua +++ b/src/Classes/ItemsTab.lua @@ -349,15 +349,6 @@ function ItemsTabClass:ItemsTab(build) self.activeItemSet.useSecondWeaponSet = false self:AddUndoState() self.build.buildFlag = true - local mainSocketGroup = self.build.skillsTab.socketGroupList[self.build.mainSocketGroup] - if mainSocketGroup and mainSocketGroup.slot and self.slots[mainSocketGroup.slot].weaponSet == 2 then - for index, socketGroup in ipairs(self.build.skillsTab.socketGroupList) do - if socketGroup.slot and self.slots[socketGroup.slot].weaponSet == 1 then - self.build.mainSocketGroup = index - break - end - end - end end end) self.controls.weaponSwap1.overSizeText = 3 @@ -369,15 +360,6 @@ function ItemsTabClass:ItemsTab(build) self.activeItemSet.useSecondWeaponSet = true self:AddUndoState() self.build.buildFlag = true - local mainSocketGroup = self.build.skillsTab.socketGroupList[self.build.mainSocketGroup] - if mainSocketGroup and mainSocketGroup.slot and self.slots[mainSocketGroup.slot].weaponSet == 1 then - for index, socketGroup in ipairs(self.build.skillsTab.socketGroupList) do - if socketGroup.slot and self.slots[socketGroup.slot].weaponSet == 2 then - self.build.mainSocketGroup = index - break - end - end - end end end) self.controls.weaponSwap2.overSizeText = 3 @@ -4358,8 +4340,9 @@ function ItemsTabClass:AddItemTooltip(tooltip, item, slot, dbMode, maxWidth) self:UpdateSockets() -- Build sorted list of slots to compare with local compareSlots = { } + local weaponSet = self.build.calcsTab.mainEnv and self.build.calcsTab.mainEnv.weaponSet or (self.activeItemSet.useSecondWeaponSet and 2 or 1) for slotName, slot in pairs(self.slots) do - if self:IsItemValidForSlot(item, slotName) and not slot.inactive and (not slot.weaponSet or slot.weaponSet == (self.activeItemSet.useSecondWeaponSet and 2 or 1)) and slot.shown() then + if self:IsItemValidForSlot(item, slotName) and not slot.inactive and (not slot.weaponSet or slot.weaponSet == weaponSet) and (slot.weaponSet or slot.shown()) then t_insert(compareSlots, slot) end end @@ -4391,6 +4374,9 @@ function ItemsTabClass:AddItemTooltip(tooltip, item, slot, dbMode, maxWidth) -- one slot if main.slotOnlyTooltips and slot then slot = type(slot) ~= "string" and slot or self.slots[slot] + if slot and slot.weaponSet then + slot = self.slots[slot.slotName:gsub(" Swap", "") .. (weaponSet == 2 and " Swap" or "")] + end if slot then addCompareForSlot(slot) end return end diff --git a/src/Classes/PassiveSpec.lua b/src/Classes/PassiveSpec.lua index 636204787e..4c79e770b5 100644 --- a/src/Classes/PassiveSpec.lua +++ b/src/Classes/PassiveSpec.lua @@ -1219,7 +1219,7 @@ function PassiveSpecClass:SetGrantedPassiveNodes(grantedNodeMap) return changed end -function PassiveSpecClass:CollectGrantedPassiveNodesFromItems(itemsTab, baseAllocNodes, ignoreJewelLimits, override, nodesModsList) +function PassiveSpecClass:CollectGrantedPassiveNodesFromItems(itemsTab, baseAllocNodes, ignoreJewelLimits, override, nodesModsList, activeWeaponSet) override = override or { } local granted = { } local allocNodes = { } @@ -1228,7 +1228,6 @@ function PassiveSpecClass:CollectGrantedPassiveNodesFromItems(itemsTab, baseAllo allocNodes[nodeId] = node end end - local activeWeaponSet = itemsTab.activeItemSet.useSecondWeaponSet and 2 or 1 local jewelLimits = { } local changed = true local safety = 0 diff --git a/src/Classes/PassiveTreeView.lua b/src/Classes/PassiveTreeView.lua index 86ffcd4659..61b78bc499 100644 --- a/src/Classes/PassiveTreeView.lua +++ b/src/Classes/PassiveTreeView.lua @@ -1787,12 +1787,13 @@ function PassiveTreeViewClass:AddNodeTooltip(tooltip, node, build, incSmallPassi local localIncEffect = 0 local hasWSCondition = false local newSd = copyTable(mNode.sd) + local weaponSet = build.calcsTab.mainEnv and build.calcsTab.mainEnv.weaponSet or (build.itemsTab.activeItemSet.useSecondWeaponSet and 2 or 1) for _, mod in ipairs(mNode.finalModList) do -- if the jewelMod has a WS Condition, only add the incEffect given it matches the activeWeaponSet -- otherwise the mod came from a jewel that is allocMode 0, so it always applies for _, modCriteria in ipairs(mod) do if modCriteria.type == "Condition" and modCriteria.var and modCriteria.var:match("^WeaponSet") then - if (tonumber(modCriteria.var:match("(%d)")) == (build.itemsTab.activeItemSet.useSecondWeaponSet and 2 or 1)) then + if tonumber(modCriteria.var:match("(%d)")) == weaponSet then if mod.name == "JewelSmallPassiveSkillEffect" then localIncEffect = mod.value elseif mod.name == "JewelNotablePassiveSkillEffect" then diff --git a/src/Classes/SkillListControl.lua b/src/Classes/SkillListControl.lua index 95e5966630..fded2d2e94 100644 --- a/src/Classes/SkillListControl.lua +++ b/src/Classes/SkillListControl.lua @@ -79,7 +79,7 @@ function SkillListClass:GetRowValue(column, index, socketGroup) if column == 1 then local label = socketGroup.displayLabel or "?" local currentMainSkill = self.skillsTab.build.mainSocketGroup == index - local disabled = not socketGroup.enabled or not socketGroup.slotEnabled + local disabled = not socketGroup.enabled if disabled then local colour = currentMainSkill and "" or "^x7F7F7F" label = colour .. label .. " (Disabled)" diff --git a/src/Classes/SkillsTab.lua b/src/Classes/SkillsTab.lua index 3ab1ce9eb6..56f005b237 100644 --- a/src/Classes/SkillsTab.lua +++ b/src/Classes/SkillsTab.lua @@ -10,23 +10,6 @@ local t_remove = table.remove local m_min = math.min local m_max = math.max -local groupSlotDropList = { - { label = "None" }, - { label = "Weapon 1", slotName = "Weapon 1" }, - { label = "Weapon 2", slotName = "Weapon 2" }, - { label = "Weapon 1 (Swap)", slotName = "Weapon 1 Swap" }, - { label = "Weapon 2 (Swap)", slotName = "Weapon 2 Swap" }, - { label = "Helmet", slotName = "Helmet" }, - { label = "Body Armour", slotName = "Body Armour" }, - { label = "Gloves", slotName = "Gloves" }, - { label = "Boots", slotName = "Boots" }, - { label = "Amulet", slotName = "Amulet" }, - { label = "Ring 1", slotName = "Ring 1" }, - { label = "Ring 2", slotName = "Ring 2" }, - { label = "Ring 3", slotName = "Ring 3" }, - { label = "Belt", slotName = "Belt" }, -} - local defaultGemLevelList = { { label = "Normal Maximum", @@ -171,31 +154,52 @@ function SkillsTabClass:SkillsTab(build) self:AddUndoState() self.build.buildFlag = true end) - self.controls.groupSlotLabel = new("LabelControl"):LabelControl({ "TOPLEFT", self.anchorGroupDetail, "TOPLEFT" }, { 0, 30, 0, 16 }, "^7Socketed in:") - self.controls.groupSlot = new("DropDownControl"):DropDownControl({ "TOPLEFT", self.anchorGroupDetail, "TOPLEFT" }, { 85, 28, 130, 20 }, groupSlotDropList, function(index, value) - self.displayGroup.slot = value.slotName + local function updateWeaponSet(set, state) + if not state and not self.displayGroup[set == 1 and "set2" or "set1"] then + self.controls[set == 1 and "set1Enabled" or "set2Enabled"].state = true + return + end + self.displayGroup[set == 1 and "set1" or "set2"] = state self:AddUndoState() self.build.buildFlag = true + end + self.controls.set1Enabled = new("CheckBoxControl"):CheckBoxControl({ "TOPLEFT", self.anchorGroupDetail, "TOPLEFT" }, { 42, 30, 20 }, "Set 1:", function(state) + updateWeaponSet(1, state) end) - self.controls.groupSlot.tooltipFunc = function(tooltip, mode, index, value) - tooltip:Clear() - if mode == "OUT" or index == 1 then - tooltip:AddLine(16, "Select the item in which this skill is socketed.") - tooltip:AddLine(16, "This will allow the skill to benefit from modifiers on the item that affect socketed gems.") - else - local slot = self.build.itemsTab.slots[value.slotName] - local ttItem = self.build.itemsTab.items[slot.selItemId] - if ttItem then - self.build.itemsTab:AddItemTooltip(tooltip, ttItem, slot) - else - tooltip:AddLine(16, "No item is equipped in this slot.") + self.controls.set2Enabled = new("CheckBoxControl"):CheckBoxControl({ "LEFT", self.controls.set1Enabled, "RIGHT" }, { 50, 0, 20 }, "Set 2:", function(state) + updateWeaponSet(2, state) + end) + for set, control in ipairs({ self.controls.set1Enabled, self.controls.set2Enabled }) do + control.label = function() + local valid = true + if self.displayGroup then + if self:IsSocketGroupWeaponSetLocked(self.displayGroup) then + valid = self.displayGroup[set == 1 and "set1" or "set2"] + elseif not self.displayGroup.forcedBoth then + valid = self:IsSocketGroupWeaponSetValid(self.displayGroup, set) + end + end + return (valid and "" or colorCodes.NEGATIVE) .. "Set " .. set .. ":" + end + control.enabled = function() + return self.displayGroup and not self:IsSocketGroupWeaponSetLocked(self.displayGroup) and not self.displayGroup.forcedBoth and self:IsSocketGroupWeaponSetValid(self.displayGroup, set) + end + control.tooltipFunc = function(tooltip) + if self.displayGroup and self.displayGroup.forcedBoth then + tooltip:Clear() + tooltip:AddLine(16, "This skill reserves Spirit in all weapon sets and must be enabled in both sets.") + elseif self.displayGroup and self:IsSocketGroupWeaponSetLocked(self.displayGroup) then + tooltip:Clear() + tooltip:AddLine(16, self.displayGroup.source == "Default Attack" + and "Default attack skills can only be used in the weapon set that grants them." + or "Skills granted by items in weapon slots can only be used in that item's weapon set.") + elseif self.displayGroup and not self:IsSocketGroupWeaponSetValid(self.displayGroup, set) then + tooltip:Clear() + tooltip:AddLine(16, colorCodes.NEGATIVE .. "This skill cannot be used with the weapons equipped in Set " .. set .. ".") end end end - self.controls.groupSlot.enabled = function() - return self.displayGroup.source == nil - end - self.controls.groupEnabled = new("CheckBoxControl"):CheckBoxControl({ "LEFT", self.controls.groupSlot, "RIGHT" }, { 70, 0, 20 }, "Enabled:", function(state) + self.controls.groupEnabled = new("CheckBoxControl"):CheckBoxControl({ "LEFT", self.controls.set2Enabled, "RIGHT" }, { 70, 0, 20 }, "Enabled:", function(state) self.displayGroup.enabled = state self:AddUndoState() self.build.buildFlag = true @@ -230,9 +234,9 @@ function SkillsTabClass:SkillsTab(build) self.controls.groupCount.shown = function() return self.displayGroup.source ~= nil end - self.controls.sourceNote = new("LabelControl"):LabelControl({ "TOPLEFT", self.controls.groupSlotLabel, "TOPLEFT" }, { 0, 30, 0, 16 }) + self.controls.sourceNote = new("LabelControl"):LabelControl({ "TOPLEFT", self.controls.groupLabel, "TOPLEFT" }, { 0, 58, 0, 16 }) self.controls.sourceNote.shown = function() - return self.displayGroup.source ~= nil + return self.displayGroup.explodeSources ~= nil end self.controls.sourceNote.label = function() local label @@ -243,25 +247,6 @@ which comes from the following sources:]] label = label .. "\n\t" .. colorCodes[source.rarity or "NORMAL"] .. (source.name or source.dn or "???") end label = label .. "^7\nYou cannot delete this group, but it will disappear if you lose the above sources." - else - local activeGem = self.displayGroup.gemList[1] - local sourceName - if self.displayGroup.sourceItem then - sourceName = "'" .. colorCodes[self.displayGroup.sourceItem.rarity] .. self.displayGroup.sourceItem.name - elseif self.displayGroup.sourceNode then - sourceName = "'" .. colorCodes["NORMAL"] .. self.displayGroup.sourceNode.name - else - sourceName = "'" .. colorCodes["NORMAL"] .. "?" - end - sourceName = sourceName .. "^7'" - label = [[^7This is a special group created for the ']] .. activeGem.color .. (activeGem.grantedEffect and activeGem.grantedEffect.name or activeGem.nameSpec) .. [[^7' skill, -which is being provided by ]] .. sourceName .. [[. -You cannot delete this group, but it will disappear if you ]] .. (self.displayGroup.sourceNode and [[un-allocate the node.]] or [[un-equip the item.]]) - if not self.displayGroup.noSupports then - label = label .. "\n\n" .. [[You cannot add support gems to this group, but support gems in -any other group socketed into ]] .. sourceName .. [[ -will automatically apply to the skill.]] - end end return label end @@ -310,8 +295,12 @@ function SkillsTabClass:LoadSkill(node, skillSetId) socketGroup.includeInFullDPS = node.attrib.includeInFullDPS and node.attrib.includeInFullDPS == "true" socketGroup.groupCount = tonumber(node.attrib.groupCount) socketGroup.label = node.attrib.label - socketGroup.slot = node.attrib.slot socketGroup.source = node.attrib.source + -- Ordinary groups no longer expose a slot selector, but retain legacy slot data + -- until CalcSetup migrates old same-slot supports into generated source groups. + socketGroup.slot = node.attrib.slot + socketGroup.set1 = node.attrib.set1 and node.attrib.set1 == "true" + socketGroup.set2 = node.attrib.set2 and node.attrib.set2 == "true" socketGroup.mainActiveSkill = tonumber(node.attrib.mainActiveSkill) or 1 socketGroup.mainActiveSkillCalcs = tonumber(node.attrib.mainActiveSkillCalcs) or 1 socketGroup.gemList = { } @@ -401,7 +390,12 @@ function SkillsTabClass:LoadSkill(node, skillSetId) socketGroup.gemList[1].skillPart = tonumber(node.attrib.skillPart) end self:ProcessSocketGroup(socketGroup) - t_insert(self.skillSets[skillSetId].socketGroupList, socketGroup) + if node.attrib.removed == "true" then + local key = (node.attrib.removedSource or "") .. "\0" .. (node.attrib.removedSlot or "") .. "\0" .. (node.attrib.removedSkillId or "") + self.skillSets[skillSetId].removedSocketGroupList[key] = socketGroup + else + t_insert(self.skillSets[skillSetId].socketGroupList, socketGroup) + end end function SkillsTabClass:Load(xml, fileName) @@ -469,7 +463,21 @@ function SkillsTabClass:Save(xml) local child = { elem = "SkillSet", attrib = { id = tostring(skillSetId), title = skillSet.title } } t_insert(xml, child) + local socketGroups = { } for _, socketGroup in ipairs(skillSet.socketGroupList) do + t_insert(socketGroups, { group = socketGroup }) + end + local removedKeys = { } + for key in pairs(skillSet.removedSocketGroupList or { }) do + t_insert(removedKeys, key) + end + table.sort(removedKeys) + for _, key in ipairs(removedKeys) do + local source, slot, skillId = key:match("^(.-)%z(.-)%z(.*)$") + t_insert(socketGroups, { group = skillSet.removedSocketGroupList[key], removed = true, source = source, slot = slot, skillId = skillId }) + end + for _, entry in ipairs(socketGroups) do + local socketGroup = entry.group local node = { elem = "Skill", attrib = { enabled = tostring(socketGroup.enabled), includeInFullDPS = tostring(socketGroup.includeInFullDPS), @@ -477,8 +485,14 @@ function SkillsTabClass:Save(xml) label = socketGroup.label, slot = socketGroup.slot, source = socketGroup.source, + set1 = tostring(socketGroup.set1 ~= false), + set2 = tostring(socketGroup.set2 ~= false), mainActiveSkill = tostring(socketGroup.mainActiveSkill), mainActiveSkillCalcs = tostring(socketGroup.mainActiveSkillCalcs), + removed = entry.removed and "true" or nil, + removedSource = entry.source, + removedSlot = entry.slot, + removedSkillId = entry.skillId, } } for _, gemInstance in ipairs(socketGroup.gemList) do local gemInfo = { elem = "Gem", attrib = { @@ -552,6 +566,14 @@ function SkillsTabClass:Save(xml) end function SkillsTabClass:Draw(viewPort, inputEvents) + local validity = self.weaponSetValidityCache and self.weaponSetValidityCache[self.displayGroup] + local needsReconcile = self.weaponSetValidityRevision ~= self.build.outputRevision + or self.displayGroup and not self:IsSocketGroupWeaponSetLocked(self.displayGroup) and not self.displayGroup.forcedBoth + and (not validity or validity[1] == nil or validity[2] == nil) + if needsReconcile and self.displayGroup and self.build.calcsTab and self.build.calcsTab.mainEnv + and self:ReconcileSocketGroupWeaponSets(self.build.calcsTab.mainEnv, self.displayGroup) then + self.build.buildFlag = true + end self.x = viewPort.x self.y = viewPort.y self.width = viewPort.width @@ -619,9 +641,7 @@ function SkillsTabClass:CopySocketGroup(socketGroup) if socketGroup.label and socketGroup.label:match("%S") then skillText = skillText .. "Label: " .. socketGroup.label .. "\r\n" end - if socketGroup.slot then - skillText = skillText .. "Slot: " .. socketGroup.slot .. "\r\n" - end + skillText = skillText .. "Weapon Set: " .. self:GetSocketGroupWeaponSetLabel(socketGroup) .. "\r\n" for _, gemInstance in ipairs(socketGroup.gemList) do skillText = skillText .. string.format( "%s %d/%d %s %s%s\r\n", @@ -663,6 +683,11 @@ function SkillsTabClass:PasteSocketGroup(testInput) if slot then newGroup.slot = slot end + local weaponSet = skillText:match("Weapon Set: Set ([12])") + if weaponSet then + newGroup.set1 = weaponSet == "1" + newGroup.set2 = weaponSet == "2" + end for line in skillText:gmatch("([^\r\n]+)") do local currentLine = line -- reassignment to local var to avoid modifying iter var @@ -736,6 +761,10 @@ function SkillsTabClass:PasteSocketGroup(testInput) end end +local function isGeneratedSourceGem(socketGroup, index) + return index == 1 and socketGroup and (socketGroup.source or socketGroup.sourceItem or socketGroup.sourceNode) +end + -- Create the controls for editing the gem at a given index function SkillsTabClass:CreateGemSlot(index) local slot = { } @@ -771,9 +800,12 @@ function SkillsTabClass:CreateGemSlot(index) end) end slot.delete.shown = function() - return index <= #self.displayGroup.gemList + 1 and self.displayGroup.source == nil + return index <= #self.displayGroup.gemList + 1 and self.displayGroup.explodeSources == nil end slot.delete.enabled = function() + if isGeneratedSourceGem(self.displayGroup, index) then + return false + end return index <= #self.displayGroup.gemList end slot.delete.tooltipText = "Remove this gem." @@ -841,6 +873,9 @@ function SkillsTabClass:CreateGemSlot(index) self.build.buildFlag = true end end, true) + slot.nameSpec.enabled = function() + return not isGeneratedSourceGem(self.displayGroup, index) + end slot.nameSpec:AddToTabGroup(self.controls.groupLabel) self.controls["gemSlot"..index.."Name"] = slot.nameSpec @@ -863,6 +898,9 @@ function SkillsTabClass:CreateGemSlot(index) end) slot.level:AddToTabGroup(self.controls.groupLabel) slot.level.enabled = function() + if isGeneratedSourceGem(self.displayGroup, index) then + return false + end return index <= #self.displayGroup.gemList end self.controls["gemSlot"..index.."Level"] = slot.level @@ -978,6 +1016,9 @@ function SkillsTabClass:CreateGemSlot(index) end slot.quality:AddToTabGroup(self.controls.groupLabel) slot.quality.enabled = function() + if isGeneratedSourceGem(self.displayGroup, index) then + return false + end return index <= #self.displayGroup.gemList end self.controls["gemSlot"..index.."Quality"] = slot.quality @@ -1018,6 +1059,9 @@ function SkillsTabClass:CreateGemSlot(index) end end slot.enabled.enabled = function() + if isGeneratedSourceGem(self.displayGroup, index) then + return false + end return index <= #self.displayGroup.gemList end self.controls["gemSlot"..index.."Enable"] = slot.enabled @@ -1040,6 +1084,9 @@ function SkillsTabClass:CreateGemSlot(index) self.build.buildFlag = true end) slot.count.shown = function() + if isGeneratedSourceGem(self.displayGroup, index) then + return false + end local gemInstance = self.displayGroup and self.displayGroup.gemList[index] if gemInstance then local grantedEffectList = gemInstance.gemData and gemInstance.gemData.grantedEffectList or { gemInstance.grantedEffect } @@ -1059,6 +1106,9 @@ function SkillsTabClass:CreateGemSlot(index) end end slot.count.enabled = function() + if isGeneratedSourceGem(self.displayGroup, index) then + return false + end return index <= #self.displayGroup.gemList end self.controls["gemSlot"..index.."Count"] = slot.count @@ -1155,6 +1205,7 @@ function SkillsTabClass:UpdateGemSlots() if not self.displayGroup then return end + self:EnsureSocketGroupDisplaySkills(self.displayGroup) for slotIndex = 1, #self.displayGroup.gemList + 1 do if not self.gemSlots[slotIndex] then self:CreateGemSlot(slotIndex) @@ -1168,7 +1219,11 @@ function SkillsTabClass:UpdateGemSlots() slot.count:SetText(1) slot.corruptLevel.selIndex = 1 else - slot.nameSpec.inactiveCol = self.displayGroup.gemList[slotIndex].color + local gemInstance = self.displayGroup.gemList[slotIndex] + slot.nameSpec.inactiveCol = gemInstance.color + if isGeneratedSourceGem(self.displayGroup, slotIndex) and tonumber(slot.level.buf) ~= gemInstance.level then + slot.level:SetText(gemInstance.level) + end end end self:UpdateGlobalGemCountAssignments() @@ -1262,13 +1317,15 @@ function SkillsTabClass:ProcessSocketGroup(socketGroup) end elseif gemInstance.skillId then -- Specified by skill ID - -- Used for skills granted by items + -- Used for skills granted by items and passive tree nodes gemInstance.errMsg = nil - local gemId = data.gemForSkill[gemInstance.skillId] + local grantedEffect = data.skills[gemInstance.skillId] + local gemId = data.gemForSkill[grantedEffect] if gemId then gemInstance.gemData = data.gems[gemId] + gemInstance.nameSpec = gemInstance.gemData.name else - gemInstance.grantedEffect = data.skills[gemInstance.skillId] + gemInstance.grantedEffect = grantedEffect end if gemInstance.triggered and gemInstance.grantedEffect then if gemInstance.grantedEffect.levels[gemInstance.level] then @@ -1319,6 +1376,162 @@ function SkillsTabClass:ProcessSocketGroup(socketGroup) end end end + local sourceSlot = (socketGroup.sourceItem or socketGroup.source == "Default Attack") and socketGroup.slot and self.build.itemsTab.slots[socketGroup.slot] + if sourceSlot and sourceSlot.weaponSet then + socketGroup.set1 = sourceSlot.weaponSet ~= 2 + socketGroup.set2 = sourceSlot.weaponSet ~= 1 + else + socketGroup.set1 = socketGroup.forcedBoth or socketGroup.set1 ~= false + socketGroup.set2 = socketGroup.forcedBoth or socketGroup.set2 ~= false + end + if not socketGroup.set1 and not socketGroup.set2 then + socketGroup.set1 = true + socketGroup.set2 = true + end +end + +function SkillsTabClass:IsSocketGroupWeaponSetLocked(socketGroup) + local sourceSlot = socketGroup and (socketGroup.sourceItem or socketGroup.source == "Default Attack") and socketGroup.slot and self.build.itemsTab.slots[socketGroup.slot] + return sourceSlot and sourceSlot.weaponSet ~= nil +end + +function SkillsTabClass:GetSocketGroupWeaponSet(socketGroup) + if not socketGroup or socketGroup.set1 ~= false and socketGroup.set2 ~= false then + return self.build.itemsTab.activeItemSet.useSecondWeaponSet and 2 or 1 + elseif socketGroup.set2 then + return 2 + end + return 1 +end + +function SkillsTabClass:GetSocketGroupWeaponSetLabel(socketGroup) + if not socketGroup or socketGroup.set1 ~= false and socketGroup.set2 ~= false then + return "Both" + elseif socketGroup.set2 then + return "Set 2" + end + return "Set 1" +end + +local function cacheWeaponSetContext(skillsTab, context) + if not context then + return + end + local groupSkillIndex = { } + for _, activeSkill in ipairs(context.weaponSetValidationSkillList or context.player.activeSkillList) do + local socketGroup = activeSkill.socketGroup + if socketGroup then + if activeSkill.skillData.reservesInAllWeaponSets then + socketGroup.forcedBoth = true + end + groupSkillIndex[socketGroup] = (groupSkillIndex[socketGroup] or 0) + 1 + local selectedIndex = context.mode == "CALCS" and socketGroup.mainActiveSkillCalcs or socketGroup.mainActiveSkill + if groupSkillIndex[socketGroup] == (selectedIndex or 1) then + local flags = context.mode == "CALCS" and activeSkill.activeEffect.statSetCalcs and activeSkill.activeEffect.statSetCalcs.skillFlags + or activeSkill.activeEffect.statSet and activeSkill.activeEffect.statSet.skillFlags + skillsTab.weaponSetValidityCache[socketGroup] = skillsTab.weaponSetValidityCache[socketGroup] or { } + skillsTab.weaponSetValidityCache[socketGroup][context.weaponSet] = not (flags and flags.disable) + end + end + end +end + +function SkillsTabClass:CacheSocketGroupWeaponSetValidity(env) + if not self.weaponSetValidityCache or self.weaponSetValidityRevision ~= self.build.outputRevision then + self.weaponSetValidityRevision = self.build.outputRevision + self.weaponSetValidityCache = { } + for _, socketGroup in ipairs(self.socketGroupList) do + socketGroup.forcedBoth = false + end + end + if not env or env.outputRevision ~= self.build.outputRevision then + return + end + cacheWeaponSetContext(self, env) + if env.weaponSetEnvs then + for _, context in pairs(env.weaponSetEnvs) do + cacheWeaponSetContext(self, context) + end + end +end + +function SkillsTabClass:ApplySocketGroupWeaponSetValidity(socketGroup, set1Valid, set2Valid) + local set1, set2 + if set1Valid ~= set2Valid then + set1, set2 = set1Valid, set2Valid + elseif not set1Valid or not socketGroup.set1 and not socketGroup.set2 then + set1, set2 = true, true + else + return false + end + local changed = socketGroup.set1 ~= set1 or socketGroup.set2 ~= set2 + socketGroup.set1, socketGroup.set2 = set1, set2 + return changed +end + +function SkillsTabClass:ReconcileSocketGroupWeaponSets(env, validateSocketGroup) + self:CacheSocketGroupWeaponSetValidity(env) + local changed = false + for _, socketGroup in ipairs(self.socketGroupList) do + if socketGroup.enabled and not self:IsSocketGroupWeaponSetLocked(socketGroup) then + if socketGroup.forcedBoth then + changed = not socketGroup.set1 or not socketGroup.set2 or changed + socketGroup.set1, socketGroup.set2 = true, true + else + local validity = self.weaponSetValidityCache and self.weaponSetValidityCache[socketGroup] + if validateSocketGroup == true or socketGroup == validateSocketGroup then + self:IsSocketGroupWeaponSetValid(socketGroup, 1) + self:IsSocketGroupWeaponSetValid(socketGroup, 2) + validity = self.weaponSetValidityCache[socketGroup] + elseif validity and validity[env.weaponSet] == false then + self:IsSocketGroupWeaponSetValid(socketGroup, env.weaponSet == 1 and 2 or 1) + validity = self.weaponSetValidityCache[socketGroup] + end + if validity and validity[1] ~= nil and validity[2] ~= nil then + changed = self:ApplySocketGroupWeaponSetValidity(socketGroup, validity[1], validity[2]) or changed + end + end + end + end + if changed then + self:AddUndoState() + if self.displayGroup then + self.controls.set1Enabled.state = self.displayGroup.set1 + self.controls.set2Enabled.state = self.displayGroup.set2 + end + end + return changed +end + +function SkillsTabClass:IsSocketGroupWeaponSetValid(socketGroup, weaponSet) + if not socketGroup or not self.build.calcsTab or self.weaponSetValidityInProgress then + return true + end + self:CacheSocketGroupWeaponSetValidity(self.build.calcsTab.mainEnv) + self.weaponSetValidityCache[socketGroup] = self.weaponSetValidityCache[socketGroup] or { } + if self.weaponSetValidityCache[socketGroup][weaponSet] ~= nil then + return self.weaponSetValidityCache[socketGroup][weaponSet] + end + local groupIndex = isValueInArray(self.socketGroupList, socketGroup) + if not groupIndex then + return true + end + self.weaponSetValidityInProgress = true + local valid = true + local ok, env = pcall(self.build.calcsTab.calcs.initEnv, self.build, "CALCULATOR", { + weaponSet = weaponSet, + mainSocketGroup = groupIndex, + skipWeaponSetContexts = true, + }) + if ok and env and env.player.mainSkill then + self:CacheSocketGroupWeaponSetValidity(env) + valid = self.weaponSetValidityCache[socketGroup][weaponSet] ~= false + elseif not ok then + ConPrintf("Error validating weapon set %d for socket group %d: %s", weaponSet, groupIndex, tostring(env)) + end + self.weaponSetValidityInProgress = false + self.weaponSetValidityCache[socketGroup][weaponSet] = valid + return valid end -- Set the skill to be displayed/edited @@ -1329,7 +1542,8 @@ function SkillsTabClass:SetDisplayGroup(socketGroup) -- Update the main controls self.controls.groupLabel:SetText(socketGroup.label) - self.controls.groupSlot:SelByValue(socketGroup.slot, "slotName") + self.controls.set1Enabled.state = socketGroup.set1 + self.controls.set2Enabled.state = socketGroup.set2 self.controls.groupEnabled.state = socketGroup.enabled self.controls.includeInFullDPS.state = socketGroup.includeInFullDPS and socketGroup.enabled self.controls.groupCount:SetText(socketGroup.groupCount or 1) @@ -1349,16 +1563,27 @@ function SkillsTabClass:SetDisplayGroup(socketGroup) end end +-- Reuse the calculation cache to resolve deferred default attacks only when inspected. +function SkillsTabClass:EnsureSocketGroupDisplaySkills(socketGroup) + local env = self.build.calcsTab.mainEnv + if socketGroup.source ~= "Default Attack" or not env or env.outputRevision ~= self.build.outputRevision then + return + end + for _, skill in ipairs(env.player.activeSkillList) do + if skill.socketGroup == socketGroup and not GlobalCache.cachedData.MAIN[cacheSkillUUID(skill, env)] then + self.build.calcsTab.calcs.buildActiveSkill(env, "MAIN", skill) + end + end +end + function SkillsTabClass:AddSocketGroupTooltip(tooltip, socketGroup) + self:EnsureSocketGroupDisplaySkills(socketGroup) if socketGroup.explodeSources then for _, source in ipairs(socketGroup.explodeSources) do tooltip:AddLine(18, "^7Source: " .. colorCodes[source.rarity or "NORMAL"] .. (source.name or source.dn or "???")) end return end - if socketGroup.enabled and not socketGroup.slotEnabled then - tooltip:AddLine(16, "^7Note: this group is disabled because it is socketed in the inactive weapon set.") - end local sourceSingle = socketGroup.sourceItem or socketGroup.sourceNode if sourceSingle then tooltip:AddLine(18, "^7Source: " .. colorCodes[sourceSingle.rarity or "NORMAL"] .. sourceSingle.name) @@ -1414,7 +1639,7 @@ function SkillsTabClass:AddSocketGroupTooltip(tooltip, socketGroup) reason = "(Unsupported)" elseif not gemInstance.enabled then reason = "(Disabled)" - elseif not socketGroup.enabled or not socketGroup.slotEnabled then + elseif not socketGroup.enabled then elseif grantedEffect.support then if displayEffect.superseded then reason = "(Superseded)" @@ -1435,21 +1660,31 @@ function SkillsTabClass:AddSocketGroupTooltip(tooltip, socketGroup) end end +local function cloneSocketGroup(socketGroup) + local clone = copyTable(socketGroup, true) + clone.gemList = { } + for gemIndex, gem in pairs(socketGroup.gemList) do + clone.gemList[gemIndex] = copyTable(gem, true) + end + return clone +end + +local function cloneSocketGroupList(socketGroupList) + local clone = { } + for key, socketGroup in pairs(socketGroupList or { }) do + clone[key] = cloneSocketGroup(socketGroup) + end + return clone +end + function SkillsTabClass:CreateUndoState() local state = { } state.activeSkillSetId = self.activeSkillSetId state.skillSets = { } for skillSetIndex, skillSet in pairs(self.skillSets) do local newSkillSet = copyTable(skillSet, true) - newSkillSet.socketGroupList = { } - for socketGroupIndex, socketGroup in pairs(skillSet.socketGroupList) do - local newGroup = copyTable(socketGroup, true) - newGroup.gemList = { } - for gemIndex, gem in pairs(socketGroup.gemList) do - newGroup.gemList[gemIndex] = copyTable(gem, true) - end - newSkillSet.socketGroupList[socketGroupIndex] = newGroup - end + newSkillSet.socketGroupList = cloneSocketGroupList(skillSet.socketGroupList) + newSkillSet.removedSocketGroupList = cloneSocketGroupList(skillSet.removedSocketGroupList) state.skillSets[skillSetIndex] = newSkillSet end state.skillSetOrderList = copyTable(self.skillSetOrderList) @@ -1491,7 +1726,7 @@ end -- Creates a new skill set without adding to order list function SkillsTabClass:CreateSkillSet(skillSetId, title) - local skillSet = { id = skillSetId, title = title, socketGroupList = {} } + local skillSet = { id = skillSetId, title = title, socketGroupList = { }, removedSocketGroupList = { } } if not skillSetId then skillSet.id = #self.skillSets + 1 end @@ -1511,15 +1746,8 @@ function SkillsTabClass:CopySkillSet(sourceSkillSetId, newSkillSetName) local skillSet = self.skillSets[sourceSkillSetId] local newSkillSet = copyTable(skillSet, true) newSkillSet.title = newSkillSetName or skillSet.title .. " (Copy)" - newSkillSet.socketGroupList = {} - for socketGroupIndex, socketGroup in pairs(skillSet.socketGroupList) do - local newGroup = copyTable(socketGroup, true) - newGroup.gemList = {} - for gemIndex, gem in pairs(socketGroup.gemList) do - newGroup.gemList[gemIndex] = copyTable(gem, true) - end - t_insert(newSkillSet.socketGroupList, newGroup) - end + newSkillSet.socketGroupList = cloneSocketGroupList(skillSet.socketGroupList) + newSkillSet.removedSocketGroupList = cloneSocketGroupList(skillSet.removedSocketGroupList) newSkillSet.id = #self.skillSets + 1 self.skillSets[newSkillSet.id] = newSkillSet t_insert(self.skillSetOrderList, newSkillSet.id) diff --git a/src/Data/CharacterMeleeSkills.lua b/src/Data/CharacterMeleeSkills.lua new file mode 100644 index 0000000000..946bb7d9e6 --- /dev/null +++ b/src/Data/CharacterMeleeSkills.lua @@ -0,0 +1,154 @@ +-- This file is automatically generated, do not edit! +-- Game data (c) Grinding Gear Games + +-- Default skill gem base item IDs keyed by main-hand and off-hand WieldableClasses item class IDs. + +-- spell-checker: disable +return { + ["Bow"] = { + ["Unarmed"] = { + "Metadata/Items/Gem/SkillGemPlayerDefaultBow", + }, + }, + ["Claw"] = { + ["Claw"] = { + "Metadata/Items/Gem/SkillGemPlayerDefaultClawClaw", + }, + ["Unarmed"] = { + "Metadata/Items/Gem/SkillGemPlayerDefaultClaw", + }, + }, + ["Crossbow"] = { + ["Unarmed"] = { + "Metadata/Items/Gem/SkillGemPlayerDefaultCrossbow", + }, + }, + ["Dagger"] = { + ["Dagger"] = { + "Metadata/Items/Gem/SkillGemPlayerDefaultDaggerDagger", + }, + ["Unarmed"] = { + "Metadata/Items/Gem/SkillGemPlayerDefaultDagger", + }, + }, + ["Flail"] = { + ["Unarmed"] = { + "Metadata/Items/Gem/SkillGemPlayerDefaultFlail", + }, + }, + ["One Hand Axe"] = { + ["One Hand Axe"] = { + "Metadata/Items/Gem/SkillGemPlayerDefaultAxeAxe", + }, + ["Two Hand Axe"] = { + "Metadata/Items/Gem/SkillGemPlayerDefaultAxeAxe", + }, + ["Unarmed"] = { + "Metadata/Items/Gem/SkillGemPlayerDefault1HAxe", + }, + }, + ["One Hand Mace"] = { + ["One Hand Mace"] = { + "Metadata/Items/Gem/SkillGemPlayerDefaultMaceMace", + }, + ["Two Hand Mace"] = { + "Metadata/Items/Gem/SkillGemPlayerDefaultMaceMace", + }, + ["Unarmed"] = { + "Metadata/Items/Gem/SkillGemPlayerDefault1HMace", + }, + }, + ["One Hand Sword"] = { + ["One Hand Sword"] = { + "Metadata/Items/Gem/SkillGemPlayerDefaultSwordSword", + }, + ["Two Hand Sword"] = { + "Metadata/Items/Gem/SkillGemPlayerDefaultSwordSword", + }, + ["Unarmed"] = { + "Metadata/Items/Gem/SkillGemPlayerDefault1HSword", + }, + }, + ["Spear"] = { + ["Buckler"] = { + "Metadata/Items/Gem/SkillGemPlayerDefaultSpearOffHand", + "Metadata/Items/Gem/SkillGemPlayerDefaultSpearThrow", + }, + ["Unarmed"] = { + "Metadata/Items/Gem/SkillGemPlayerDefaultSpear", + "Metadata/Items/Gem/SkillGemPlayerDefaultSpearThrow", + }, + }, + ["Talisman"] = { + ["Unarmed"] = { + "Metadata/Items/Gem/SkillGemPlayerDefaultUnarmed", + }, + }, + ["Two Hand Axe"] = { + ["One Hand Axe"] = { + "Metadata/Items/Gem/SkillGemPlayerDefaultAxeAxe", + }, + ["Two Hand Axe"] = { + "Metadata/Items/Gem/SkillGemPlayerDefaultAxeAxe", + }, + ["Unarmed"] = { + "Metadata/Items/Gem/SkillGemPlayerDefault2HAxe", + }, + }, + ["Two Hand Mace"] = { + ["One Hand Mace"] = { + "Metadata/Items/Gem/SkillGemPlayerDefaultMaceMace", + }, + ["Two Hand Mace"] = { + "Metadata/Items/Gem/SkillGemPlayerDefaultMaceMace", + }, + ["Unarmed"] = { + "Metadata/Items/Gem/SkillGemPlayerDefault2HMace", + }, + }, + ["Two Hand Sword"] = { + ["One Hand Sword"] = { + "Metadata/Items/Gem/SkillGemPlayerDefaultSwordSword", + }, + ["Two Hand Sword"] = { + "Metadata/Items/Gem/SkillGemPlayerDefaultSwordSword", + }, + ["Unarmed"] = { + "Metadata/Items/Gem/SkillGemPlayerDefault2HSword", + }, + }, + ["Unarmed"] = { + ["Claw"] = { + "Metadata/Items/Gem/SkillGemPlayerDefaultClaw", + }, + ["Dagger"] = { + "Metadata/Items/Gem/SkillGemPlayerDefaultDagger", + }, + ["One Hand Axe"] = { + "Metadata/Items/Gem/SkillGemPlayerDefault1HAxe", + }, + ["One Hand Mace"] = { + "Metadata/Items/Gem/SkillGemPlayerDefault1HMace", + }, + ["One Hand Sword"] = { + "Metadata/Items/Gem/SkillGemPlayerDefault1HSword", + }, + ["Two Hand Axe"] = { + "Metadata/Items/Gem/SkillGemPlayerDefault1HAxe", + }, + ["Two Hand Mace"] = { + "Metadata/Items/Gem/SkillGemPlayerDefault1HMace", + }, + ["Two Hand Sword"] = { + "Metadata/Items/Gem/SkillGemPlayerDefault1HSword", + }, + ["Unarmed"] = { + "Metadata/Items/Gem/SkillGemPlayerDefaultUnarmed", + }, + }, + ["Warstaff"] = { + ["Unarmed"] = { + "Metadata/Items/Gem/SkillGemPlayerDefaultQuarterstaff", + }, + }, +} diff --git a/src/Data/ModCache.lua b/src/Data/ModCache.lua index 0a6ecfdd41..ee2ae09f46 100644 --- a/src/Data/ModCache.lua +++ b/src/Data/ModCache.lua @@ -8120,7 +8120,7 @@ c["Grants Skill: Ruzhan's Trap"]={nil,nil} c["Grants Skill: Ruzhan, the Blazing Sword"]={{[1]={flags=0,keywordFlags=0,name="ExtraSkill",type="LIST",value={level=1,skillId="SummonFireDjinnPlayer"}}},nil} c["Grants Skill: Shattering Concoction"]={{[1]={flags=0,keywordFlags=0,name="ExtraSkill",type="LIST",value={level=1,skillId="ShatteringConcoctionPlayer"}}},nil} c["Grants Skill: Sorcery Ward"]={{[1]={flags=0,keywordFlags=0,name="Condition:SorceryWard",type="FLAG",value=true},[2]={flags=0,keywordFlags=0,name="ExtraSkill",type="LIST",value={level=1,skillId="SorceryWardPlayer"}}},nil} -c["Grants Skill: Spear Throw"]={{[1]={flags=0,keywordFlags=0,name="ExtraSkill",type="LIST",value={level=1,skillId="SpearThrowPlayer"}}},nil} +c["Grants Skill: Spear Throw"]={{},nil} c["Grants Skill: Summon Infernal Hound"]={{[1]={flags=0,keywordFlags=0,name="ExtraSkill",type="LIST",value={level=1,skillId="SummonInfernalHoundPlayer"}}},nil} c["Grants Skill: Supporting Fire"]={{[1]={flags=0,keywordFlags=0,name="ExtraSkill",type="LIST",value={level=1,skillId="SupportingFirePlayer"}}},nil} c["Grants Skill: Temper Weapon"]={{[1]={flags=0,keywordFlags=0,name="ExtraSkill",type="LIST",value={level=1,skillId="TemperWeaponPlayer"}}},nil} diff --git a/src/Data/SkillStatMap.lua b/src/Data/SkillStatMap.lua index e1f4826422..3346810c6c 100644 --- a/src/Data/SkillStatMap.lua +++ b/src/Data/SkillStatMap.lua @@ -182,6 +182,9 @@ return function(mod, flag, skill) mod("LifeReservePercentPerSpirit", "BASE", nil), div = 100, }, +["display_skill_reserves_in_all_weapon_sets"] = { + skill("reservesInAllWeaponSets", true), +}, ["base_skill_cost_life_instead_of_mana"] = { flag("CostLifeInsteadOfMana"), }, diff --git a/src/Export/Scripts/miscdata.lua b/src/Export/Scripts/miscdata.lua index d096a5a7b7..a30343e843 100644 --- a/src/Export/Scripts/miscdata.lua +++ b/src/Export/Scripts/miscdata.lua @@ -179,4 +179,18 @@ for row in dat("BaseItemTypes"):Rows() do end end utils.saveTableToFile("../Data/CurrencyNames.lua", currencies, "This file contains mapping item names for every currency base item type ID.\nUsed for working with the currency exchange which uses item type IDs.") + +local characterMeleeSkills = { } +for row in dat("CharacterMeleeSkills"):Rows() do + local mainHand = row.MainHandItem.ItemClass.Id + local offHand = row.OffHandItem.ItemClass.Id + local skillGems = { } + for _, skillGem in ipairs(row.SkillGem) do + skillGems[#skillGems + 1] = skillGem.BaseItemType.Id + end + characterMeleeSkills[mainHand] = characterMeleeSkills[mainHand] or { } + characterMeleeSkills[mainHand][offHand] = skillGems +end +utils.saveTableToFile("../Data/CharacterMeleeSkills.lua", characterMeleeSkills, "Default skill gem base item IDs keyed by main-hand and off-hand WieldableClasses item class IDs.") + print("Misc data exported.") diff --git a/src/Modules/CalcDefence.lua b/src/Modules/CalcDefence.lua index f17b409def..b9e0c14773 100644 --- a/src/Modules/CalcDefence.lua +++ b/src/Modules/CalcDefence.lua @@ -195,8 +195,9 @@ function calcs.doActorLifeManaSpiritReservation(actor) breakdown.SpiritReserved = { reservations = { } } end for _, activeSkill in ipairs(actor.activeSkillList) do + local activeInWeaponSet = activeSkill.actor == actor or activeSkill.skillData.reservesInAllWeaponSets local isTotemAndAncestralBond = activeSkill.skillTypes[SkillType.SummonsTotem] and modDB:Flag(nil, "AncestralBond") - if (activeSkill.skillTypes[SkillType.HasReservation] or activeSkill.skillData.SupportedByAutoexertion) and not activeSkill.skillTypes[SkillType.ReservationBecomesCost] or isTotemAndAncestralBond then + if activeInWeaponSet and ((activeSkill.skillTypes[SkillType.HasReservation] or activeSkill.skillData.SupportedByAutoexertion) and not activeSkill.skillTypes[SkillType.ReservationBecomesCost] or isTotemAndAncestralBond) then local skillModList = activeSkill.skillModList local skillCfg = activeSkill.skillCfg local mult = floor(skillModList:More(skillCfg, "ReservationMultiplier"), 4) diff --git a/src/Modules/CalcSetup.lua b/src/Modules/CalcSetup.lua index 2922ef02f6..9c55ce816e 100644 --- a/src/Modules/CalcSetup.lua +++ b/src/Modules/CalcSetup.lua @@ -327,7 +327,7 @@ function calcs.buildModListForNode(env, node, reuse, incSmallPassiveSkill, inclu t_insert(node.grantedSkills, { skillId = skill.skillId, level = skill.level, - noSupports = true, + noSupports = skill.noSupports, source = "Tree:" .. node.id }) end @@ -368,7 +368,7 @@ function calcs.buildModListForNode(env, node, reuse, incSmallPassiveSkill, inclu if mod.name == "JewelSmallPassiveSkillEffect" then for _, modCriteria in ipairs(mod) do if modCriteria.type == "Condition" and modCriteria.var and modCriteria.var:match("^WeaponSet") then - if (tonumber(modCriteria.var:match("(%d)")) == (env.build.itemsTab.activeItemSet.useSecondWeaponSet and 2 or 1)) then + if tonumber(modCriteria.var:match("(%d)")) == env.weaponSet then localSmallIncEffect = mod.value end hasWSCondition = true @@ -381,7 +381,7 @@ function calcs.buildModListForNode(env, node, reuse, incSmallPassiveSkill, inclu if mod.name == "JewelNotablePassiveSkillEffect" then for _, modCriteria in ipairs(mod) do if modCriteria.type == "Condition" and modCriteria.var and modCriteria.var:match("^WeaponSet") then - if (tonumber(modCriteria.var:match("(%d)")) == (env.build.itemsTab.activeItemSet.useSecondWeaponSet and 2 or 1)) then + if tonumber(modCriteria.var:match("(%d)")) == env.weaponSet then localNotableIncEffect = mod.value end hasWSCondition = true @@ -675,6 +675,42 @@ local function getNormalizedSkillLevel(grantedSkill) return normalizedGrantedSkill.level end +local function getGrantedSkillLevel(gemData, maxLevel, characterLevel, modDB) + local str, dex, int + if modDB then + str = m_max(round(calcLib.val(modDB, "Str")), 0) + dex = m_max(round(calcLib.val(modDB, "Dex")), 0) + int = m_max(round(calcLib.val(modDB, "Int")), 0) + end + for level = maxLevel, 1, -1 do + local levelData = gemData.grantedEffect.levels[level] + local levelRequirement = levelData and levelData.levelRequirement + if levelRequirement and levelRequirement <= characterLevel and (not modDB + or calcLib.getGemStatRequirement(levelRequirement, gemData.reqStr, false) <= str + and calcLib.getGemStatRequirement(levelRequirement, gemData.reqDex, false) <= dex + and calcLib.getGemStatRequirement(levelRequirement, gemData.reqInt, false) <= int) then + return level + end + end + return 1 +end + +local defaultAttackSlots = { + { "Weapon 1", "Weapon 2" }, + { "Weapon 1 Swap", "Weapon 2 Swap" }, +} + +local function getCharacterMeleeSkillClass(item) + if item and item.base then + if item.base.tags.buckler then + return "Buckler" + elseif item.base.weapon then + return item.base.subType or item.base.type + end + end + return "Unarmed" +end + local thornsStats = { "PhysicalMin", "PhysicalMax", "FireMin", "FireMax", "ColdMin", "ColdMax", "LightningMin", "LightningMax", "ChaosMin", "ChaosMax" } local function modDBHasThornsDamage(modDB) for _, stat in ipairs(thornsStats) do @@ -698,6 +734,88 @@ local function defaultRadiusJewelFunc(node, out, data) end end end + +local function replaceWeaponSetActiveSkills(targetEnv, sourceEnv, sourceSet) + local replacements = { } + for _, activeSkill in ipairs(sourceEnv.player.activeSkillList) do + local group = activeSkill.socketGroup + if group and group.usingSkillSet == sourceSet then + if not replacements[group] then + replacements[group] = { } + end + t_insert(replacements[group], activeSkill) + end + end + local replaced = { } + local activeSkillList = { } + for _, activeSkill in ipairs(targetEnv.player.activeSkillList) do + local group = activeSkill.socketGroup + if replacements[group] then + if not replaced[group] then + replaced[group] = true + for _, replacement in ipairs(replacements[group]) do + t_insert(activeSkillList, replacement) + end + end + else + t_insert(activeSkillList, activeSkill) + end + end + targetEnv.player.activeSkillList = activeSkillList +end + +local function socketGroupHasGem(socketGroup, gemInstance) + for _, existing in ipairs(socketGroup.gemList) do + if gemInstance.gemId and existing.gemId == gemInstance.gemId + or gemInstance.skillId and existing.skillId == gemInstance.skillId + or not gemInstance.gemId and not gemInstance.skillId and existing.nameSpec == gemInstance.nameSpec then + return true + end + end + return false +end + +local function migrateLegacySlotSupports(socketGroup, legacyGroups, migratedGroups) + for _, legacyGroup in ipairs(legacyGroups) do + for _, gemInstance in ipairs(legacyGroup.gemList) do + local grantedEffect = gemInstance.grantedEffect or gemInstance.gemData and gemInstance.gemData.grantedEffect + if grantedEffect and grantedEffect.support then + migratedGroups[legacyGroup] = true + if not socketGroupHasGem(socketGroup, gemInstance) then + t_insert(socketGroup.gemList, copyTable(gemInstance, true)) + end + end + end + end +end + +local function getGrantedSkillGroupKey(source, slot, skillId) + return (source or "") .. "\0" .. (slot or "") .. "\0" .. (skillId or "") +end + +local grantedSkillGroupStateFields = { "label", "enabled", "includeInFullDPS", "groupCount", "set1", "set2", "mainActiveSkill", "mainActiveSkillCalcs" } +local function cacheGrantedSkillGroupState(build, group) + local sourceSlot = (group.sourceItem or group.source == "Default Attack") and group.slot and build.itemsTab.slots[group.slot] + local customized = #group.gemList > 1 or group.label and group.label:match("%S") or group.enabled == false + or group.includeInFullDPS or group.groupCount and group.groupCount ~= 1 + or not (sourceSlot and sourceSlot.weaponSet) and (group.set1 == false or group.set2 == false) + or group.mainActiveSkill and group.mainActiveSkill ~= 1 or group.mainActiveSkillCalcs and group.mainActiveSkillCalcs ~= 1 + if not customized then + return + end + local state = { gemList = { } } + for _, field in ipairs(grantedSkillGroupStateFields) do + state[field] = group[field] + end + for index = 2, #group.gemList do + local support = copyTable(group.gemList[index], true) + support.displayEffect = nil + support.supportEffect = nil + t_insert(state.gemList, support) + end + return state +end + ---@alias CalcEnvMode "MAIN"|"CALCS"|"CALCULATOR" -- Initialise environment: -- 1. Initialises the player and enemy modifier databases @@ -932,7 +1050,24 @@ function calcs.initEnv(build, mode, override, specEnv) local allocatedMasteryTypeCount = env.spec.allocatedMasteryTypeCount local allocatedMasteryTypes = copyTable(env.spec.allocatedMasteryTypes) - + -- Resolve the weapon set from the selected skill. Groups assigned to both sets + -- intentionally follow the set currently selected on the Items tab. + if env.mode == "CALCS" then + env.mainSocketGroup = m_min(m_max(#build.skillsTab.socketGroupList, 1), override.mainSocketGroup or env.calcsInput.skill_number or 1) + if not override.mainSocketGroup then + env.calcsInput.skill_number = env.mainSocketGroup + end + else + env.mainSocketGroup = m_min(m_max(#build.skillsTab.socketGroupList, 1), override.mainSocketGroup or build.mainSocketGroup or 1) + if not override.mainSocketGroup then + build.mainSocketGroup = env.mainSocketGroup + end + end + local mainSocketGroup = build.skillsTab.socketGroupList[env.mainSocketGroup] + env.weaponSet = override.weaponSet or build.skillsTab:GetSocketGroupWeaponSet(mainSocketGroup) + env.outputRevision = build.outputRevision + env.weaponSetEnvs = nil + env.weaponSetValidationSkillList = nil if not accelerate.nodeAlloc then -- Build list of passive nodes @@ -1017,7 +1152,7 @@ function calcs.initEnv(build, mode, override, specEnv) end -- add Conditional WeaponSet# base on weapon set from item - modDB:NewMod("Condition:WeaponSet" .. (build.itemsTab.activeItemSet.useSecondWeaponSet and 2 or 1) , "FLAG", true, "Weapon Set") + modDB:NewMod("Condition:WeaponSet" .. env.weaponSet, "FLAG", true, "Weapon Set") local weaponFlagState = { giantsBlood = nodesModsList:Flag(nil, "GiantsBlood") or modDB:Flag(nil, "GiantsBlood") or false, @@ -1038,7 +1173,7 @@ function calcs.initEnv(build, mode, override, specEnv) -- Build and merge item modifiers, and create list of radius jewels if not accelerate.requirementsItems then - local grantedNodes = env.spec:CollectGrantedPassiveNodesFromItems(build.itemsTab, env.allocNodes, env.configInput.ignoreJewelLimits, override, nodesModsList) + local grantedNodes = env.spec:CollectGrantedPassiveNodesFromItems(build.itemsTab, env.allocNodes, env.configInput.ignoreJewelLimits, override, nodesModsList, env.weaponSet) if mode == "MAIN" then if build.spec:SetGrantedPassiveNodes(grantedNodes) then build.itemsTab:UpdateSockets() @@ -1110,10 +1245,10 @@ function calcs.initEnv(build, mode, override, specEnv) t_insert(env.explodeSources, item) end - if slot.weaponSet and slot.weaponSet ~= (build.itemsTab.activeItemSet.useSecondWeaponSet and 2 or 1) then + if slot.weaponSet and slot.weaponSet ~= env.weaponSet then goto continue end - if slot.weaponSet == 2 and build.itemsTab.activeItemSet.useSecondWeaponSet then + if slot.weaponSet == 2 and env.weaponSet == 2 then slotName = slotName:gsub(" Swap","") end local node = slot.nodeId and env.spec.nodes[slot.nodeId] @@ -1689,19 +1824,92 @@ function calcs.initEnv(build, mode, override, specEnv) if not accelerate.skills then if env.mode == "MAIN" then + -- Keep both sets available, independently of the Items tab's selected set. + for _, slots in ipairs(defaultAttackSlots) do + local mainItem = build.itemsTab.items[build.itemsTab.slots[slots[1]].selItemId] + local offItem = build.itemsTab.items[build.itemsTab.slots[slots[2]].selItemId] + local skillsForMainHand = data.characterMeleeSkills[getCharacterMeleeSkillClass(mainItem)] + local defaultGems = skillsForMainHand and skillsForMainHand[getCharacterMeleeSkillClass(offItem)] + for _, gemData in ipairs(defaultGems or { }) do + if gemData then + t_insert(env.grantedSkills, { + skillId = gemData.grantedEffectId, nameSpec = gemData.name, slotName = slots[1], source = "Default Attack", + level = getGrantedSkillLevel(gemData, gemData.naturalMaxLevel, build.characterLevel), + }) + end + end + end -- Process extra skills granted by items or tree nodes local markList = wipeTable(tempTable1) + local mainGroup = build.skillsTab.socketGroupList[build.mainSocketGroup] + local calcsGroup = build.skillsTab.socketGroupList[build.calcsTab.input.skill_number] + local mergedGroups = { } + local removedSocketGroupList = build.skillsTab.skillSets[build.skillsTab.activeSkillSetId].removedSocketGroupList + local legacySupportGroups + for _, socketGroup in ipairs(build.skillsTab.socketGroupList) do + if not socketGroup.source and socketGroup.slot then + legacySupportGroups = legacySupportGroups or { } + legacySupportGroups[socketGroup.slot] = legacySupportGroups[socketGroup.slot] or { } + t_insert(legacySupportGroups[socketGroup.slot], socketGroup) + end + end + local migratedLegacyGroups for _, grantedSkill in ipairs(env.grantedSkills) do + if grantedSkill.sourceNode then + local grantedEffect = data.skills[grantedSkill.skillId] + local gemData = data.gems[data.gemForSkill[grantedEffect]] + grantedSkill.level = getGrantedSkillLevel(gemData, gemData.naturalMaxLevel, build.characterLevel) + end + local normalizedSkillLevel -- Check if a matching group already exists - local group - for index, socketGroup in pairs(build.skillsTab.socketGroupList) do - if socketGroup.source == grantedSkill.source and socketGroup.slot == grantedSkill.slotName then - if socketGroup.gemList[1] and socketGroup.gemList[1].skillId == grantedSkill.skillId and (socketGroup.gemList[1].level == grantedSkill.level or socketGroup.gemList[1].level == getNormalizedSkillLevel(grantedSkill)) then + local group, legacyGroup + for _, socketGroup in pairs(build.skillsTab.socketGroupList) do + local gemInstance = socketGroup.gemList[1] + if gemInstance and gemInstance.skillId == grantedSkill.skillId then + if socketGroup.source == grantedSkill.source and socketGroup.slot == grantedSkill.slotName then group = socketGroup - markList[socketGroup] = true - break + elseif not legacyGroup and not markList[socketGroup] and not socketGroup.source + and (grantedSkill.source ~= "Default Attack" or socketGroup.set1 and socketGroup.set2 + or build.skillsTab:GetSocketGroupWeaponSet(socketGroup) == build.itemsTab.slots[grantedSkill.slotName].weaponSet) then + local matchingLevel = grantedSkill.source == "Default Attack" or gemInstance.level == grantedSkill.level + if not matchingLevel then + normalizedSkillLevel = normalizedSkillLevel or getNormalizedSkillLevel(grantedSkill) + matchingLevel = gemInstance.level == normalizedSkillLevel + end + local grantedEffect = gemInstance.grantedEffect or gemInstance.gemData and gemInstance.gemData.grantedEffect + if matchingLevel and grantedEffect and (grantedSkill.sourceItem and grantedEffect.fromItem or grantedSkill.sourceNode and grantedEffect.fromTree + or grantedSkill.source == "Default Attack" and grantedEffect.fromItem) then + legacyGroup = socketGroup + end + end + end + end + if group and legacyGroup then + -- Keep the user's group, including its settings and any other active gems. + for index = 2, #group.gemList do + if not socketGroupHasGem(legacyGroup, group.gemList[index]) then + t_insert(legacyGroup.gemList, copyTable(group.gemList[index], true)) end end + mergedGroups[group] = true + if mainGroup == group then mainGroup = legacyGroup end + if calcsGroup == group then calcsGroup = legacyGroup end + if build.skillsTab.displayGroup == group then build.skillsTab.displayGroup = legacyGroup end + end + group = legacyGroup or group + if group then + markList[group] = true + end + if not group then + local removedKey = getGrantedSkillGroupKey(grantedSkill.source, grantedSkill.slotName, grantedSkill.skillId) + local cachedState = removedSocketGroupList[removedKey] + if cachedState then + removedSocketGroupList[removedKey] = nil + group = cachedState + t_insert(group.gemList, 1, { skillId = grantedSkill.skillId, nameSpec = grantedSkill.nameSpec }) + t_insert(build.skillsTab.socketGroupList, group) + markList[group] = true + end end if not group then -- Create a new group for this skill @@ -1711,27 +1919,44 @@ function calcs.initEnv(build, mode, override, specEnv) end -- Update the group + group.source = grantedSkill.source + group.slot = grantedSkill.slotName group.sourceItem = grantedSkill.sourceItem group.sourceNode = grantedSkill.sourceNode local activeGemInstance = group.gemList[1] or { skillId = grantedSkill.skillId, nameSpec = grantedSkill.nameSpec, - quality = 0, - enabled = true, } - activeGemInstance.fromItem = grantedSkill.sourceItem ~= nil + activeGemInstance.fromItem = grantedSkill.sourceItem ~= nil or grantedSkill.source == "Default Attack" + activeGemInstance.fromTree = grantedSkill.sourceNode ~= nil + activeGemInstance.sourceLevel = grantedSkill.sourceItem and grantedSkill.level or nil + activeGemInstance.skillId = grantedSkill.skillId + activeGemInstance.gemId = nil activeGemInstance.level = grantedSkill.level - activeGemInstance.gemId = data.gemForSkill[data.skills[grantedSkill.skillId]] + activeGemInstance.quality = 0 + activeGemInstance.enabled = true + activeGemInstance.count = 1 + activeGemInstance.corrupted = false + activeGemInstance.corruptLevel = 0 activeGemInstance.enableGlobal1 = true + activeGemInstance.enableGlobal2 = true activeGemInstance.noSupports = grantedSkill.noSupports group.noSupports = grantedSkill.noSupports activeGemInstance.noReservation = grantedSkill.noReservation activeGemInstance.triggered = grantedSkill.triggered activeGemInstance.triggerChance = grantedSkill.triggerChance - wipeTable(group.gemList) - t_insert(group.gemList, activeGemInstance) + group.gemList[1] = activeGemInstance + if grantedSkill.sourceItem and grantedSkill.slotName and legacySupportGroups and legacySupportGroups[grantedSkill.slotName] then + migratedLegacyGroups = migratedLegacyGroups or { } + migrateLegacySlotSupports(group, legacySupportGroups[grantedSkill.slotName], migratedLegacyGroups) + end build.skillsTab:ProcessSocketGroup(group) end + if migratedLegacyGroups then + for legacyGroup in pairs(migratedLegacyGroups) do + legacyGroup.slot = nil + end + end if #env.explodeSources ~= 0 then -- Check if a matching group already exists @@ -1838,15 +2063,40 @@ function calcs.initEnv(build, mode, override, specEnv) local i = 1 while build.skillsTab.socketGroupList[i] do local socketGroup = build.skillsTab.socketGroupList[i] - if socketGroup.source and not markList[socketGroup] then - t_remove(build.skillsTab.socketGroupList, i) + if mergedGroups[socketGroup] or socketGroup.source and not markList[socketGroup] then + local removed = t_remove(build.skillsTab.socketGroupList, i) + local sourceGem = removed.gemList[1] + if not mergedGroups[socketGroup] and sourceGem and removed.source ~= "Explode" and removed.source ~= "Thorns" then + local cachedState = cacheGrantedSkillGroupState(build, removed) + if cachedState then + removedSocketGroupList[getGrantedSkillGroupKey(removed.source, removed.slot, sourceGem.skillId)] = cachedState + end + end if build.skillsTab.displayGroup == socketGroup then build.skillsTab.displayGroup = nil end else + if socketGroup == mainGroup then build.mainSocketGroup = i end + if socketGroup == calcsGroup then build.calcsTab.input.skill_number = i end i = i + 1 end end + + -- Generated groups can become the selected main group, most notably when an + -- imported build contains only a skill granted by a swapped weapon. Restart + -- before weapon data and modifiers are consumed if that changes the context. + if not override.weaponSet then + local resolvedMainSocketGroup = m_min(m_max(#build.skillsTab.socketGroupList, 1), override.mainSocketGroup or build.mainSocketGroup) + local resolvedGroup = build.skillsTab.socketGroupList[resolvedMainSocketGroup] + local resolvedWeaponSet = build.skillsTab:GetSocketGroupWeaponSet(resolvedGroup) + if resolvedMainSocketGroup ~= env.mainSocketGroup or resolvedWeaponSet ~= env.weaponSet then + build.mainSocketGroup = resolvedMainSocketGroup + local resolvedOverride = copyTable(override, true) + resolvedOverride.mainSocketGroup = resolvedMainSocketGroup + resolvedOverride.weaponSet = resolvedWeaponSet + return calcs.initEnv(build, mode, resolvedOverride, specEnv) + end + end end -- Get the weapon data tables for the equipped weapons @@ -1885,15 +2135,6 @@ function calcs.initEnv(build, mode, override, specEnv) env.player.weaponData2 = env.player.itemList["Weapon 2"].weaponData and env.player.itemList["Weapon 2"].weaponData[2] or { } end - -- Determine main skill group - if env.mode == "CALCS" then - env.calcsInput.skill_number = m_min(m_max(#build.skillsTab.socketGroupList, 1), env.calcsInput.skill_number or 1) - env.mainSocketGroup = env.calcsInput.skill_number - else - build.mainSocketGroup = m_min(m_max(#build.skillsTab.socketGroupList, 1), build.mainSocketGroup or 1) - env.mainSocketGroup = build.mainSocketGroup - end - -- Process supports and put them into the correct buckets env.crossLinkedSupportGroups = {} for _, mod in ipairs(env.modDB:Tabulate("LIST", nil, "LinkedSupport")) do @@ -1906,10 +2147,21 @@ function calcs.initEnv(build, mode, override, specEnv) local processedSockets = {} -- Process support gems adding them to applicable support lists for index, group in ipairs(build.skillsTab.socketGroupList) do - local slot = group.slot and build.itemsTab.slots[group.slot] - group.slotEnabled = not slot or not slot.weaponSet or slot.weaponSet == (build.itemsTab.activeItemSet.useSecondWeaponSet and 2 or 1) + group.usingSkillSet = build.skillsTab:GetSocketGroupWeaponSet(group) + local sourceGem = group.gemList[1] + if sourceGem and sourceGem.fromItem and sourceGem.sourceLevel and sourceGem.gemData and group.usingSkillSet == env.weaponSet then + local level = getGrantedSkillLevel(sourceGem.gemData, sourceGem.sourceLevel, build.characterLevel, env.modDB) + if sourceGem.level ~= level then + sourceGem.level = level + local grantedEffect = sourceGem.gemData.grantedEffect + sourceGem.reqLevel = grantedEffect.levels[level].levelRequirement + sourceGem.reqStr = calcLib.getGemStatRequirement(sourceGem.reqLevel, sourceGem.gemData.reqStr, false) + sourceGem.reqDex = calcLib.getGemStatRequirement(sourceGem.reqLevel, sourceGem.gemData.reqDex, false) + sourceGem.reqInt = calcLib.getGemStatRequirement(sourceGem.reqLevel, sourceGem.gemData.reqInt, false) + end + end -- if group is main skill or group is enabled - if index == env.mainSocketGroup or (group.enabled and group.slotEnabled) then + if index == env.mainSocketGroup or group.enabled then local slotName = group.slot and group.slot:gsub(" Swap","") groupCfgList[slotName or "noSlot"] = groupCfgList[slotName or "noSlot"] or {} groupCfgList[slotName or "noSlot"][group] = groupCfgList[slotName or "noSlot"][group] or { @@ -1978,7 +2230,7 @@ function calcs.initEnv(build, mode, override, specEnv) -- Process active skills adding the applicable supports local socketGroupSkillListList = { } for index, group in ipairs(build.skillsTab.socketGroupList) do - if index == env.mainSocketGroup or (group.enabled and group.slotEnabled) then + if index == env.mainSocketGroup or group.enabled then local slotName = group.slot and group.slot:gsub(" Swap","") groupCfgList[slotName or "noSlot"][group] = groupCfgList[slotName or "noSlot"][group] or { slotName = slotName, @@ -1998,7 +2250,7 @@ function calcs.initEnv(build, mode, override, specEnv) for index, grantedEffect in ipairs(grantedEffectList) do if not grantedEffect.support and not grantedEffect.hideFromSideBar and (not grantedEffect.hasGlobalEffect or gemInstance["enableGlobal"..index]) then slotHasActiveSkill = true - if gemInstance.gemData and not virtuousMoteSkillCounted[gemInstance] and not (group.gemList[gemIndex].fromNode or group.gemList[gemIndex].fromItem) then + if gemInstance.gemData and not virtuousMoteSkillCounted[gemInstance] and not (group.gemList[gemIndex].fromNode or group.gemList[gemIndex].fromTree or group.gemList[gemIndex].fromItem) then virtuousMoteSkillCounted[gemInstance] = true local requiredAttributes = { } if gemInstance.gemData.reqStr > 0 then @@ -2054,26 +2306,6 @@ function calcs.initEnv(build, mode, override, specEnv) appliedSupportList = copyTable(supportLists[group] or supportLists[slotName][group], true) -- add displayGemList for tooltip to display all gems linked to active skills group.displayGemList = copyTable(group.gemList, true) - -- if skill granted by unique item, go through all support groups in slot - if group.source then - if supportLists[slotName] then - -- add socketed supports from other socketGroups - for _, otherSocketGroup in ipairs(build.skillsTab.socketGroupList) do - if otherSocketGroup.slot and otherSocketGroup.slot == group.slot then - for _, gem in ipairs(otherSocketGroup.gemList) do - if gem.gemData and gem.gemData.grantedEffect and gem.gemData.grantedEffect.support then - t_insert(group.displayGemList, gem) - end - end - end - end - for _, supportGroup in pairs(supportLists[slotName]) do - for _, supportEffect in ipairs(supportGroup) do - addBestSupport(supportEffect, appliedSupportList, env.mode) - end - end - end - end -- then add supports from crossLinked socketGroups for crossLinkedSupportSlot, crossLinkedSupportGroup in pairs(env.crossLinkedSupportGroups) do for _, crossLinkedSupportedSlot in ipairs(crossLinkedSupportGroup) do @@ -2104,7 +2336,7 @@ function calcs.initEnv(build, mode, override, specEnv) t_insert(env.player.activeSkillList, activeSkill) end end - if gemInstance.gemData and not (accelerate.requirementsGems or group.gemList[gemIndex].fromNode or group.gemList[gemIndex].fromItem) then + if gemInstance.gemData and not (accelerate.requirementsGems or group.gemList[gemIndex].fromNode or group.gemList[gemIndex].fromTree or group.gemList[gemIndex].fromItem) then t_insert(env.requirementsTableGems, { source = "Gem", sourceGem = gemInstance, @@ -2128,7 +2360,7 @@ function calcs.initEnv(build, mode, override, specEnv) socketGroupSkillListList[slotName or "noSlot"] = socketGroupSkillListList[slotName or "noSlot"] or {} socketGroupSkillListList[slotName or "noSlot"][group] = socketGroupSkillListList[slotName or "noSlot"][group] or {} local socketGroupSkillList = socketGroupSkillListList[slotName or "noSlot"][group] - if index == env.mainSocketGroup or (group.enabled and group.slotEnabled) then + if index == env.mainSocketGroup or group.enabled then groupCfgList[slotName or "noSlot"][group] = groupCfgList[slotName or "noSlot"][group] or { slotName = slotName, propertyModList = env.modDB:Tabulate("LIST", {slotName = slotName}, "GemProperty") @@ -2175,13 +2407,17 @@ function calcs.initEnv(build, mode, override, specEnv) end -- Save the active skill list for display in the socket group tooltip - group.displaySkillList = socketGroupSkillList - elseif env.mode == "CALCS" then + if group.usingSkillSet == env.weaponSet then + group.displaySkillList = socketGroupSkillList + elseif not group.displaySkillList then + group.displaySkillList = { } + end + elseif env.mode == "CALCS" and group.usingSkillSet == env.weaponSet then group.displaySkillListCalcs = socketGroupSkillList end -- Check for enabled energy blade to see if we need to regenerate everything. - if not modDB.conditions["AffectedByEnergyBlade"] and group.enabled and group.slotEnabled then + if not modDB.conditions["AffectedByEnergyBlade"] and group.enabled then for _, gemInstance in ipairs(group.gemList) do local grantedEffect = gemInstance.gemData and gemInstance.gemData.grantedEffect or gemInstance.grantedEffect if grantedEffect and not grantedEffect.support and gemInstance.enabled and grantedEffect.name == "Energy Blade" then @@ -2212,6 +2448,38 @@ function calcs.initEnv(build, mode, override, specEnv) for _, activeSkill in pairs(env.player.activeSkillList) do calcs.buildActiveSkillModList(env, activeSkill) end + + -- Rebuild auxiliary groups assigned exclusively to the other weapon set in + -- that set's actor context. Their skill modifier lists retain that context + -- when their buffs and debuffs are applied to the selected main skill. + if not override.skipWeaponSetContexts then + local otherSet + local otherSetMainGroup + for index, group in ipairs(build.skillsTab.socketGroupList) do + -- Bare default attacks have no auxiliary effects; evaluate their set when selected. + if group.enabled and group.usingSkillSet ~= env.weaponSet + and (group.source ~= "Default Attack" or #group.gemList > 1 or group.includeInFullDPS) then + otherSet = group.usingSkillSet + otherSetMainGroup = index + break + end + end + if otherSet then + local contextOverride = copyTable(override, true) + contextOverride.weaponSet = otherSet + contextOverride.mainSocketGroup = otherSetMainGroup + contextOverride.skipWeaponSetContexts = true + local contextMode = env.mode == "CALCS" and "CALCS" or "CALCULATOR" + local weaponSetEnv = calcs.initEnv(build, contextMode, contextOverride) + env.weaponSetEnvs = { [otherSet] = weaponSetEnv } + -- Preserve the uncomposed lists for checkbox validation; the composed lists + -- below intentionally replace groups with their assigned-set versions. + env.weaponSetValidationSkillList = env.player.activeSkillList + weaponSetEnv.weaponSetValidationSkillList = weaponSetEnv.player.activeSkillList + replaceWeaponSetActiveSkills(env, weaponSetEnv, otherSet) + replaceWeaponSetActiveSkills(weaponSetEnv, env, env.weaponSet) + end + end else -- Wipe skillData and readd required data the rest of the data will be added by the rest of code this stops iterative calculations on skillData not being reset for _, activeSkill in pairs(env.player.activeSkillList) do diff --git a/src/Modules/Calcs.lua b/src/Modules/Calcs.lua index fc55f96af3..0a4032a5e3 100644 --- a/src/Modules/Calcs.lua +++ b/src/Modules/Calcs.lua @@ -81,6 +81,9 @@ end ---@field toggleCharm Item? Item object used as a table key. ---@field conditions string[]? ---@field extraJewelFuncs ModList? +---@field weaponSet integer? +---@field mainSocketGroup integer? +---@field skipWeaponSetContexts boolean? -- Get calculator for other changes (adding/removing nodes, items, gems, etc) ---@param build Build @@ -248,6 +251,22 @@ local function surfacesEqual(refSurface, curSurface) return refSurface.metaStr == curSurface.metaStr and modListsEqual(refSurface.mods, curSurface.mods) end +local function getSocketGroupOverride(build, override, socketGroup) + local skillOverride = copyTable(override or { }, true) + skillOverride.weaponSet = socketGroup.usingSkillSet + skillOverride.mainSocketGroup = isValueInArray(build.skillsTab.socketGroupList, socketGroup) + return skillOverride +end + +local function findActiveSkillInEnv(env, sourceSkill) + for _, candidate in ipairs(env.player.activeSkillList) do + if candidate.socketGroup == sourceSkill.socketGroup and candidate.activeEffect.srcInstance == sourceSkill.activeEffect.srcInstance + and candidate.activeEffect.grantedEffect.id == sourceSkill.activeEffect.grantedEffect.id then + return candidate + end + end +end + function calcs.calcFullDPS(build, mode, override, specEnv) local fullEnv, cachedPlayerDB, cachedEnemyDB, cachedMinionDB = calcs.initEnv(build, mode, override, specEnv) local usedEnv = nil @@ -287,11 +306,24 @@ function calcs.calcFullDPS(build, mode, override, specEnv) local sources = { } + local initialWeaponSet = fullEnv.weaponSet + local initialActiveSkillList = fullEnv.player.activeSkillList - for _, activeSkill in ipairs(fullEnv.player.activeSkillList) do + for activeSkillIndex, sourceSkill in ipairs(initialActiveSkillList) do + local activeSkill = sourceSkill if activeSkill.socketGroup and activeSkill.socketGroup.includeInFullDPS then - local uuid = cacheStore and cacheSkillUUID(activeSkill, fullEnv) - local canCacheSkill = not (activeSkill.triggeredBy or activeSkill.skillData.triggered) + local skillEnv = fullEnv + local groupSet = activeSkill.socketGroup.usingSkillSet + local crossSetSkill = groupSet ~= initialWeaponSet + if groupSet ~= fullEnv.weaponSet then + skillEnv = fullEnv.weaponSetEnvs and fullEnv.weaponSetEnvs[groupSet] + if not skillEnv then + skillEnv = calcs.initEnv(build, mode, getSocketGroupOverride(build, override, activeSkill.socketGroup)) + end + end + activeSkill = findActiveSkillInEnv(skillEnv, sourceSkill) or activeSkill + local uuid = cacheStore and cacheSkillUUID(activeSkill, skillEnv) + local canCacheSkill = not crossSetSkill and not (activeSkill.triggeredBy or activeSkill.skillData.triggered) local cachedPasses if canCacheSkill and surfaceSame and activeSkill.baseSkillModList then local ref = cacheStore.refs[uuid] @@ -318,9 +350,9 @@ function calcs.calcFullDPS(build, mode, override, specEnv) ownRef[i] = mod end end - fullEnv.player.mainSkill = activeSkill - calcs.perform(fullEnv, true) - usedEnv = fullEnv + skillEnv.player.mainSkill = activeSkill + calcs.perform(skillEnv, true) + usedEnv = skillEnv -- Capture this pass's results into a plain snapshot, then merge it into the totals; -- the snapshot lets later calls reuse the results when this skill's inputs are unchanged local skillName = calcs.getActiveSkillDisplayName(activeSkill) @@ -340,7 +372,7 @@ function calcs.calcFullDPS(build, mode, override, specEnv) dotScale = 1, }) -- This is a fix to prevent Absolution spell hit from being counted multiple times when increasing minions count - if activeSkill.activeEffect.grantedEffect.name == "Absolution" and fullEnv.modDB:Flag(false, "Condition:AbsolutionSkillDamageCountedOnce") then + if activeSkill.activeEffect.grantedEffect.name == "Absolution" and skillEnv.modDB:Flag(false, "Condition:AbsolutionSkillDamageCountedOnce") then activeSkillCount = 1 activeSkill.infoMessage2 = "Skill Damage" end @@ -376,15 +408,30 @@ function calcs.calcFullDPS(build, mode, override, specEnv) cacheStore.refs[uuid] = ownRef end - -- Re-Build env calculator for new run - local accelerationTbl = { - nodeAlloc = true, - requirementsItems = true, - requirementsGems = true, - skills = true, - everything = true, - } - fullEnv, _, _, _ = calcs.initEnv(build, mode, override, { cachedPlayerDB = cachedPlayerDB, cachedEnemyDB = cachedEnemyDB, cachedMinionDB = cachedMinionDB, env = fullEnv, accelerate = accelerationTbl }) + local nextSkill + for nextIndex = activeSkillIndex + 1, #initialActiveSkillList do + local candidate = initialActiveSkillList[nextIndex] + if candidate.socketGroup and candidate.socketGroup.includeInFullDPS then + nextSkill = candidate + break + end + end + if nextSkill then + if fullEnv.weaponSetEnvs or nextSkill.socketGroup.usingSkillSet ~= fullEnv.weaponSet then + -- Per-set databases cannot be reused across a set transition, and paired + -- environments share auxiliary skill objects mutated by perform(). + fullEnv, cachedPlayerDB, cachedEnemyDB, cachedMinionDB = calcs.initEnv(build, mode, getSocketGroupOverride(build, override, nextSkill.socketGroup)) + else + local accelerationTbl = { + nodeAlloc = true, + requirementsItems = true, + requirementsGems = true, + skills = true, + everything = true, + } + fullEnv, _, _, _ = calcs.initEnv(build, mode, override, { cachedPlayerDB = cachedPlayerDB, cachedEnemyDB = cachedEnemyDB, cachedMinionDB = cachedMinionDB, env = fullEnv, accelerate = accelerationTbl }) + end + end end end end @@ -440,7 +487,9 @@ end -- Process active skill function calcs.buildActiveSkill(env, mode, skill, targetUUID, limitedProcessingFlags) - local fullEnv, _, _, _ = calcs.initEnv(env.build, mode, env.override) + local socketGroup = skill.socketGroup + local skillOverride = socketGroup and getSocketGroupOverride(env.build, env.override, socketGroup) or env.override + local fullEnv, _, _, _ = calcs.initEnv(env.build, mode, skillOverride) fullEnv.buildBreakdown = false -- env.limitedSkills contains a map of uuids that should be limited in calculation @@ -484,7 +533,11 @@ function calcs.buildOutput(build, mode) if mode == "MAIN" then for _, skill in ipairs(env.player.activeSkillList) do local uuid = cacheSkillUUID(skill, env) - if not GlobalCache.cachedData[mode][uuid] then + local group = skill.socketGroup + -- Bare default attacks in the other set need no cost calculation until inspected. + local deferred = group and group.source == "Default Attack" and #group.gemList == 1 + and not group.includeInFullDPS and group.usingSkillSet ~= env.weaponSet + if not deferred and not GlobalCache.cachedData[mode][uuid] then calcs.buildActiveSkill(env, mode, skill, uuid) end if GlobalCache.cachedData[mode][uuid] and (not skill.triggeredBy or skill.triggeredBy.grantedEffect.id ~= "SupportBlasphemyPlayer") then diff --git a/src/Modules/Common.lua b/src/Modules/Common.lua index 08c7688826..fb9ff0fdc8 100644 --- a/src/Modules/Common.lua +++ b/src/Modules/Common.lua @@ -923,7 +923,8 @@ function cacheSkillUUID(skill, env) end end - return strName.."_"..strSlotName.."_"..tostring(slotIndx) .. "_" .. tostring(groupIdx) + local weaponSet = skill.socketGroup and skill.socketGroup.usingSkillSet or env.weaponSet + return strName.."_"..strSlotName.."_"..tostring(slotIndx).."_"..tostring(groupIdx).."_WS"..tostring(weaponSet or 0) end -- Global Cache related diff --git a/src/Modules/Data.lua b/src/Modules/Data.lua index fd79321eb5..c1633fc1be 100644 --- a/src/Modules/Data.lua +++ b/src/Modules/Data.lua @@ -967,6 +967,7 @@ end -- Load gems data.gems = LoadModule("Data/Gems") +data.characterMeleeSkills = LoadModule("Data/CharacterMeleeSkills") data.assets = LoadModule("Data/Assets") data.skillAssets = LoadModule("Data/Skills/SkillAssets") data.gemForSkill = { } @@ -1068,6 +1069,17 @@ for id, gem in pairs(toAddGems) do data.gems[id] = gem end +-- Resolve exported default-attack gem IDs once. Keep missing entries as false so +-- later entries are still processed when a skill is not implemented yet. +for _, offHandSkills in pairs(data.characterMeleeSkills) do + for _, gems in pairs(offHandSkills) do + for index, gameId in ipairs(gems) do + local variants = data.gemsByGameId[gameId] + gems[index] = variants and variants[next(variants)] or false + end + end +end + -- Load minions data.minions = LoadModule("Data/Minions")(makeSkillMod, makeFlagMod) data.spectres = LoadModule("Data/Spectres")(makeSkillMod, makeFlagMod) diff --git a/src/Modules/ModParser.lua b/src/Modules/ModParser.lua index e19623fb2b..2d613ca56f 100644 --- a/src/Modules/ModParser.lua +++ b/src/Modules/ModParser.lua @@ -3583,6 +3583,7 @@ local specialModList = { mod("ScoldsBridleSelfDamage", "LIST", {dmgMult = dmgMult, damageType = dmgType}) } end, -- Extra skill/support + ["grants skill: spear throw"] = { }, -- Display-only; granted by CharacterMeleeSkills. ["grants skill: (%D+)"] = function(_, skill) return grantedExtraSkill(skill, 1) end, ["grants skill: level (%d+) (.+)"] = function(num, _, skill) return grantedExtraSkill(skill, num) end, ["[ct][ar][si][tg]g?e?r?s? level (%d+) (.+) when equipped"] = function(num, _, skill) return triggerExtraSkill(skill, num) end,