From 4d73b0a2b1444f18a55925291dfd297dbf739bee Mon Sep 17 00:00:00 2001 From: LocalIdentity Date: Wed, 2 Sep 2026 01:11:37 +1000 Subject: [PATCH 1/9] Add individual skill weapon set handling Allow skill groups to use Both, Set 1, or Set 2. Calculate skills, effects, and stats using the assigned weapon set. Add logic to auto apply / disable weapon sets if a skill can't be used with it Make item and tree granted skills into supportable groups that lock the skill so it can't be removed. Hide item/tree only gems from the dropdown list due to them having proper groups now. Added a bunch of tests and tried to make sure there weren't any obvious performance issues --- spec/System/TestImportReimport_spec.lua | 26 ++ spec/System/TestPassiveSpec_spec.lua | 30 ++ spec/System/TestSkillsTab_spec.lua | 182 ++++++++++ spec/System/TestSkills_spec.lua | 462 ++++++++++++++++++++++++ src/Classes/CalcsTab.lua | 36 +- src/Classes/EditControl.lua | 13 +- src/Classes/GemSelectControl.lua | 8 +- src/Classes/ItemDBControl.lua | 3 +- src/Classes/ItemsTab.lua | 21 +- src/Classes/PassiveSpec.lua | 3 +- src/Classes/PassiveTreeView.lua | 3 +- src/Classes/SkillListControl.lua | 2 +- src/Classes/SkillsTab.lua | 392 +++++++++++++++----- src/Modules/CalcDefence.lua | 4 +- src/Modules/CalcSetup.lua | 286 ++++++++++++--- src/Modules/Calcs.lua | 83 ++++- src/Modules/Common.lua | 3 +- 17 files changed, 1343 insertions(+), 214 deletions(-) diff --git a/spec/System/TestImportReimport_spec.lua b/spec/System/TestImportReimport_spec.lua index 7f91e661fd..35104ab895 100644 --- a/spec/System/TestImportReimport_spec.lua +++ b/spec/System/TestImportReimport_spec.lua @@ -286,6 +286,32 @@ Fireball 20/0 1 assert.are.equal("Metadata/Items/Gems/SkillGemPlayerDefault2HMace", build.skillsTab.socketGroupList[1].gemList[1].gemId) 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("uses unique database and rune levels when importing unique items from account data", function() while main.uniqueDB.loading do runCallback("OnFrame") 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..41330f15fc 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,192 @@ 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("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) + 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..dd1066cbb2 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 @@ -1305,6 +1323,450 @@ 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("Quarterstaff Strike 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("Quarterstaff Strike 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("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() + 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() + + local grantedGroup = findGrantedGroup("sourceItem", item) + 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("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 diff --git a/src/Classes/CalcsTab.lua b/src/Classes/CalcsTab.lua index 4ed3fe035d..032324f509 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] @@ -502,9 +503,16 @@ function CalcsTabClass:BuildOutput() end self.mainEnv = self.calcs.buildOutput(self.build, "MAIN") + if self.build.skillsTab:ReconcileSocketGroupWeaponSets(self.mainEnv) 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/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..7923d48042 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.shown() then t_insert(compareSlots, slot) end 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..89f7b279cd 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,50 @@ 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, "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 +232,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 +245,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 +293,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 +388,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 +461,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 +483,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 +564,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 +639,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 +681,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 +759,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 +798,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 +871,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 +896,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 +1014,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 +1057,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 +1082,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 +1104,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 @@ -1242,6 +1290,7 @@ end function SkillsTabClass:ProcessSocketGroup(socketGroup) -- Loop through the skill gem list local data = self.build.data + socketGroup.forcedBoth = false for _, gemInstance in ipairs(socketGroup.gemList) do gemInstance.color = "^8" gemInstance.nameSpec = gemInstance.nameSpec or "" @@ -1262,13 +1311,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 @@ -1293,6 +1344,14 @@ function SkillsTabClass:ProcessSocketGroup(socketGroup) end if gemInstance.gemData or gemInstance.grantedEffect then local grantedEffect = gemInstance.grantedEffect or gemInstance.gemData.grantedEffect + if not socketGroup.forcedBoth and grantedEffect.statSets then + for _, statSet in pairs(grantedEffect.statSets) do + if statSet.stats and isValueInArray(statSet.stats, "display_skill_reserves_in_all_weapon_sets") then + socketGroup.forcedBoth = true + break + end + end + end if grantedEffect.color == 1 then gemInstance.color = colorCodes.STRENGTH elseif grantedEffect.color == 2 then @@ -1319,6 +1378,158 @@ function SkillsTabClass:ProcessSocketGroup(socketGroup) end end end + local sourceSlot = socketGroup.sourceItem 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 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 + 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 env or env.outputRevision ~= self.build.outputRevision then + return + end + if not self.weaponSetValidityCache or self.weaponSetValidityRevision ~= self.build.outputRevision then + self.weaponSetValidityRevision = self.build.outputRevision + self.weaponSetValidityCache = { } + 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) and not socketGroup.forcedBoth then + local validity = self.weaponSetValidityCache and self.weaponSetValidityCache[socketGroup] + if 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 + 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 + if not self.weaponSetValidityCache or self.weaponSetValidityRevision ~= self.build.outputRevision then + self.weaponSetValidityRevision = self.build.outputRevision + self.weaponSetValidityCache = { } + end + self.weaponSetValidityCache[socketGroup] = self.weaponSetValidityCache[socketGroup] or { } + if self.weaponSetValidityCache[socketGroup][weaponSet] ~= nil then + return self.weaponSetValidityCache[socketGroup][weaponSet] + end + self:CacheSocketGroupWeaponSetValidity(self.build.calcsTab.mainEnv) + 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 + local flags = env.player.mainSkill.activeEffect.statSet and env.player.mainSkill.activeEffect.statSet.skillFlags + valid = not (flags and flags.disable) + 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 +1540,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) @@ -1356,9 +1568,6 @@ function SkillsTabClass:AddSocketGroupTooltip(tooltip, socketGroup) 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 +1623,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 +1644,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 +1710,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 +1730,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/Modules/CalcDefence.lua b/src/Modules/CalcDefence.lua index f17b409def..046eb3c343 100644 --- a/src/Modules/CalcDefence.lua +++ b/src/Modules/CalcDefence.lua @@ -195,8 +195,10 @@ function calcs.doActorLifeManaSpiritReservation(actor) breakdown.SpiritReserved = { reservations = { } } end for _, activeSkill in ipairs(actor.activeSkillList) do + local socketGroup = activeSkill.socketGroup + local activeInWeaponSet = activeSkill.actor == actor or socketGroup and socketGroup.forcedBoth 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..f0a3f4bdf9 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 @@ -698,6 +698,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 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 +1014,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 +1116,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 +1137,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 +1209,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] @@ -1691,18 +1790,54 @@ function calcs.initEnv(build, mode, override, specEnv) if env.mode == "MAIN" then -- Process extra skills granted by items or tree nodes local markList = wipeTable(tempTable1) + 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 + 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 then + local matchingLevel = 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) then + legacyGroup = socketGroup + end end end end + group = group or legacyGroup + 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 group = { label = "", enabled = true, gemList = { }, source = grantedSkill.source, slot = grantedSkill.slotName } @@ -1711,27 +1846,43 @@ 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.fromTree = grantedSkill.sourceNode ~= 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 @@ -1839,7 +1990,14 @@ function calcs.initEnv(build, mode, override, specEnv) 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) + local removed = t_remove(build.skillsTab.socketGroupList, i) + local sourceGem = removed.gemList[1] + if 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 @@ -1847,6 +2005,22 @@ function calcs.initEnv(build, mode, override, specEnv) 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), env.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 +2059,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 +2071,9 @@ 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) -- 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 +2142,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 +2162,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 +2218,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 +2248,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 +2272,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") @@ -2181,7 +2325,7 @@ function calcs.initEnv(build, mode, override, specEnv) 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 +2356,36 @@ 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 + if group.enabled and group.usingSkillSet ~= env.weaponSet 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..931538c3b8 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 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 From e9f247a406d406e133e162ac35e5cb0c82092f44 Mon Sep 17 00:00:00 2001 From: LocalIdentity Date: Wed, 2 Sep 2026 02:06:04 +1000 Subject: [PATCH 2/9] Fix imported levels of item / tree granted skills Skills granted via tree nodes have their level scaled based on your characters level so should level up or down Skills granted via items will set a maximum base level based on the item and will auto downscale based on if you meet the stat requirement and level requirement --- spec/System/TestImportReimport_spec.lua | 50 +++++++++++++++++++++++++ spec/System/TestSkills_spec.lua | 4 +- src/Modules/CalcSetup.lua | 38 +++++++++++++++++++ 3 files changed, 91 insertions(+), 1 deletion(-) diff --git a/spec/System/TestImportReimport_spec.lua b/spec/System/TestImportReimport_spec.lua index 35104ab895..49cf14d641 100644 --- a/spec/System/TestImportReimport_spec.lua +++ b/spec/System/TestImportReimport_spec.lua @@ -312,6 +312,56 @@ Fireball 20/0 1 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() while main.uniqueDB.loading do runCallback("OnFrame") diff --git a/spec/System/TestSkills_spec.lua b/spec/System/TestSkills_spec.lua index dd1066cbb2..28d16a531a 100644 --- a/spec/System/TestSkills_spec.lua +++ b/spec/System/TestSkills_spec.lua @@ -1692,7 +1692,9 @@ describe("TestSkills", function() end) it("preserves generated supports when the granted skill level changes", function() - local item = new("Item"):Item("New Item\nRazor Quarterstaff\nGrants Skill: Level 1 Fireball") + 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() diff --git a/src/Modules/CalcSetup.lua b/src/Modules/CalcSetup.lua index f0a3f4bdf9..2e41df9920 100644 --- a/src/Modules/CalcSetup.lua +++ b/src/Modules/CalcSetup.lua @@ -675,6 +675,26 @@ 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 thornsStats = { "PhysicalMin", "PhysicalMax", "FireMin", "FireMax", "ColdMin", "ColdMax", "LightningMin", "LightningMax", "ChaosMin", "ChaosMax" } local function modDBHasThornsDamage(modDB) for _, stat in ipairs(thornsStats) do @@ -1801,6 +1821,11 @@ function calcs.initEnv(build, mode, override, specEnv) 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, legacyGroup @@ -1856,6 +1881,7 @@ function calcs.initEnv(build, mode, override, specEnv) } activeGemInstance.fromItem = grantedSkill.sourceItem ~= nil 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 @@ -2072,6 +2098,18 @@ function calcs.initEnv(build, mode, override, specEnv) -- Process support gems adding them to applicable support lists for index, group in ipairs(build.skillsTab.socketGroupList) do 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 then local slotName = group.slot and group.slot:gsub(" Swap","") From 6d2d41f1673c1ec7af739f2850c4e2e5294e7a38 Mon Sep 17 00:00:00 2001 From: LocalIdentity Date: Wed, 2 Sep 2026 10:19:43 +1000 Subject: [PATCH 3/9] Fix import not validating weapon set selection --- spec/System/TestImportReimport_spec.lua | 17 +++++++ spec/System/TestSkillsTab_spec.lua | 4 ++ src/Classes/CalcsTab.lua | 4 +- src/Classes/ImportTab.lua | 2 +- src/Classes/SkillsTab.lua | 63 ++++++++++++------------- src/Data/SkillStatMap.lua | 3 ++ src/Modules/CalcDefence.lua | 3 +- 7 files changed, 57 insertions(+), 39 deletions(-) diff --git a/spec/System/TestImportReimport_spec.lua b/spec/System/TestImportReimport_spec.lua index 49cf14d641..4ec9695ab2 100644 --- a/spec/System/TestImportReimport_spec.lua +++ b/spec/System/TestImportReimport_spec.lua @@ -286,6 +286,23 @@ Fireball 20/0 1 assert.are.equal("Metadata/Items/Gems/SkillGemPlayerDefault2HMace", build.skillsTab.socketGroupList[1].gemList[1].gemId) 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 diff --git a/spec/System/TestSkillsTab_spec.lua b/spec/System/TestSkillsTab_spec.lua index 41330f15fc..3a1f893560 100644 --- a/spec/System/TestSkillsTab_spec.lua +++ b/spec/System/TestSkillsTab_spec.lua @@ -569,6 +569,10 @@ describe("TestSkillsTab", function() 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) diff --git a/src/Classes/CalcsTab.lua b/src/Classes/CalcsTab.lua index 032324f509..2efd2ac606 100644 --- a/src/Classes/CalcsTab.lua +++ b/src/Classes/CalcsTab.lua @@ -484,7 +484,7 @@ function CalcsTabClass:SearchMatch(txt) end -- Build the calculation output tables -function CalcsTabClass:BuildOutput() +function CalcsTabClass:BuildOutput(validateWeaponSets) self.powerBuildFlag = true --[[ @@ -503,7 +503,7 @@ function CalcsTabClass:BuildOutput() end self.mainEnv = self.calcs.buildOutput(self.build, "MAIN") - if self.build.skillsTab:ReconcileSocketGroupWeaponSets(self.mainEnv) then + if self.build.skillsTab:ReconcileSocketGroupWeaponSets(self.mainEnv, validateWeaponSets) then wipeGlobalCache() self.mainEnv = self.calcs.buildOutput(self.build, "MAIN") self.build.skillsTab:CacheSocketGroupWeaponSetValidity(self.mainEnv) diff --git a/src/Classes/ImportTab.lua b/src/Classes/ImportTab.lua index 3c93b7f5dd..0e11459b9c 100644 --- a/src/Classes/ImportTab.lua +++ b/src/Classes/ImportTab.lua @@ -1326,7 +1326,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/SkillsTab.lua b/src/Classes/SkillsTab.lua index 89f7b279cd..8af2e890af 100644 --- a/src/Classes/SkillsTab.lua +++ b/src/Classes/SkillsTab.lua @@ -1290,7 +1290,6 @@ end function SkillsTabClass:ProcessSocketGroup(socketGroup) -- Loop through the skill gem list local data = self.build.data - socketGroup.forcedBoth = false for _, gemInstance in ipairs(socketGroup.gemList) do gemInstance.color = "^8" gemInstance.nameSpec = gemInstance.nameSpec or "" @@ -1344,14 +1343,6 @@ function SkillsTabClass:ProcessSocketGroup(socketGroup) end if gemInstance.gemData or gemInstance.grantedEffect then local grantedEffect = gemInstance.grantedEffect or gemInstance.gemData.grantedEffect - if not socketGroup.forcedBoth and grantedEffect.statSets then - for _, statSet in pairs(grantedEffect.statSets) do - if statSet.stats and isValueInArray(statSet.stats, "display_skill_reserves_in_all_weapon_sets") then - socketGroup.forcedBoth = true - break - end - end - end if grantedEffect.color == 1 then gemInstance.color = colorCodes.STRENGTH elseif grantedEffect.color == 2 then @@ -1423,6 +1414,9 @@ local function cacheWeaponSetContext(skillsTab, context) 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 @@ -1436,12 +1430,15 @@ local function cacheWeaponSetContext(skillsTab, context) end function SkillsTabClass:CacheSocketGroupWeaponSetValidity(env) - if not env or env.outputRevision ~= self.build.outputRevision then - return - end 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 @@ -1469,18 +1466,23 @@ 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) and not socketGroup.forcedBoth then - local validity = self.weaponSetValidityCache and self.weaponSetValidityCache[socketGroup] - if 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 + 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 @@ -1498,15 +1500,8 @@ function SkillsTabClass:IsSocketGroupWeaponSetValid(socketGroup, weaponSet) if not socketGroup or not self.build.calcsTab or self.weaponSetValidityInProgress then return true end - if not self.weaponSetValidityCache or self.weaponSetValidityRevision ~= self.build.outputRevision then - self.weaponSetValidityRevision = self.build.outputRevision - self.weaponSetValidityCache = { } - end - self.weaponSetValidityCache[socketGroup] = self.weaponSetValidityCache[socketGroup] or { } - if self.weaponSetValidityCache[socketGroup][weaponSet] ~= nil then - return self.weaponSetValidityCache[socketGroup][weaponSet] - 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 @@ -1522,8 +1517,8 @@ function SkillsTabClass:IsSocketGroupWeaponSetValid(socketGroup, weaponSet) skipWeaponSetContexts = true, }) if ok and env and env.player.mainSkill then - local flags = env.player.mainSkill.activeEffect.statSet and env.player.mainSkill.activeEffect.statSet.skillFlags - valid = not (flags and flags.disable) + 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 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/Modules/CalcDefence.lua b/src/Modules/CalcDefence.lua index 046eb3c343..b9e0c14773 100644 --- a/src/Modules/CalcDefence.lua +++ b/src/Modules/CalcDefence.lua @@ -195,8 +195,7 @@ function calcs.doActorLifeManaSpiritReservation(actor) breakdown.SpiritReserved = { reservations = { } } end for _, activeSkill in ipairs(actor.activeSkillList) do - local socketGroup = activeSkill.socketGroup - local activeInWeaponSet = activeSkill.actor == actor or socketGroup and socketGroup.forcedBoth + local activeInWeaponSet = activeSkill.actor == actor or activeSkill.skillData.reservesInAllWeaponSets local isTotemAndAncestralBond = activeSkill.skillTypes[SkillType.SummonsTotem] and modDB:Flag(nil, "AncestralBond") if activeInWeaponSet and ((activeSkill.skillTypes[SkillType.HasReservation] or activeSkill.skillData.SupportedByAutoexertion) and not activeSkill.skillTypes[SkillType.ReservationBecomesCost] or isTotemAndAncestralBond) then local skillModList = activeSkill.skillModList From 9b684725164b15ac75aaf7f2de1df95e2c593139 Mon Sep 17 00:00:00 2001 From: LocalIdentity Date: Wed, 2 Sep 2026 10:30:05 +1000 Subject: [PATCH 4/9] Fix level not syncing When levelling up your character, it was not updating the level of tree granted skills until you caused the socket group UI to refresh --- spec/System/TestSkills_spec.lua | 19 +++++++++++++++++++ src/Classes/SkillsTab.lua | 6 +++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/spec/System/TestSkills_spec.lua b/spec/System/TestSkills_spec.lua index 28d16a531a..025fd5d7db 100644 --- a/spec/System/TestSkills_spec.lua +++ b/spec/System/TestSkills_spec.lua @@ -1717,6 +1717,25 @@ describe("TestSkills", function() 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) diff --git a/src/Classes/SkillsTab.lua b/src/Classes/SkillsTab.lua index 8af2e890af..5d019ae152 100644 --- a/src/Classes/SkillsTab.lua +++ b/src/Classes/SkillsTab.lua @@ -1216,7 +1216,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() From b31eae7fcfb16a9720c7ff20e51d2cefde452430 Mon Sep 17 00:00:00 2001 From: LocalIdentity Date: Wed, 2 Sep 2026 11:30:09 +1000 Subject: [PATCH 5/9] Fix minion skills not showing the stat set dropdown sometimes When you set a minion to use weapon set 2 but had weapon set 1 active then it was hiding the skill set dropdowns in the sidebar and calcs tab --- spec/System/TestSkills_spec.lua | 18 ++++++++++++++++++ src/Modules/CalcSetup.lua | 6 ++++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/spec/System/TestSkills_spec.lua b/spec/System/TestSkills_spec.lua index 025fd5d7db..6fae91f78b 100644 --- a/spec/System/TestSkills_spec.lua +++ b/spec/System/TestSkills_spec.lua @@ -313,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") diff --git a/src/Modules/CalcSetup.lua b/src/Modules/CalcSetup.lua index 2e41df9920..ea6bae57a4 100644 --- a/src/Modules/CalcSetup.lua +++ b/src/Modules/CalcSetup.lua @@ -2357,8 +2357,10 @@ 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 + end + elseif env.mode == "CALCS" and group.usingSkillSet == env.weaponSet then group.displaySkillListCalcs = socketGroupSkillList end From 02949c63f6a969dafac7e92e50282ce5e716bee7 Mon Sep 17 00:00:00 2001 From: LocalIdentity Date: Fri, 4 Sep 2026 10:45:59 +1000 Subject: [PATCH 6/9] Auto grant default attacks for weapons Use the CharacterMeleeSkills table to set the default skill that should be added based on the current equipped weapons Auto level the skills based on the player level Spear Throw on weapons is just display text so make it not add a gem as that's handled by the dat file Add tests for a variety of scenarios --- spec/System/TestAttacks_spec.lua | 3 - spec/System/TestImportReimport_spec.lua | 60 ++++++++- spec/System/TestItemParse_spec.lua | 15 ++- spec/System/TestSkillsTab_spec.lua | 17 +++ spec/System/TestSkills_spec.lua | 133 ++++++++++++++++++-- src/Classes/ImportTab.lua | 34 ++---- src/Classes/SkillsTab.lua | 23 +++- src/Data/CharacterMeleeSkills.lua | 154 ++++++++++++++++++++++++ src/Data/ModCache.lua | 2 +- src/Export/Scripts/miscdata.lua | 14 +++ src/Modules/CalcSetup.lua | 76 ++++++++++-- src/Modules/Calcs.lua | 6 +- src/Modules/Data.lua | 12 ++ src/Modules/ModParser.lua | 1 + 14 files changed, 493 insertions(+), 57 deletions(-) create mode 100644 src/Data/CharacterMeleeSkills.lua 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 4ec9695ab2..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,40 @@ 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() 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/TestSkillsTab_spec.lua b/spec/System/TestSkillsTab_spec.lua index 3a1f893560..9beea57621 100644 --- a/spec/System/TestSkillsTab_spec.lua +++ b/spec/System/TestSkillsTab_spec.lua @@ -544,6 +544,23 @@ describe("TestSkillsTab", function() 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] diff --git a/spec/System/TestSkills_spec.lua b/spec/System/TestSkills_spec.lua index 6fae91f78b..18b4862a7f 100644 --- a/spec/System/TestSkills_spec.lua +++ b/spec/System/TestSkills_spec.lua @@ -685,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 @@ -708,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 @@ -734,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") @@ -769,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") @@ -1091,7 +1087,6 @@ describe("TestSkills", function() Quality: 0 ]]) build.itemsTab:AddDisplayItem() - runCallback("OnFrame") build.skillsTab:PasteSocketGroup("Spiral Volley 20/0 1") runCallback("OnFrame") @@ -1109,7 +1104,6 @@ describe("TestSkills", function() Quality: 0 ]]) build.itemsTab:AddDisplayItem() - runCallback("OnFrame") build.skillsTab:PasteSocketGroup("Ice Shot 20/0 1") runCallback("OnFrame") @@ -1187,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") @@ -1227,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") @@ -1567,7 +1559,7 @@ describe("TestSkills", function() 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("Quarterstaff Strike 20/0 1") + build.skillsTab:PasteSocketGroup("Falling Thunder 20/0 1") local group = build.skillsTab.socketGroupList[1] assignWeaponSet(group) recalculate() @@ -1592,7 +1584,7 @@ describe("TestSkills", function() 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("Quarterstaff Strike 20/0 1") + build.skillsTab:PasteSocketGroup("Falling Thunder 20/0 1") local group = build.skillsTab.socketGroupList[1] assignWeaponSet(group) build.mainSocketGroup = 1 @@ -1633,6 +1625,122 @@ describe("TestSkills", function() 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) @@ -1718,6 +1826,8 @@ describe("TestSkills", function() 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, @@ -1986,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") @@ -2022,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" @@ -2042,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/ImportTab.lua b/src/Classes/ImportTab.lua index 0e11459b9c..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 diff --git a/src/Classes/SkillsTab.lua b/src/Classes/SkillsTab.lua index 5d019ae152..56f005b237 100644 --- a/src/Classes/SkillsTab.lua +++ b/src/Classes/SkillsTab.lua @@ -190,7 +190,9 @@ function SkillsTabClass:SkillsTab(build) 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, "Skills granted by items in weapon slots can only be used in that item's weapon set.") + 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 .. ".") @@ -1203,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) @@ -1373,7 +1376,7 @@ function SkillsTabClass:ProcessSocketGroup(socketGroup) end end end - local sourceSlot = socketGroup.sourceItem and socketGroup.slot and self.build.itemsTab.slots[socketGroup.slot] + 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 @@ -1388,7 +1391,7 @@ function SkillsTabClass:ProcessSocketGroup(socketGroup) end function SkillsTabClass:IsSocketGroupWeaponSetLocked(socketGroup) - local sourceSlot = socketGroup and socketGroup.sourceItem and socketGroup.slot and self.build.itemsTab.slots[socketGroup.slot] + 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 @@ -1560,7 +1563,21 @@ 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 "???")) 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/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/CalcSetup.lua b/src/Modules/CalcSetup.lua index ea6bae57a4..9c55ce816e 100644 --- a/src/Modules/CalcSetup.lua +++ b/src/Modules/CalcSetup.lua @@ -695,6 +695,22 @@ local function getGrantedSkillLevel(gemData, maxLevel, characterLevel, modDB) 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 @@ -779,7 +795,7 @@ end local grantedSkillGroupStateFields = { "label", "enabled", "includeInFullDPS", "groupCount", "set1", "set2", "mainActiveSkill", "mainActiveSkillCalcs" } local function cacheGrantedSkillGroupState(build, group) - local sourceSlot = group.sourceItem and group.slot and build.itemsTab.slots[group.slot] + 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) @@ -1808,8 +1824,26 @@ 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 @@ -1834,21 +1868,35 @@ function calcs.initEnv(build, mode, override, specEnv) if gemInstance and gemInstance.skillId == grantedSkill.skillId then if socketGroup.source == grantedSkill.source and socketGroup.slot == grantedSkill.slotName then group = socketGroup - break - elseif not legacyGroup and not markList[socketGroup] and not socketGroup.source then - local matchingLevel = gemInstance.level == grantedSkill.level + 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) then + 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 - group = group or legacyGroup + 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 @@ -1879,7 +1927,7 @@ function calcs.initEnv(build, mode, override, specEnv) skillId = grantedSkill.skillId, nameSpec = grantedSkill.nameSpec, } - 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 @@ -2015,10 +2063,10 @@ 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 + 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 sourceGem and removed.source ~= "Explode" and removed.source ~= "Thorns" then + 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 @@ -2028,6 +2076,8 @@ function calcs.initEnv(build, mode, override, specEnv) 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 @@ -2036,7 +2086,7 @@ function calcs.initEnv(build, mode, override, specEnv) -- 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), env.mainSocketGroup) + 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 @@ -2359,6 +2409,8 @@ function calcs.initEnv(build, mode, override, specEnv) -- Save the active skill list for display in the socket group tooltip 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 @@ -2404,7 +2456,9 @@ function calcs.initEnv(build, mode, override, specEnv) local otherSet local otherSetMainGroup for index, group in ipairs(build.skillsTab.socketGroupList) do - if group.enabled and group.usingSkillSet ~= env.weaponSet then + -- 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 diff --git a/src/Modules/Calcs.lua b/src/Modules/Calcs.lua index 931538c3b8..0a4032a5e3 100644 --- a/src/Modules/Calcs.lua +++ b/src/Modules/Calcs.lua @@ -533,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/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, From 39119aee9804b58eddd2bc2df2809dba25de4caa Mon Sep 17 00:00:00 2001 From: LocalIdentity Date: Fri, 4 Sep 2026 11:03:43 +1000 Subject: [PATCH 7/9] Fix compare not using the item set the skill is assigned to When comparing a weapon for a skill, it could show no difference if you were on set 1 while the skill was set to use only set 2 It now uses the set the skill is set to --- spec/System/TestItemsTab_spec.lua | 35 +++++++++++++++++++++++++++++++ src/Classes/ItemsTab.lua | 5 ++++- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/spec/System/TestItemsTab_spec.lua b/spec/System/TestItemsTab_spec.lua index 19c6d80020..43e86a2c48 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({ 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/src/Classes/ItemsTab.lua b/src/Classes/ItemsTab.lua index 7923d48042..b6e9b1e56f 100644 --- a/src/Classes/ItemsTab.lua +++ b/src/Classes/ItemsTab.lua @@ -4342,7 +4342,7 @@ function ItemsTabClass:AddItemTooltip(tooltip, item, slot, dbMode, maxWidth) 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 == weaponSet) 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 @@ -4374,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 From c5c9d31916b35c44f31cedff892164392b1f637e Mon Sep 17 00:00:00 2001 From: LocalIdentity Date: Fri, 4 Sep 2026 11:24:28 +1000 Subject: [PATCH 8/9] Fix test --- spec/System/TestItemMods_spec.lua | 1 - 1 file changed, 1 deletion(-) diff --git a/spec/System/TestItemMods_spec.lua b/spec/System/TestItemMods_spec.lua index 18c1975a11..ebfd310856 100644 --- a/spec/System/TestItemMods_spec.lua +++ b/spec/System/TestItemMods_spec.lua @@ -640,7 +640,6 @@ 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) end) From 86acf81e6f8019f16c6867d78424aee8b749b23f Mon Sep 17 00:00:00 2001 From: LocalIdentity Date: Fri, 4 Sep 2026 13:30:42 +1000 Subject: [PATCH 9/9] Fix tests --- spec/System/TestItemMods_spec.lua | 4 ++-- spec/System/TestItemsTab_spec.lua | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/spec/System/TestItemMods_spec.lua b/spec/System/TestItemMods_spec.lua index ebfd310856..64c59511da 100644 --- a/spec/System/TestItemMods_spec.lua +++ b/spec/System/TestItemMods_spec.lua @@ -639,8 +639,8 @@ describe("TetsItemMods", function() ]]) build.itemsTab:AddDisplayItem() runCallback("OnFrame") - build.skillsTab:PasteSocketGroup("Leap Slam 20/0 1") - 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/TestItemsTab_spec.lua b/spec/System/TestItemsTab_spec.lua index 43e86a2c48..f8c3fc6b73 100644 --- a/spec/System/TestItemsTab_spec.lua +++ b/spec/System/TestItemsTab_spec.lua @@ -46,7 +46,7 @@ describe("TestItemsTab", function() 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({ case[4] }, slots) + assert.are.same(slotOnly and { case[4] } or { case[4], case[4] }, slots) end end end)