diff --git a/src/Classes/ImportTab.lua b/src/Classes/ImportTab.lua index 640217b202..b3bfe135db 100644 --- a/src/Classes/ImportTab.lua +++ b/src/Classes/ImportTab.lua @@ -732,6 +732,7 @@ function ImportTabClass:ImportPassiveTreeAndJewels(charData) self.build.treeTab.controls.versionSelect.selIndex = #self.build.treeTab.treeVersions -- attributes nodes for skillId, nodeInfo in pairs(charPassiveData.skill_overrides) do + local id = tonumber(skillId) local changeAttributeId = 0 if nodeInfo.name == "Intelligence" then changeAttributeId = 3 @@ -742,10 +743,15 @@ function ImportTabClass:ImportPassiveTreeAndJewels(charData) end if changeAttributeId > 0 then - local id = tonumber(skillId) self.build.spec:SwitchAttributeNode(id, changeAttributeId) local node = self.build.spec.nodes[id] - + if node then + self.build.spec:ReplaceNode(node, self.build.spec.hashOverrides[id]) + end + elseif nodeInfo.stats and nodeInfo.stats[1] then + local stat = escapeGGGString(nodeInfo.stats[1]):lower() + self.build.spec:SwitchAttributeNode(id, stat) + local node = self.build.spec.nodes[id] if node then self.build.spec:ReplaceNode(node, self.build.spec.hashOverrides[id]) end diff --git a/src/Classes/PassiveSpec.lua b/src/Classes/PassiveSpec.lua index 294782679c..1d4b0a6758 100644 --- a/src/Classes/PassiveSpec.lua +++ b/src/Classes/PassiveSpec.lua @@ -189,17 +189,24 @@ function PassiveSpecClass:Load(xml, dbFileName) if node.elem == "Overrides" then for _, child in ipairs(node) do if child.elem == "AttributeOverride" then - for strengthId in child.attrib.strNodes:gmatch("%d+") do - self:SwitchAttributeNode(tonumber(strengthId), 1) + -- Load standard attributes + for id in (child.attrib.strNodes or ""):gmatch("%d+") do + self:SwitchAttributeNode(tonumber(id), 1) end - for dexterityId in child.attrib.dexNodes:gmatch("%d+") do - self:SwitchAttributeNode(tonumber(dexterityId), 2) + for id in (child.attrib.dexNodes or ""):gmatch("%d+") do + self:SwitchAttributeNode(tonumber(id), 2) end - for intelligenceId in child.attrib.intNodes:gmatch("%d+") do - self:SwitchAttributeNode(tonumber(intelligenceId), 3) + for id in (child.attrib.intNodes or ""):gmatch("%d+") do + self:SwitchAttributeNode(tonumber(id), 3) + end + -- Load custom attributes from child elements + for _, subChild in ipairs(child) do + if subChild.elem == "Custom" then + for id in (subChild.attrib.nodes or ""):gmatch("%d+") do + self:SwitchAttributeNode(tonumber(id), subChild.attrib.name) + end + end end - else - ConPrintf("[PassiveSpecClass:Load] Unexpected element found in Overrides: " .. child.elem) end end end @@ -281,19 +288,31 @@ function PassiveSpecClass:Save(xml) elem = "Overrides" } if self.hashOverrides then - local strList, dexList, intList = { }, { }, { } + local standard = { [1] = { }, [2] = { }, [3] = { } } + local custom = { } for nodeId, node in pairs(self.hashOverrides) do if node.isAttribute then - if node.dn == "Strength" then - t_insert(strList, nodeId) - elseif node.dn == "Dexterity" then - t_insert(dexList, nodeId) - elseif node.dn == "Intelligence" then - t_insert(intList, nodeId) + local index = node.attributeIndex or 1 + if index <= 3 then + t_insert(standard[index], nodeId) + else + local stat = node.stats and node.stats[1] + if stat then + custom[stat] = custom[stat] or { } + t_insert(custom[stat], nodeId) + end end end end - local attributeOverride = { elem = "AttributeOverride", attrib = { strNodes = table.concat(strList, ","), dexNodes = table.concat(dexList, ","), intNodes = table.concat(intList, ",") } } + local attrib = { + strNodes = table.concat(standard[1], ","), + dexNodes = table.concat(standard[2], ","), + intNodes = table.concat(standard[3], ",") + } + local attributeOverride = { elem = "AttributeOverride", attrib = attrib } + for stat, ids in pairs(custom) do + t_insert(attributeOverride, { elem = "Custom", attrib = { name = stat, nodes = table.concat(ids, ",") } }) + end t_insert(overrides, attributeOverride) end t_insert(xml, overrides) @@ -866,6 +885,13 @@ end -- Deallocate the given node, and all nodes which depend on it (i.e. which are only connected to the tree through this node) function PassiveSpecClass:DeallocNode(node) + local providesAttributeOption = false + for i = 1, #node.modList do + if node.modList[i].name == "AttributeOption" then + providesAttributeOption = true + break + end + end for _, depNode in ipairs(node.depends) do -- reset any switched attribute nodes if depNode.isAttribute then @@ -874,6 +900,10 @@ function PassiveSpecClass:DeallocNode(node) self:DeallocSingleNode(depNode) end + if providesAttributeOption then + self:RevertInvalidAttributeOverrides() + end + -- Rebuild all paths and dependencies for all allocated nodes self:BuildAllDependsAndPaths() end @@ -1698,6 +1728,7 @@ function PassiveSpecClass:ReplaceNode(old, newNode) old.keystoneMod = newNode.keystoneMod old.activeEffectImage = newNode.activeEffectImage old.reminderText = newNode.reminderText or { } + old.attributeIndex = newNode.attributeIndex end ---Reconnects altered timeless jewel to class start, for Pure Talent @@ -2351,15 +2382,139 @@ function PassiveSpecClass:NodeInKeystoneRadius(keystoneNames, nodeId, radiusInde return false end +-- Derives a short display name from a raw attribute option stat string. +-- e.g. "5% increased damage" -> "Damage", "+2% movement speed" -> "movement speed" +local function optionDisplayName(optionText) + return optionText:match("%% increased (.+)") or optionText:match("%+?%d+%%? (.+)") or optionText +end + +function PassiveSpecClass:GetOptionDisplayName(optionText) + return optionDisplayName(optionText) +end + +function PassiveSpecClass:RevertInvalidAttributeOverrides() + local dynamicOptions = self:GetDynamicAttributeOptions() + local optionsMap = { } + for _, stat in ipairs(dynamicOptions) do + optionsMap[stat] = true + end + local reverted = false + for nodeId, overrideNode in pairs(self.hashOverrides) do + if overrideNode.isAttribute and overrideNode.attributeIndex and overrideNode.attributeIndex > 3 then + local stat = overrideNode.stats and overrideNode.stats[1] + if stat and not optionsMap[stat] then + self.hashOverrides[nodeId] = nil + reverted = true + end + end + end + if (self.attributeIndex or 1) > 3 then + if not optionsMap[dynamicOptions[self.attributeIndex - 3]] then + self.attributeIndex = 1 + end + end + return reverted +end + +function PassiveSpecClass:GetDynamicAttributeOptions() + -- Cache keyed on outputRevision: rebuild only when the build recalculates, not every frame. + local rev = self.build and self.build.outputRevision or 0 + if self._dynAttrCache and self._dynAttrCacheRev == rev then + return self._dynAttrCache + end + local optionsMap = { } + local optionsList = { } + -- Scan all allocated nodes for AttributeOption modifiers + for _, node in pairs(self.allocNodes) do + for i = 1, #node.modList do + local mod = node.modList[i] + if mod.name == "AttributeOption" and not optionsMap[mod.value] then + optionsMap[mod.value] = true + t_insert(optionsList, mod.value) + end + end + end + -- Scan all equipped items for AttributeOption modifiers + if self.build and self.build.itemsTab then + for _, slot in pairs(self.build.itemsTab.slots) do + local itemId = slot.selItemId + if itemId and itemId ~= 0 then + local item = self.build.itemsTab.items[itemId] + if item and item.baseModList then + for _, mod in ipairs(item.baseModList) do + if mod.name == "AttributeOption" and not optionsMap[mod.value] then + optionsMap[mod.value] = true + t_insert(optionsList, mod.value) + end + end + end + end + end + end + -- Scan custom modifiers from the Config tab + if self.build and self.build.configTab then + local customMods = self.build.configTab.input.customMods + if customMods then + for line in customMods:gmatch("([^\n]*)\n?") do + local strippedLine = StripEscapes(line):gsub("^[%s?]+", ""):gsub("[%s?]+$", "") + if strippedLine ~= "" then + local mods, extra = modLib.parseMod(strippedLine) + if mods and not extra then + for _, mod in ipairs(mods) do + if mod.name == "AttributeOption" and not optionsMap[mod.value] then + optionsMap[mod.value] = true + t_insert(optionsList, mod.value) + end + end + end + end + end + end + end + table.sort(optionsList) + self._dynAttrCache = optionsList + self._dynAttrCacheRev = rev + return optionsList +end + function PassiveSpecClass:SwitchAttributeNode(nodeId, attributeIndex) - if self.tree.nodes[nodeId] then --Make sure node exists on current tree - local newNode = copyTableSafe(self.tree.nodes[nodeId], false, true) - if not newNode.isAttribute then return end -- safety check - - local option = newNode.options[attributeIndex] - self:ReplaceNode(newNode, option) - self.tree:ProcessStats(newNode) - - self.hashOverrides[nodeId] = newNode + local node = self.tree.nodes[nodeId] + if node then + local newNode = copyTableSafe(node, false, true) + if not newNode.isAttribute then return end + + local option + if type(attributeIndex) == "number" and attributeIndex <= 3 then + option = newNode.options[attributeIndex] + else + local optionText + local index = attributeIndex + if type(attributeIndex) == "string" then + -- Find by stat string (used for loading) + optionText = attributeIndex + index = 4 -- Assign an arbitrary index > 3 so it's treated as custom by Save() and UI + else + -- Find by index (used for UI/Hotkeys) + local dynamicOptions = self:GetDynamicAttributeOptions() + optionText = dynamicOptions[attributeIndex - 3] + end + + if optionText then + option = { name = optionDisplayName(optionText), sd = { optionText }, icon = "Art/2DArt/SkillIcons/passives/PathFinder/PathfinderAdditionalPoints.dds" } + attributeIndex = index + end + end + + if option then + self:ReplaceNode(newNode, option) + newNode.attributeIndex = attributeIndex + if attributeIndex > 3 then + -- Persist the stat string so Save() and DeallocNode reversion can find it + -- (ReplaceNode does not copy the stats field) + newNode.stats = option.sd + end + self.tree:ProcessStats(newNode) + self.hashOverrides[nodeId] = newNode + end end end diff --git a/src/Classes/PassiveTreeView.lua b/src/Classes/PassiveTreeView.lua index 1f00fedcfe..8414ba3580 100644 --- a/src/Classes/PassiveTreeView.lua +++ b/src/Classes/PassiveTreeView.lua @@ -266,10 +266,21 @@ function PassiveTreeViewClass:Draw(build, viewPort, inputEvents) elseif IsKeyDown("1") or IsKeyDown("I") then spec.attributeIndex = 3 if switchAttribute then spec:SwitchAttributeNode(hoverNode.id, 3) end + else + local dynamicOptions = spec:GetDynamicAttributeOptions() + for i = 4, 9 do + if IsKeyDown(tostring(i)) then + if dynamicOptions[i - 3] then + spec.attributeIndex = i + if switchAttribute then spec:SwitchAttributeNode(hoverNode.id, i) end + end + break + end + end end end - local hotkeyPressed = IsKeyDown("1") or IsKeyDown("I") or IsKeyDown("2") or IsKeyDown("S") or IsKeyDown("3") or IsKeyDown("D") + local hotkeyPressed = IsKeyDown("1") or IsKeyDown("I") or IsKeyDown("2") or IsKeyDown("S") or IsKeyDown("3") or IsKeyDown("D") or IsKeyDown("4") or IsKeyDown("5") or IsKeyDown("6") or IsKeyDown("7") or IsKeyDown("8") or IsKeyDown("9") -- Helper function to determine if global node allocation should be blocked local function shouldBlockGlobalNodeAllocation(node) @@ -448,15 +459,47 @@ function PassiveTreeViewClass:Draw(build, viewPort, inputEvents) if hotkeyPressed then processAttributeHotkeys(hoverNode.isAttribute) elseif hoverNode.isAttribute then - -- If the attribute node is already set to str, int, or dex create a toggle effect between attrs - if hoverNode.dn == "Intelligence" then - spec.attributeIndex = 1 - elseif hoverNode.dn == "Dexterity" then - spec.attributeIndex = 3 - elseif hoverNode.dn == "Strength" then - spec.attributeIndex = 2 + if hoverNode.alloc then + -- Allocated node: cycle to the next available attribute type + local dynamicOptions = spec:GetDynamicAttributeOptions() + local totalOptions = 3 + #dynamicOptions + local currentIndex = hoverNode.attributeIndex + if not currentIndex then + -- Infer from name if index is missing (legacy) + if hoverNode.dn == "Strength" then currentIndex = 1 + elseif hoverNode.dn == "Dexterity" then currentIndex = 2 + elseif hoverNode.dn == "Intelligence" then currentIndex = 3 + else currentIndex = 1 end + elseif currentIndex > 3 then + -- Correct for loaded builds where all custom nodes are stored with index 4. + -- Look up the actual position by matching the stat string in the current dynamic list. + local stat = hoverNode.sd and hoverNode.sd[1] + if stat then + for i, s in ipairs(dynamicOptions) do + if s == stat then + currentIndex = i + 3 + break + end + end + end + end + local nextIndex = (currentIndex % totalOptions) + 1 + spec.attributeIndex = nextIndex + spec:SwitchAttributeNode(hoverNode.id, nextIndex) + else + -- Unallocated node: apply the last used attribute type directly, + -- bypassing the popup. Works for standard (1-3) and custom (4+) types. + local attrIndex = spec.attributeIndex or 1 + if attrIndex > 3 then + -- Validate the custom option still exists (source may have been removed) + local dynamicOptions = spec:GetDynamicAttributeOptions() + if not dynamicOptions[attrIndex - 3] then + attrIndex = 1 + spec.attributeIndex = 1 + end + end + spec:SwitchAttributeNode(hoverNode.id, attrIndex) end - spec:SwitchAttributeNode(hoverNode.id, spec.attributeIndex or 1) end spec:AllocNode(hoverNode, self.tracePath and hoverNode == self.tracePath[#self.tracePath] and self.tracePath) spec:AddUndoState() diff --git a/src/Classes/TreeTab.lua b/src/Classes/TreeTab.lua index 48743874fd..28fab8f38d 100644 --- a/src/Classes/TreeTab.lua +++ b/src/Classes/TreeTab.lua @@ -832,9 +832,14 @@ function TreeTabClass:ModifyAttributePopup(hoverNode) local controls = { } local spec = self.build.spec local attributes = { "Strength", "Dexterity", "Intelligence" } - - controls.attrSelect = new("DropDownControl", {"TOPLEFT",nil,"TOPLEFT"}, {225, 30, 100, 18}, attributes, nil) - controls.save = new("ButtonControl", nil, {-50, 65, 80, 20}, "Allocate", function() + + local dynamicOptions = spec:GetDynamicAttributeOptions() + for _, optionText in ipairs(dynamicOptions) do + t_insert(attributes, spec:GetOptionDisplayName(optionText)) + end + + controls.attrSelect = new("DropDownControl", {"TOPLEFT",nil,"TOPLEFT"}, {225, 22, 100, 18}, attributes, nil) + controls.save = new("ButtonControl", nil, {-50, 46, 80, 20}, "Allocate", function() spec:SwitchAttributeNode(hoverNode.id, controls.attrSelect.selIndex) spec.attributeIndex = controls.attrSelect.selIndex spec:AllocNode(hoverNode, spec.tracePath and hoverNode == spec.tracePath[#spec.tracePath] and spec.tracePath) @@ -842,17 +847,18 @@ function TreeTabClass:ModifyAttributePopup(hoverNode) self.build.buildFlag = true main:ClosePopup() end) - controls.close = new("ButtonControl", nil, {50, 65, 80, 20}, "Cancel", function() + controls.close = new("ButtonControl", nil, {50, 46, 80, 20}, "Cancel", function() spec:DeallocNode(hoverNode) main:ClosePopup() end) - controls.hotkeyTooltip = new("LabelControl", nil, {0, 100, 0, 16}, - "^8You can switch attributes quicker by holding hotkeys while allocating:\n"..colorCodes.INTELLIGENCE.."\"1\" or \"I\" for Intelligence, " - ..colorCodes.STRENGTH.."\"2\" or \"S\" for Strength, "..colorCodes.DEXTERITY.."\"3\" or \"D\" for Dexterity\n\n" - ..colorCodes.RARE.."Right-click ^8an allocated node to toggle attribute types or to set an\n" .. - "unallocated node to your last used attribute\n\n" + controls.hotkeyTooltip = new("LabelControl", nil, {0, 70, 0, 16}, + "^8You can switch attribute node options quicker by holding hotkeys while allocating:\n"..colorCodes.INTELLIGENCE.."\"1\" or \"I\" for Intelligence, " + ..colorCodes.STRENGTH.."\"2\" or \"S\" for Strength, "..colorCodes.DEXTERITY.."\"3\" or \"D\" for Dexterity\n" + .."^8\"4\"-\"9\" for additional attribute options (when available)\n" + ..colorCodes.RARE.."Right-click ^8an allocated node to cycle attribute node options,\n" + .."or an unallocated node to apply your last used attribute node option" ) - main:OpenPopup(550, 185, "Choose Attribute", controls, "save") + main:OpenPopup(550, 175, "Choose Attribute Passive Node Option", controls, "save") end function TreeTabClass:SaveMasteryPopup(node, listControl) diff --git a/src/Data/ModCache.lua b/src/Data/ModCache.lua index bab988d5c9..f15fc74b3f 100644 --- a/src/Data/ModCache.lua +++ b/src/Data/ModCache.lua @@ -4409,12 +4409,9 @@ c["Attacks with One-Handed Weapons have 15% increased Chance to inflict Ailments c["Attacks with One-Handed Weapons have 20% increased Chance to inflict Ailments"]={{[1]={flags=17179869184,keywordFlags=0,name="AilmentChance",type="INC",value=20}},nil} c["Attacks with this Weapon gain 100% of Physical damage as Extra damage of each Element"]={{[1]={[2]={type="Condition",var="{Hand}Attack"},[3]={skillType=1,type="SkillType"},flags=0,keywordFlags=0,name="PhysicalDamageGainAsLightning",type="BASE",value=100},[2]={[2]={type="Condition",var="{Hand}Attack"},[3]={skillType=1,type="SkillType"},flags=0,keywordFlags=0,name="PhysicalDamageGainAsCold",type="BASE",value=100},[3]={[2]={type="Condition",var="{Hand}Attack"},[3]={skillType=1,type="SkillType"},flags=0,keywordFlags=0,name="PhysicalDamageGainAsFire",type="BASE",value=100}},nil} c["Attacks with this Weapon gain 50% of Physical damage as Extra damage of each Element"]={{[1]={[2]={type="Condition",var="{Hand}Attack"},[3]={skillType=1,type="SkillType"},flags=0,keywordFlags=0,name="PhysicalDamageGainAsLightning",type="BASE",value=50},[2]={[2]={type="Condition",var="{Hand}Attack"},[3]={skillType=1,type="SkillType"},flags=0,keywordFlags=0,name="PhysicalDamageGainAsCold",type="BASE",value=50},[3]={[2]={type="Condition",var="{Hand}Attack"},[3]={skillType=1,type="SkillType"},flags=0,keywordFlags=0,name="PhysicalDamageGainAsFire",type="BASE",value=50}},nil} -c["Attribute Passive Skills can instead grant 5% increased Cost Efficiency"]={nil,"Attribute Passive Skills can instead grant 5% increased Cost Efficiency "} -c["Attribute Passive Skills can instead grant 5% increased Damage"]={nil,"Attribute Passive Skills can instead grant 5% increased Damage "} -c["Attribute Passive Skills can instead grant 5% increased Damage Attribute Passive Skills can instead grant 5% increased Defences"]={nil,"Attribute Passive Skills can instead grant 5% increased Damage Attribute Passive Skills can instead grant 5% increased Defences "} -c["Attribute Passive Skills can instead grant 5% increased Damage Attribute Passive Skills can instead grant 5% increased Defences Attribute Passive Skills can instead grant 5% increased Cost Efficiency"]={nil,"Attribute Passive Skills can instead grant 5% increased Damage Attribute Passive Skills can instead grant 5% increased Defences Attribute Passive Skills can instead grant 5% increased Cost Efficiency "} -c["Attribute Passive Skills can instead grant 5% increased Defences"]={nil,"Attribute Passive Skills can instead grant 5% increased Defences "} -c["Attribute Passive Skills can instead grant 5% increased Defences Attribute Passive Skills can instead grant 5% increased Cost Efficiency"]={nil,"Attribute Passive Skills can instead grant 5% increased Defences Attribute Passive Skills can instead grant 5% increased Cost Efficiency "} +c["Attribute Passive Skills can instead grant 5% increased Cost Efficiency"]={{[1]={flags=0,keywordFlags=0,name="AttributeOption",type="LIST",value="5% increased cost efficiency"}},nil} +c["Attribute Passive Skills can instead grant 5% increased Damage"]={{[1]={flags=0,keywordFlags=0,name="AttributeOption",type="LIST",value="5% increased damage"}},nil} +c["Attribute Passive Skills can instead grant 5% increased Defences"]={{[1]={flags=0,keywordFlags=0,name="AttributeOption",type="LIST",value="5% increased defences"}},nil} c["Attribute Requirements of Gems can be satisified by your highest Attribute"]={{[1]={flags=0,keywordFlags=0,name="GemAttributeRequirementsSatisfiedByHighestAttribute",type="FLAG",value=true}},nil} c["Aura Skills have 10% increased Magnitudes"]={{[1]={[1]={skillType=39,type="SkillType"},flags=0,keywordFlags=0,name="Magnitude",type="INC",value=10}},nil} c["Aura Skills have 12% increased Magnitudes"]={{[1]={[1]={skillType=39,type="SkillType"},flags=0,keywordFlags=0,name="Magnitude",type="INC",value=12}},nil} diff --git a/src/Modules/Build.lua b/src/Modules/Build.lua index 09c2b13d5c..3c6d12e0a4 100644 --- a/src/Modules/Build.lua +++ b/src/Modules/Build.lua @@ -1145,9 +1145,17 @@ function buildMode:OnFrame(inputEvents) -- Wipe Global Cache wipeGlobalCache() - -- Rebuild calculation output tables + -- Increment revision first so GetDynamicAttributeOptions cache is valid for this + -- entire frame (both the revert pass below and the rendering pass after BuildOutput) self.outputRevision = self.outputRevision + 1 self.buildFlag = false + + -- Revert attribute node overrides whose source (item or custom mod) no longer exists + if self.spec:RevertInvalidAttributeOverrides() then + self.spec:BuildAllDependsAndPaths() + end + + -- Rebuild calculation output tables self.calcsTab:BuildOutput() self:RefreshStatList() end diff --git a/src/Modules/ModParser.lua b/src/Modules/ModParser.lua index a13144ac8b..009c1a1dea 100644 --- a/src/Modules/ModParser.lua +++ b/src/Modules/ModParser.lua @@ -6219,8 +6219,14 @@ end local jewelFuncList = { } local function parseMod(line, order) - -- Check if this is a special modifier + -- Attribute Options (PoE2) local lineLower = line:lower() + local optionText = lineLower:match("^attribute passive skills can instead grant (.*)") + if optionText then + optionText = optionText:gsub("^%s+", ""):gsub("%s+$", "") + return { mod("AttributeOption", "LIST", optionText) } + end + -- Check if this is a special modifier for pattern, patternVal in pairs(jewelFuncList) do local _, _, cap1, cap2, cap3, cap4, cap5 = lineLower:find(pattern, 1) if cap1 then