From 306828f87eb918aeaf4f69d9c38834b42331993d Mon Sep 17 00:00:00 2001 From: followingthefasciaplane <115037920+followingthefasciaplane@users.noreply.github.com> Date: Wed, 24 Jun 2026 05:05:25 +1200 Subject: [PATCH] Add support for Sinister Jewel sockets adds support for sinister jewel sockets granted by amulet anoints and Voices, including Zarokh's Gift. granted sockets now apply in calculator environments, update main build socket state only during the main calc pass, and are removed cleanly when the granting anoint/item goes away. also fixes the anoint picker so Zarokh's Gift appears by adding the new 0.5 emotions to the filter list, and verifies tooltip comparisons include jewels socketed into prospective Zarokh sockets. --- spec/System/TestPassiveSpec_spec.lua | 435 +++++++++++++++++++++++++++ src/Classes/ItemsTab.lua | 19 +- src/Classes/NotableDBControl.lua | 19 +- src/Classes/PassiveSpec.lua | 215 ++++++++++++- src/Modules/CalcSetup.lua | 30 +- 5 files changed, 697 insertions(+), 21 deletions(-) diff --git a/spec/System/TestPassiveSpec_spec.lua b/spec/System/TestPassiveSpec_spec.lua index 2a6a248db4..d4fe6a5214 100644 --- a/spec/System/TestPassiveSpec_spec.lua +++ b/spec/System/TestPassiveSpec_spec.lua @@ -11,6 +11,82 @@ describe("TestPassiveSpec", function() end end + local function findNodeByName(spec, name) + for nodeId, node in pairs(spec.nodes) do + if node.name == name or node.dn == name then + return nodeId, node + end + end + end + + local function firstNormalJewelSocket(spec) + for nodeId, node in pairs(spec.nodes) do + if node.isJewelSocket and node.name == "Jewel Socket" then + return nodeId, node + end + end + end + + local function normalJewelSockets(spec, count) + local sockets = { } + for nodeId, node in pairs(spec.nodes) do + if node.isJewelSocket and node.name == "Jewel Socket" then + table.insert(sockets, { nodeId = nodeId, node = node }) + end + end + table.sort(sockets, function(a, b) return a.nodeId < b.nodeId end) + if count then + while #sockets > count do + table.remove(sockets) + end + end + return sockets + end + + local function makeAmulet(rawMod) + local item = new("Item", [[ +Rarity: RARE +Test Locket +Gold Amulet +-------- +Item Level: 80 +-------- +]] .. (rawMod or "") .. [[ +]]) + return item + end + + local function makeCustomAmulet(rawMod) + local item = makeAmulet(rawMod) + build.itemsTab:AddItem(item, true) + build.itemsTab.slots["Amulet"]:SetSelItemId(item.id) + build.buildFlag = true + return item + end + + local function socketJewel(nodeId, raw) + local item = new("Item", raw) + build.itemsTab:AddItem(item, true) + build.spec.jewels[nodeId] = item.id + if build.itemsTab.sockets[nodeId] then + build.itemsTab.sockets[nodeId]:SetSelItemId(item.id) + end + build.buildFlag = true + return item + end + + local function socketIntJewel(nodeId) + return socketJewel(nodeId, [[ +Rarity: RARE +Test Mind +Sapphire +-------- +Item Level: 80 +-------- ++10 to Intelligence +]]) + end + it("ignores stale jewel socket item ids when loading saved builds", function() local spec = new("PassiveSpec", build, latestTreeVersion) local socketNodeId = firstLoadedSocketNode(spec) @@ -44,6 +120,365 @@ describe("TestPassiveSpec", function() assert.is_true(ok, err) end) + it("grants Zarokh's Gift as an active free sinister jewel socket from item mods", function() + makeCustomAmulet("Allocates Zarokh's Gift") + runCallback("OnFrame") + + local nodeId, node = findNodeByName(build.spec, "Zarokh's Gift") + assert.is_not_nil(nodeId) + assert.is_true(node.isJewelSocket) + assert.is_not_nil(build.spec.allocNodes[nodeId]) + assert.is_true(build.spec.allocNodes[nodeId].isFreeAllocate) + assert.is_true(build.spec.allocNodes[nodeId].isGrantedPassive) + assert.is_not_nil(build.itemsTab.sockets[nodeId]) + assert.is_false(build.itemsTab.sockets[nodeId].inactive) + + local pointsUsed = build.spec:CountAllocNodes() + assert.are.equals(0, pointsUsed) + + local xml = { } + build.spec:Save(xml) + local savedNodeIds = { } + for savedNodeId in xml.attrib.nodes:gmatch("%d+") do + savedNodeIds[tonumber(savedNodeId)] = true + end + assert.is_nil(savedNodeIds[nodeId]) + end) + + it("jewel socketed in item-granted Zarokh's Gift contributes in the same recompute", function() + makeCustomAmulet("Allocates Zarokh's Gift") + runCallback("OnFrame") + + local nodeId = assert(findNodeByName(build.spec, "Zarokh's Gift")) + local beforeInt = build.calcsTab.mainOutput.Int or 0 + + socketJewel(nodeId, [[ +Rarity: RARE +Test Mind +Sapphire +-------- +Item Level: 80 +-------- ++10 to Intelligence +]]) + runCallback("OnFrame") + + assert.True((build.calcsTab.mainOutput.Int or 0) >= beforeInt + 10) + end) + + it("calculator replacement without Zarokh's Gift ignores jewels in stale granted sockets", function() + makeCustomAmulet("Allocates Zarokh's Gift") + runCallback("OnFrame") + + local nodeId = assert(findNodeByName(build.spec, "Zarokh's Gift")) + socketIntJewel(nodeId) + runCallback("OnFrame") + + local intWithSocket = build.calcsTab.mainOutput.Int or 0 + local calcFunc = build.calcsTab:GetMiscCalculator() + local output = calcFunc({ repSlotName = "Amulet", repItem = makeAmulet("") }, false) + + assert.are.equals(intWithSocket - 10, output.Int or 0) + assert.is_not_nil(build.spec.allocNodes[nodeId]) + end) + + it("calculator replacement granting Zarokh's Gift uses socket jewels without mutating the build spec", function() + makeCustomAmulet("") + runCallback("OnFrame") + + local nodeId = assert(findNodeByName(build.spec, "Zarokh's Gift")) + local baseInt = build.calcsTab.mainOutput.Int or 0 + assert.is_nil(build.spec.allocNodes[nodeId]) + + socketIntJewel(nodeId) + runCallback("OnFrame") + assert.are.equals(baseInt, build.calcsTab.mainOutput.Int or 0) + + local calcFunc = build.calcsTab:GetMiscCalculator() + local output = calcFunc({ repSlotName = "Amulet", repItem = makeAmulet("Allocates Zarokh's Gift") }, false) + + assert.are.equals(baseInt + 10, output.Int or 0) + assert.is_nil(build.spec.allocNodes[nodeId]) + end) + + it("does not allow unique jewels in item-granted Zarokh's Gift", function() + makeCustomAmulet("Allocates Zarokh's Gift") + runCallback("OnFrame") + + local nodeId = assert(findNodeByName(build.spec, "Zarokh's Gift")) + local voices = new("Item", [[ +Rarity: UNIQUE +Voices +Sapphire +-------- +Limited to: 1 +Allocates 2 Sinister Jewel sockets +Corrupted +]]) + build.itemsTab:AddItem(voices, true) + + assert.is_false(build.itemsTab:IsItemValidForSlot(voices, "Jewel " .. nodeId)) + + build.spec.jewels[nodeId] = voices.id + build.itemsTab.sockets[nodeId]:SetSelItemId(voices.id) + build.buildFlag = true + runCallback("OnFrame") + + assert.is_nil(build.spec.allocNodes[62152]) + assert.is_nil(build.spec.allocNodes[26178]) + end) + + it("resolves Voices sinister socket grants by alias order", function() + local nodes = build.spec:ResolveGrantedPassiveNodes("3 sinister jewel sockets") + assert.are.equals(3, #nodes) + assert.are.equals("voices_jewel_slot1", nodes[1].aliasPassiveSocket) + assert.are.equals("voices_jewel_slot2", nodes[2].aliasPassiveSocket) + assert.are.equals("voices_jewel_slot3__", nodes[3].aliasPassiveSocket) + end) + + it("Voices grants free Sinister Jewel sockets from an active tree jewel", function() + local hostNodeId, hostNode = firstNormalJewelSocket(build.spec) + build.spec:AllocNode(hostNode) + socketJewel(hostNodeId, [[ +Rarity: UNIQUE +Voices +Sapphire +-------- +Limited to: 1 +Allocates 3 Sinister Jewel sockets +Corrupted +]]) + + runCallback("OnFrame") + + local activeSinister = { } + for _, node in pairs(build.spec.allocNodes) do + if node.name == "Sinister Jewel Socket" then + activeSinister[node.aliasPassiveSocket] = true + assert.is_true(node.isFreeAllocate) + end + end + assert.is_true(activeSinister["voices_jewel_slot1"]) + assert.is_true(activeSinister["voices_jewel_slot2"]) + assert.is_true(activeSinister["voices_jewel_slot3__"]) + assert.is_nil(activeSinister["voices_jewel_slot4"]) + end) + + it("removes Voices-granted sockets when the Voices jewel is removed", function() + local hostNodeId, hostNode = firstNormalJewelSocket(build.spec) + build.spec:AllocNode(hostNode) + socketJewel(hostNodeId, [[ +Rarity: UNIQUE +Voices +Sapphire +-------- +Limited to: 1 +Allocates 2 Sinister Jewel sockets +Corrupted +]]) + runCallback("OnFrame") + assert.is_not_nil(build.spec.allocNodes[62152]) + + build.spec.jewels[hostNodeId] = 0 + if build.itemsTab.sockets[hostNodeId] then + build.itemsTab.sockets[hostNodeId]:SetSelItemId(0) + end + build.buildFlag = true + runCallback("OnFrame") + + assert.is_nil(build.spec.allocNodes[62152]) + assert.is_nil(build.spec.allocNodes[26178]) + end) + + it("does not remove a user-allocated passive when an item grant goes away", function() + local nodeId, node = findNodeByName(build.spec, "Acceleration") + assert.is_not_nil(nodeId) + build.spec:AllocNode(node) + local pointsUsed = build.spec:CountAllocNodes() + assert.is_true(pointsUsed > 0) + + makeCustomAmulet("Allocates Acceleration") + runCallback("OnFrame") + assert.is_not_nil(build.spec.allocNodes[nodeId]) + assert.is_nil(build.spec.allocNodes[nodeId].isFreeAllocate) + + build.itemsTab.slots["Amulet"]:SetSelItemId(0) + build.buildFlag = true + runCallback("OnFrame") + + assert.is_not_nil(build.spec.allocNodes[nodeId]) + assert.are.equals(pointsUsed, build.spec:CountAllocNodes()) + end) + + it("does not persist ordinary anoint notables as granted tree allocations", function() + local nodeId = assert(findNodeByName(build.spec, "Acceleration")) + runCallback("OnFrame") + assert.are.equals(1, build.calcsTab.mainOutput.MovementSpeedMod) + + makeCustomAmulet("Allocates Acceleration") + runCallback("OnFrame") + + assert.is_nil(build.spec.allocNodes[nodeId]) + assert.is_true(build.calcsTab.mainEnv.grantedPassives[nodeId]) + assert.are.equals(1.03, build.calcsTab.mainOutput.MovementSpeedMod) + end) + + it("shows Zarokh's Gift in the amulet anoint lookup", function() + if not common.classes.NotableDBControl then + LoadModule("Classes/NotableDBControl") + end + local control = new("NotableDBControl", {"TOPLEFT", nil, "TOPLEFT"}, {0, 0, 360, 400}, build.itemsTab, build.spec.tree.nodes, "ANOINT") + control:ListBuilder() + + local found + for _, node in ipairs(control.list) do + if node.dn == "Zarokh's Gift" then + found = true + break + end + end + assert.is_true(found) + end) + + it("anoint tooltip comparison includes jewels in a prospective Zarokh's Gift socket", function() + makeCustomAmulet("") + runCallback("OnFrame") + + local zarokhNodeId, zarokhNode = assert(findNodeByName(build.spec, "Zarokh's Gift")) + socketIntJewel(zarokhNodeId) + build.itemsTab.displayItem = makeAmulet("") + + local capturedBase + local capturedNew + local originalAddStatComparesToTooltip = build.AddStatComparesToTooltip + build.AddStatComparesToTooltip = function(_, tooltip, outputBase, outputNew) + capturedBase = outputBase + capturedNew = outputNew + return 1 + end + build.itemsTab:AppendAnointTooltip({ AddLine = function() end }, zarokhNode) + build.AddStatComparesToTooltip = originalAddStatComparesToTooltip + + assert.are.equals((capturedBase.Int or 0) + 10, capturedNew.Int or 0) + assert.is_nil(build.spec.allocNodes[zarokhNodeId]) + end) + + it("removes secondary socket grants from jewels in a removed granted socket", function() + makeCustomAmulet("Allocates Zarokh's Gift") + runCallback("OnFrame") + + local zarokhNodeId = assert(findNodeByName(build.spec, "Zarokh's Gift")) + socketJewel(zarokhNodeId, [[ +Rarity: RARE +Test Eye +Sapphire +-------- +Item Level: 80 +-------- +Allocates 2 Sinister Jewel sockets +]]) + runCallback("OnFrame") + assert.is_not_nil(build.spec.allocNodes[62152]) + assert.is_not_nil(build.spec.allocNodes[26178]) + + build.itemsTab.slots["Amulet"]:SetSelItemId(0) + build.buildFlag = true + runCallback("OnFrame") + + assert.is_nil(build.spec.allocNodes[zarokhNodeId]) + assert.is_nil(build.spec.allocNodes[62152]) + assert.is_nil(build.spec.allocNodes[26178]) + end) + + it("replaces Zarokh's Gift with an ordinary amulet anoint and removes the socket jewel effect", function() + makeCustomAmulet("Allocates Zarokh's Gift") + runCallback("OnFrame") + + local zarokhNodeId = assert(findNodeByName(build.spec, "Zarokh's Gift")) + local accelerationNodeId = assert(findNodeByName(build.spec, "Acceleration")) + local baseInt = build.calcsTab.mainOutput.Int or 0 + socketIntJewel(zarokhNodeId) + runCallback("OnFrame") + assert.are.equals(baseInt + 10, build.calcsTab.mainOutput.Int or 0) + + local amulet = makeAmulet("Allocates Acceleration") + build.itemsTab:AddItem(amulet, true) + build.itemsTab.slots["Amulet"]:SetSelItemId(amulet.id) + build.buildFlag = true + runCallback("OnFrame") + + assert.is_nil(build.spec.allocNodes[zarokhNodeId]) + assert.are.equals(baseInt, build.calcsTab.mainOutput.Int or 0) + assert.is_true(build.calcsTab.mainEnv.grantedPassives[accelerationNodeId]) + assert.are.equals(1.03, build.calcsTab.mainOutput.MovementSpeedMod) + end) + + it("ignores grants from duplicate limited Voices jewels", function() + local sockets = normalJewelSockets(build.spec, 2) + assert.are.equals(2, #sockets) + build.spec:AllocNode(sockets[1].node) + build.spec:AllocNode(sockets[2].node) + socketJewel(sockets[1].nodeId, [[ +Rarity: UNIQUE +Voices +Sapphire +-------- +Limited to: 1 +Allocates 2 Sinister Jewel sockets +Corrupted +]]) + socketJewel(sockets[2].nodeId, [[ +Rarity: UNIQUE +Voices +Sapphire +-------- +Limited to: 1 +Allocates 3 Sinister Jewel sockets +Corrupted +]]) + + runCallback("OnFrame") + + local activeSinister = { } + for _, node in pairs(build.spec.allocNodes) do + if node.name == "Sinister Jewel Socket" then + activeSinister[node.aliasPassiveSocket] = true + end + end + assert.is_true(activeSinister["voices_jewel_slot1"]) + assert.is_true(activeSinister["voices_jewel_slot2"]) + assert.is_nil(activeSinister["voices_jewel_slot3__"]) + end) + + it("jewel socketed in a Voices-granted Sinister socket contributes in the same recompute", function() + local hostNodeId, hostNode = firstNormalJewelSocket(build.spec) + build.spec:AllocNode(hostNode) + socketJewel(hostNodeId, [[ +Rarity: UNIQUE +Voices +Sapphire +-------- +Limited to: 1 +Allocates 3 Sinister Jewel sockets +Corrupted +]]) + runCallback("OnFrame") + + local beforeInt = build.calcsTab.mainOutput.Int or 0 + socketJewel(62152, [[ +Rarity: RARE +Test Mind +Sapphire +-------- +Item Level: 80 +-------- ++10 to Intelligence +]]) + runCallback("OnFrame") + + assert.True((build.calcsTab.mainOutput.Int or 0) >= beforeInt + 10) + end) + it("remaps legacy class ids only for trees before 0.4", function() local function loadClass(treeVersion, classId) local spec = new("PassiveSpec", build, latestTreeVersion) diff --git a/src/Classes/ItemsTab.lua b/src/Classes/ItemsTab.lua index 1f2de90fc0..40298334fd 100644 --- a/src/Classes/ItemsTab.lua +++ b/src/Classes/ItemsTab.lua @@ -2233,6 +2233,9 @@ function ItemsTabClass:IsItemValidForSlot(item, slotName, itemSet, flagState) local node = self.build.spec.tree.nodes[tonumber(slotId)] or self.build.spec.nodes[tonumber(slotId)] if not node or item.type ~= "Jewel" then return false + elseif self.build.spec:IsSinisterJewelSocketNode(node) and (item.rarity == "UNIQUE" or item.rarity == "RELIC") then + -- Sinister Jewel Sockets can only accept non-unique jewels + return false elseif node.containJewelSocket then if item.rarity == "UNIQUE" or item.rarity == "RELIC" or (item.base and item.base.subType ~= nil) then -- Lich socket can only accept basic non-unique jewels @@ -3567,12 +3570,18 @@ function ItemsTabClass:AddItemTooltip(tooltip, item, slot, dbMode, maxWidth) tooltip:AddLine(fontSizeBig, formattedModLine, "FONTIN SC", bg) end - -- Show mods from granted Notables + -- Show mods from granted passives if modLine.modList[1] and modLine.modList[1].name == "GrantedPassive" then - local node = self.build.spec.tree.notableMap[modLine.modList[1].value] - if node then - for _, stat in ipairs(node.sd) do - tooltip:AddLine(fontSizeBig, "^x7F7F7F"..stat, "FONTIN SC") + for _, node in ipairs(self.build.spec:ResolveGrantedPassiveNodes(modLine.modList[1].value)) do + local displayed = false + if node.sd then + for _, stat in ipairs(node.sd) do + tooltip:AddLine(fontSizeBig, "^x7F7F7F"..stat, "FONTIN SC") + displayed = true + end + end + if not displayed and node.isJewelSocket then + tooltip:AddLine(fontSizeBig, "^x7F7F7F"..(node.name or "Jewel Socket"), "FONTIN SC") end end -- Add separator only for anoints diff --git a/src/Classes/NotableDBControl.lua b/src/Classes/NotableDBControl.lua index c0a9277aa9..a524a09a76 100644 --- a/src/Classes/NotableDBControl.lua +++ b/src/Classes/NotableDBControl.lua @@ -12,7 +12,7 @@ local m_floor = math.floor local m_huge = math.huge local s_format = string.format -local emotionList = {"Ire", "Guilt", "Greed", "Paranoia", "Envy", "Disgust", "Despair", "Fear", "Suffering", "Isolation" } +local emotionList = {"Ire", "Guilt", "Greed", "Paranoia", "Envy", "Disgust", "Despair", "Fear", "Suffering", "Isolation", "Melancholy", "Ferocity", "Contempt" } ---@param node table ---@return boolean @@ -22,7 +22,7 @@ end ---@class NotableDBControl : ListControl local NotableDBClass = newClass("NotableDBControl", "ListControl", function(self, anchor, rect, itemsTab, db, dbType) - local headerHeight = 68 + local headerHeight = 96 local innerRect = {rect[1], rect[2]+headerHeight, rect[3], rect[4]-headerHeight} self.ListControl(anchor, innerRect, 16, "VERTICAL", false) self.itemsTab = itemsTab @@ -67,9 +67,9 @@ local NotableDBClass = newClass("NotableDBControl", "ListControl", function(self self.listBuildFlag = true end end - local function emoCheck(name, relTo) - local anchor = {"LEFT", relTo, "RIGHT"} - local rect = {2, 0, 26, 26} + local function emoCheck(name, relTo, newRow) + local anchor = newRow and {"TOPLEFT", relTo, "BOTTOMLEFT"} or {"LEFT", relTo, "RIGHT"} + local rect = newRow and {0, 2, 26, 26} or {2, 0, 26, 26} local ctl = new("CheckBoxControl", anchor, rect, "", emoCheckOnChange(name), "Distilled "..name, true) if self.emotionImages then ctl:SetCheckImage(self.emotionImages[name]) end return ctl @@ -77,7 +77,9 @@ local NotableDBClass = newClass("NotableDBControl", "ListControl", function(self local emotionCheckBoxes = {} for i,emo in ipairs(emotionList) do - local emoCtl = emoCheck(emo, emotionCheckBoxes[i-1] or self.controls.emotionLabel) + local newRow = i == 8 + local relTo = newRow and emotionCheckBoxes[1] or emotionCheckBoxes[i-1] or self.controls.emotionLabel + local emoCtl = emoCheck(emo, relTo, newRow) emotionCheckBoxes[i] = emoCtl self.controls["emotionCheckbox"..emo] = emoCtl end @@ -302,7 +304,8 @@ function NotableDBClass:AddValueTooltip(tooltip, index, node) if node.sd[1] then tooltip:AddLine(16, "") for i, line in ipairs(node.sd) do - if line ~= " " and (node.mods[i].extra or not node.mods[i].list) then + local mod = node.mods and node.mods[i] + if line ~= " " and (not mod or mod.extra or not mod.list) then local line = colorCodes.UNSUPPORTED..line line = main.notSupportedModTooltips and (line .. main.notSupportedTooltipText) or line tooltip:AddLine(16, line) @@ -343,4 +346,4 @@ end ---@param node table function NotableDBClass:OnSelCopy(index, node) Copy(item.dn) -end \ No newline at end of file +end diff --git a/src/Classes/PassiveSpec.lua b/src/Classes/PassiveSpec.lua index e532c4ac48..1442be1859 100644 --- a/src/Classes/PassiveSpec.lua +++ b/src/Classes/PassiveSpec.lua @@ -226,8 +226,10 @@ end function PassiveSpecClass:Save(xml) local allocNodeIdList = { } local weaponSets = {} - for nodeId in pairs(self.allocNodes) do - t_insert(allocNodeIdList, nodeId) + for nodeId, node in pairs(self.allocNodes) do + if not (node.isGrantedPassive and node.isFreeAllocate) then + t_insert(allocNodeIdList, nodeId) + end if self.nodes[nodeId].allocMode and self.nodes[nodeId].allocMode ~= 0 then local weaponSet = self.nodes[nodeId].allocMode if not weaponSets[weaponSet] then @@ -1058,6 +1060,210 @@ function PassiveSpecClass:GetJewel(itemId) return item end +local function normalisePassiveName(name) + return type(name) == "string" and name:lower():gsub("^%s+", ""):gsub("%s+$", "") or nil +end + +function PassiveSpecClass:FindNodesByDisplayName(name, predicate) + local key = normalisePassiveName(name) + local out = { } + if not key then + return out + end + for _, node in pairs(self.nodes) do + local nodeName = normalisePassiveName(node.name or node.dn) + if nodeName == key and (not predicate or predicate(node)) then + t_insert(out, node) + end + end + table.sort(out, function(a, b) return a.id < b.id end) + return out +end + +function PassiveSpecClass:FindNodeByDisplayName(name, predicate) + return self:FindNodesByDisplayName(name, predicate)[1] +end + +local voicesSinisterSocketAliases = { + "voices_jewel_slot1", + "voices_jewel_slot2", + "voices_jewel_slot3__", + "voices_jewel_slot4", + "voices_jewel_slot5", +} + +function PassiveSpecClass:GetVoicesSinisterJewelSocketNodes(count) + local byAlias = { } + for _, node in pairs(self.nodes) do + if node.isJewelSocket and node.name == "Sinister Jewel Socket" and node.aliasPassiveSocket then + byAlias[node.aliasPassiveSocket] = node + end + end + local out = { } + for i = 1, m_min(count or 0, #voicesSinisterSocketAliases) do + local node = byAlias[voicesSinisterSocketAliases[i]] + if node then + t_insert(out, node) + end + end + return out +end + +function PassiveSpecClass:ResolveGrantedPassiveNodes(passive) + local out = { } + if type(passive) ~= "string" then + return out + end + + local notable = self.tree.notableMap[passive] + if notable then + t_insert(out, self.nodes[notable.id] or notable) + return out + end + + local sinisterCount = passive:match("^(%d+)%s+sinister jewel sockets$") + if sinisterCount then + return self:GetVoicesSinisterJewelSocketNodes(tonumber(sinisterCount)) + end + + local node = self:FindNodeByDisplayName(passive, function(node) + return node.isJewelSocket or node.type == "Socket" or node.type == "Notable" or node.type == "Keystone" + end) + if node then + t_insert(out, node) + end + return out +end + +function PassiveSpecClass:IsSinisterJewelSocketNode(node) + if not node then + return false + end + if node.name == "Sinister Jewel Socket" then + return true + end + for _, stat in ipairs(node.stats or node.sd or { }) do + if stat == "Sinister Jewel Socket" then + return true + end + end + return false +end + +local function isPersistentGrantedPassiveNode(node) + return node and (node.isJewelSocket or node.type == "Socket") +end + +local function getItemForGrantedPassiveSlot(spec, itemsTab, slot, allocNodes, override, activeWeaponSet, nodesModsList) + local slotName = slot.slotName + if slot.nodeId then + if not allocNodes[slot.nodeId] then + return + end + if slotName == override.repSlotName then + return override.repItem + end + local itemId = spec.jewels[slot.nodeId] + if (not itemId or itemId == 0) and itemsTab.sockets[slot.nodeId] then + itemId = itemsTab.sockets[slot.nodeId].selItemId + end + return itemsTab.items[itemId] + end + + if slot.weaponSet and slot.weaponSet ~= activeWeaponSet then + return + end + if slotName == "Ring 3" and nodesModsList and not nodesModsList:Flag(nil, "AdditionalRingSlot") then + return + end + if slotName == override.repSlotName then + return override.repItem + end + return itemsTab.items[slot.selItemId] +end + +function PassiveSpecClass:SetGrantedPassiveNodes(grantedNodeMap) + local changed = false + grantedNodeMap = grantedNodeMap or { } + + for nodeId, node in pairs(self.allocNodes) do + if node.isGrantedPassive and node.isFreeAllocate and not grantedNodeMap[nodeId] then + node.alloc = false + node.isGrantedPassive = nil + node.isFreeAllocate = nil + self.allocNodes[nodeId] = nil + changed = true + end + end + + for nodeId, node in pairs(grantedNodeMap) do + local specNode = self.nodes[nodeId] or node + if not self.allocNodes[nodeId] then + specNode.alloc = true + specNode.allocMode = 0 + specNode.isGrantedPassive = true + specNode.isFreeAllocate = true + self.allocNodes[nodeId] = specNode + changed = true + end + end + + if changed then + self:BuildAllDependsAndPaths() + end + return changed +end + +function PassiveSpecClass:CollectGrantedPassiveNodesFromItems(itemsTab, baseAllocNodes, ignoreJewelLimits, override, nodesModsList) + override = override or { } + local granted = { } + local allocNodes = { } + for nodeId, node in pairs(baseAllocNodes or self.allocNodes) do + if not (node.isGrantedPassive and node.isFreeAllocate) then + allocNodes[nodeId] = node + end + end + local activeWeaponSet = itemsTab.activeItemSet.useSecondWeaponSet and 2 or 1 + local jewelLimits = { } + local changed = true + local safety = 0 + + while changed and safety < 8 do + changed = false + safety = safety + 1 + + for _, slot in pairs(itemsTab.orderedSlots) do + local item = getItemForGrantedPassiveSlot(self, itemsTab, slot, allocNodes, override, activeWeaponSet, nodesModsList) + + if item and item.modList and not (slot.nodeId and not itemsTab:IsItemValidForSlot(item, slot.slotName)) then + if slot.nodeId and item.limit and not ignoreJewelLimits then + local limitKey = item.base.subType == "Timeless" and "Historic" or item.title + if jewelLimits[limitKey] and jewelLimits[limitKey] >= item.limit then + goto continue + end + jewelLimits[limitKey] = (jewelLimits[limitKey] or 0) + 1 + end + for _, mod in ipairs(item.modList) do + if mod.name == "GrantedPassive" then + local passive = mod.value + for _, node in ipairs(self:ResolveGrantedPassiveNodes(passive)) do + if isPersistentGrantedPassiveNode(node) and not granted[node.id] then + local specNode = self.nodes[node.id] or node + granted[node.id] = specNode + allocNodes[node.id] = specNode + changed = true + end + end + end + end + end + ::continue:: + end + end + + return granted +end + -- Perform a breadth-first search of the tree, starting from this node, and determine if it is the closest node to any other nodes function PassiveSpecClass:BuildPathFromNode(root) root.pathDist = 0 @@ -1588,7 +1794,10 @@ function PassiveSpecClass:BuildAllDependsAndPaths() for id, node in pairs(self.allocNodes) do node.visited = true node.connectedToStart = false - local anyStartFound = (node.type == "ClassStart" or node.type == "AscendClassStart") + local anyStartFound = (node.type == "ClassStart" or node.type == "AscendClassStart" or node.isFreeAllocate) + if node.isFreeAllocate then + node.connectedToStart = true + end for _, other in ipairs(node.linked) do local otherAlloc = other.alloc or alternateClassStartNodes[other.id] if otherAlloc and self:CanPathThroughAllocMode(node.allocMode or 0, other) and not isValueInArray(node.depends, other) then diff --git a/src/Modules/CalcSetup.lua b/src/Modules/CalcSetup.lua index be72984edd..961d1f42c4 100644 --- a/src/Modules/CalcSetup.lua +++ b/src/Modules/CalcSetup.lua @@ -770,6 +770,11 @@ function calcs.initEnv(build, mode, override, specEnv) nodes = copyTable(env.spec.allocNodes, true) end env.allocNodes = nodes + for nodeId, node in pairs(env.allocNodes) do + if node.isGrantedPassive and node.isFreeAllocate then + env.allocNodes[nodeId] = nil + end + end end local nodesModsList = calcs.buildModListForNodeList(env, env.allocNodes, true, true) @@ -812,6 +817,18 @@ 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) + if mode == "MAIN" then + if build.spec:SetGrantedPassiveNodes(grantedNodes) then + build.itemsTab:UpdateSockets() + end + end + for nodeId, node in pairs(grantedNodes) do + env.allocNodes[nodeId] = env.spec.nodes[nodeId] or node + env.grantedPassives[nodeId] = true + env.extraRadiusNodeList[nodeId] = nil + end + local items = {} local jewelLimits = {} local giantsBlood = weaponFlagState.giantsBlood @@ -864,6 +881,8 @@ function calcs.initEnv(build, mode, override, specEnv) -- Slot is a jewel socket, check if socket is allocated if not env.allocNodes[slot.nodeId] then goto continue + elseif item and not build.itemsTab:IsItemValidForSlot(item, slot.slotName) then + goto continue elseif item then if item.jewelData then item.jewelData.limitDisabled = nil @@ -1326,11 +1345,12 @@ function calcs.initEnv(build, mode, override, specEnv) -- Add granted passives (e.g., amulet anoints) if not accelerate.nodeAlloc then for _, passive in pairs(env.modDB:List(nil, "GrantedPassive")) do - local node = env.spec.tree.notableMap[passive] - if node and (not override.removeNodes or not override.removeNodes[node.id]) then - env.allocNodes[node.id] = env.spec.nodes[node.id] or node -- use the conquered node data, if available - env.grantedPassives[node.id] = true - env.extraRadiusNodeList[node.id] = nil + for _, node in ipairs(env.spec:ResolveGrantedPassiveNodes(passive)) do + if node and (not override.removeNodes or not override.removeNodes[node.id]) then + env.allocNodes[node.id] = env.spec.nodes[node.id] or node -- use the conquered node data, if available + env.grantedPassives[node.id] = true + env.extraRadiusNodeList[node.id] = nil + end end end end