Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions src/Classes/ImportTab.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
205 changes: 180 additions & 25 deletions src/Classes/PassiveSpec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
61 changes: 52 additions & 9 deletions src/Classes/PassiveTreeView.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down
Loading
Loading