From dff2161698d74f4c84b3af214e0d339b725aa3ae Mon Sep 17 00:00:00 2001 From: Alexander Karrer Date: Thu, 29 Oct 2026 02:59:17 +0100 Subject: [PATCH] Show cluster jewel notable positions in the item editor --- spec/System/TestClusterJewelPreview_spec.lua | 136 +++++++++++++++++++ src/Classes/ItemsTab.lua | 30 +++- src/Classes/PassiveSpec.lua | 28 +--- src/Modules/ItemTools.lua | 72 +++++++++- 4 files changed, 237 insertions(+), 29 deletions(-) create mode 100644 spec/System/TestClusterJewelPreview_spec.lua diff --git a/spec/System/TestClusterJewelPreview_spec.lua b/spec/System/TestClusterJewelPreview_spec.lua new file mode 100644 index 00000000000..aa3bc7b46da --- /dev/null +++ b/spec/System/TestClusterJewelPreview_spec.lua @@ -0,0 +1,136 @@ +describe("Cluster jewel notable preview", function() + local function makeCluster(names, size, count, sockets) + size = size or "Large" + local raw = "Rarity: RARE\nPreview\n"..size.." Cluster Jewel\nImplicits: 2\nAdds "..(count or 8).." Passive Skills\n"..(sockets or 2).." Added Passive Skills are Jewel Sockets\n" + for _, name in ipairs(names) do + raw = raw.."1 Added Passive Skill is "..name.."\n" + end + return new("Item"):Item(raw) + end + + local function positions(item) + return itemLib.getClusterJewelNotablePositions(item, data.clusterJewels.notableSortOrder) + end + + before_each(function() + newBuild() + end) + + it("places a single large-cluster notable at the back", function() + assert.are.same({ { name = "Renewal", index = 6, position = "Back" } }, positions(makeCluster({ "Renewal" }))) + end) + + it("moves the desired pair to the front when a middle-ordered notable is added", function() + local names = { "Feasting Fiends", "Renewal" } + local item = makeCluster(names) + assert.are.same({ + { name = "Renewal", index = 6, position = "Back" }, + { name = "Feasting Fiends", index = 10, position = "Front" }, + }, positions(item)) + table.insert(names, "Call to the Slaughter") + assert.are.same({ + { name = "Renewal", index = 2, position = "Front" }, + { name = "Call to the Slaughter", index = 6, position = "Back" }, + { name = "Feasting Fiends", index = 10, position = "Front" }, + }, positions(makeCluster(names))) + assert.are.same({ "Feasting Fiends", "Renewal" }, item.jewelData.clusterJewelNotables) + end) + + it("handles the legacy single socket and socket-count override", function() + local item = makeCluster({ "Renewal", "Feasting Fiends" }, "Large", 8, 1) + assert.are.same({ 4, 8 }, itemLib.getClusterJewelNotableIndices(item.clusterJewel, item.jewelData, 2)) + item.jewelData.clusterJewelSocketCountOverride = 2 + assert.are.same({ 6, 10 }, itemLib.getClusterJewelNotableIndices(item.clusterJewel, item.jewelData, 2)) + end) + + it("uses the special four-passive medium-cluster positions", function() + local item = makeCluster({ "Flow of Life", "Brush with Death" }, "Medium", 4, 1) + local preview = positions(item) + assert.are.equal(3, preview[1].index) + assert.are.equal(9, preview[2].index) + assert.are.equal("Front", preview[1].position) + assert.are.equal("Front", preview[2].position) + item.jewelData.clusterJewelSocketCountOverride = 0 + assert.are.same({ 4, 8 }, itemLib.getClusterJewelNotableIndices(item.clusterJewel, item.jewelData, 2)) + end) + + it("labels a small cluster's terminal notable as End", function() + local preview = positions(makeCluster({ "Fettle" }, "Small", 3, 0)) + assert.are.equal("End", preview[1].position) + end) + + it("returns no positions for ordinary items, empty clusters, or keystones", function() + assert.are.same({ }, positions(nil)) + assert.are.same({ }, positions(new("Item"):Item("Rarity: NORMAL\nIron Ring"))) + assert.are.same({ }, positions(makeCluster({ }))) + local item = makeCluster({ "Renewal" }) + item.jewelData.clusterJewelKeystone = "Hollow Palm Technique" + assert.are.same({ }, positions(item)) + end) + + it("hides the editor readout for ordinary medium and small clusters", function() + for _, item in ipairs({ + makeCluster({ "Flow of Life", "Brush with Death" }, "Medium", 4, 1), + makeCluster({ "Fettle" }, "Small", 3, 0), + }) do + build.itemsTab:SetDisplayItem(item) + item.crafted = true + local controls = build.itemsTab.controls + assert.is_falsy(controls.displayItemClusterJewelPositions:IsShown()) + assert.are.equal(0, controls.displayItemSectionClusterJewelPositions:GetProperty("height")) + end + end) + + it("shows Megalomaniac positions without requiring crafting controls", function() + local item = new("Item"):Item([[Rarity: UNIQUE +Megalomaniac +Medium Cluster Jewel +Implicits: 2 +Adds 4 Passive Skills +Added Small Passive Skills grant Nothing +1 Added Passive Skill is Feasting Fiends +1 Added Passive Skill is Renewal +1 Added Passive Skill is Call to the Slaughter]]) + build.itemsTab:SetDisplayItem(item) + local controls = build.itemsTab.controls + assert.is_falsy(controls.displayItemClusterJewelSkill:IsShown()) + assert.is_true(controls.displayItemClusterJewelPosition3:IsShown()) + assert.are.same({ + { name = "Renewal", index = 3, position = "Front" }, + { name = "Call to the Slaughter", index = 6, position = "Back" }, + { name = "Feasting Fiends", index = 9, position = "Front" }, + }, build.itemsTab.clusterJewelNotablePositions) + assert.are.equal(78, controls.displayItemSectionClusterJewelPositions:GetProperty("height")) + end) + + it("updates the editor readout and collapses it when the last notable is removed", function() + local tab = build.itemsTab + local item = makeCluster({ "Renewal" }) + -- Exercise the editor controls without turning this parsed fixture into a crafted item. + tab:SetDisplayItem(item) + item.crafted = true + local controls = tab.controls + assert.is_true(controls.displayItemClusterJewelPosition1:IsShown()) + assert.is_truthy(controls.displayItemClusterJewelPosition1:GetProperty("label"):find("Back: %^7Renewal")) + assert.are.equal(52, controls.displayItemSectionClusterJewel:GetProperty("height")) + assert.are.equal(42, controls.displayItemSectionClusterJewelPositions:GetProperty("height")) + item.jewelData.clusterJewelNotables = { "Feasting Fiends", "Call to the Slaughter", "Renewal" } + tab:UpdateDisplayItemTooltip() + assert.is_truthy(controls.displayItemClusterJewelPosition1:GetProperty("label"):find("Front: %^7Renewal")) + assert.is_true(controls.displayItemClusterJewelPosition3:IsShown()) + local _, lastLabelY = controls.displayItemClusterJewelPosition3:GetPos() + local _, headerY = controls.displayItemClusterJewelPositions:GetPos() + local _, addModifierY = controls.displayItemAddCustom:GetPos() + local _, rangeY = controls.displayItemSectionRange:GetPos() + assert.is_true(headerY >= addModifierY + 28) + assert.is_true(rangeY >= lastLabelY + 18) + item.jewelData.clusterJewelNotables = { } + tab:UpdateDisplayItemTooltip() + assert.is_false(controls.displayItemClusterJewelPositions:IsShown()) + assert.are.equal(0, controls.displayItemSectionClusterJewelPositions:GetProperty("height")) + assert.are.equal(52, controls.displayItemSectionClusterJewel:GetProperty("height")) + tab:SetDisplayItem(new("Item"):Item("Rarity: NORMAL\nIron Ring")) + assert.is_falsy(controls.displayItemClusterJewelPosition1:IsShown()) + assert.are.equal(0, controls.displayItemSectionClusterJewel:GetProperty("height")) + end) +end) diff --git a/src/Classes/ItemsTab.lua b/src/Classes/ItemsTab.lua index 0b3852aba58..1d92247800f 100644 --- a/src/Classes/ItemsTab.lua +++ b/src/Classes/ItemsTab.lua @@ -1009,9 +1009,36 @@ holding Shift will put it in the second.]]) return self.displayItem and (self.displayItem:GetPrimarySlot() == "Weapon 1" or self.displayItem.type == "Shield" or self.displayItem.canHaveShieldCrucibleTree) end + -- Section: Cluster jewel notable positions + self.controls.displayItemSectionClusterJewelPositions = new("Control"):Control({"TOPLEFT",self.controls.displayItemSectionCustom,"BOTTOMLEFT"}, {0, 0, 0, function() + if not self.controls.displayItemClusterJewelPositions:IsShown() then + return 0 + end + return 24 + #self.clusterJewelNotablePositions * 18 + end}) + self.controls.displayItemClusterJewelPositions = new("LabelControl"):LabelControl({"TOPLEFT",self.controls.displayItemSectionClusterJewelPositions,"TOPLEFT"}, {0, 0, 0, 14}, "^7Notable positions (relative to entrance):") + self.controls.displayItemClusterJewelPositions.shown = function() + local item = self.displayItem + return item and item.clusterJewel and (item.clusterJewel.size == "Large" and item.crafted or item.title == "Megalomaniac") + and #(self.clusterJewelNotablePositions or { }) > 0 + end + for i = 1, 4 do + self.controls["displayItemClusterJewelPosition"..i] = new("LabelControl"):LabelControl({"TOPLEFT",self.controls.displayItemClusterJewelPositions,"TOPLEFT"}, {0, i * 18, 0, 14}, function() + local notable = self.clusterJewelNotablePositions and self.clusterJewelNotablePositions[i] + if not notable then + return "" + end + local color = notable.position == "Front" and colorCodes.POSITIVE or colorCodes.NORMAL + return color..notable.position..": ^7"..notable.name + end) + self.controls["displayItemClusterJewelPosition"..i].shown = function() + return self.controls.displayItemClusterJewelPositions:IsShown() and self.clusterJewelNotablePositions[i] ~= nil + end + end + -- Section: Modifier Range local labelFontSize = 14 - self.controls.displayItemSectionRange = new("Control"):Control({ "TOPLEFT", self.controls.displayItemSectionCustom, "BOTTOMLEFT" }, { 0, 0, 0, function() + self.controls.displayItemSectionRange = new("Control"):Control({ "TOPLEFT", self.controls.displayItemSectionClusterJewelPositions, "BOTTOMLEFT" }, { 0, 0, 0, function() if not self.displayItem or not self.displayItem.rangeLineList[1] then return 0 end @@ -2115,6 +2142,7 @@ function ItemsTabClass:SetDisplayItem(item) end function ItemsTabClass:UpdateDisplayItemTooltip() + self.clusterJewelNotablePositions = itemLib.getClusterJewelNotablePositions(self.displayItem, self.build.data.clusterJewels.notableSortOrder) self.displayItemTooltip:Clear() self:AddItemTooltip(self.displayItemTooltip, self.displayItem) self.displayItemTooltip.center = true diff --git a/src/Classes/PassiveSpec.lua b/src/Classes/PassiveSpec.lua index 3e645124071..0979c1263cf 100644 --- a/src/Classes/PassiveSpec.lua +++ b/src/Classes/PassiveSpec.lua @@ -2195,33 +2195,7 @@ function PassiveSpecClass:BuildSubgraph(jewel, parentSocket, id, upSize, importe -- Second pass: notables -- Gather notable indicies - local notableIndexList = { } - for _, nodeIndex in ipairs(clusterJewel.notableIndicies) do - if #notableIndexList == notableCount then - break - end - if clusterJewel.size == "Medium" then - if socketCount == 0 and notableCount == 2 then - -- Special rule for two notables in a Medium cluster - if nodeIndex == 6 then - nodeIndex = 4 - elseif nodeIndex == 10 then - nodeIndex = 8 - end - elseif nodeCount == 4 then - -- Special rule for notables in a 4-node Medium cluster - if nodeIndex == 10 then - nodeIndex = 9 - elseif nodeIndex == 2 then - nodeIndex = 3 - end - end - end - if not indicies[nodeIndex] then - t_insert(notableIndexList, nodeIndex) - end - end - table.sort(notableIndexList) + local notableIndexList = itemLib.getClusterJewelNotableIndices(clusterJewel, jewelData, notableCount) -- Create the notables for index, baseNode in ipairs(notableList) do diff --git a/src/Modules/ItemTools.lua b/src/Modules/ItemTools.lua index 2f21380cce1..d6acf456583 100644 --- a/src/Modules/ItemTools.lua +++ b/src/Modules/ItemTools.lua @@ -11,6 +11,76 @@ local m_floor = math.floor itemLib = { } +-- Cluster-template positions, shared by the passive tree and the item editor. +-- Index 0 is the entrance; half of totalIndicies is the back of the cluster. +function itemLib.getClusterJewelNotableIndices(clusterJewel, jewelData, notableCount) + local socketCount = jewelData.clusterJewelSocketCountOverride or jewelData.clusterJewelSocketCount or 0 + local nodeCount = jewelData.clusterJewelNodeCount or (socketCount + notableCount + (jewelData.clusterJewelNothingnessCount or 0)) + local sockets = { } + if clusterJewel.size == "Large" and socketCount == 1 then + sockets[6] = true + else + for i = 1, socketCount do + local nodeIndex = clusterJewel.socketIndicies[i] + if nodeIndex then + sockets[nodeIndex] = true + end + end + end + local indices = { } + for _, nodeIndex in ipairs(clusterJewel.notableIndicies) do + if #indices == notableCount then + break + end + if clusterJewel.size == "Medium" then + if socketCount == 0 and notableCount == 2 then + if nodeIndex == 6 then + nodeIndex = 4 + elseif nodeIndex == 10 then + nodeIndex = 8 + end + elseif nodeCount == 4 then + if nodeIndex == 10 then + nodeIndex = 9 + elseif nodeIndex == 2 then + nodeIndex = 3 + end + end + end + if not sockets[nodeIndex] then + t_insert(indices, nodeIndex) + end + end + table.sort(indices) + return indices +end + +function itemLib.getClusterJewelNotablePositions(item, sortOrder) + local positions = { } + if not item or not item.clusterJewel or not item.jewelData or item.jewelData.clusterJewelKeystone then + return positions + end + local names = { } + for _, name in ipairs(item.jewelData.clusterJewelNotables or { }) do + if not sortOrder[name] then + return positions + end + t_insert(names, name) + end + table.sort(names, function(a, b) return sortOrder[a] < sortOrder[b] end) + local indices = itemLib.getClusterJewelNotableIndices(item.clusterJewel, item.jewelData, #names) + for i, nodeIndex in ipairs(indices) do + local position = "Front" + if item.clusterJewel.size == "Small" then + position = "End" + elseif nodeIndex > item.clusterJewel.totalIndicies / 4 and nodeIndex < item.clusterJewel.totalIndicies * 3 / 4 then + position = "Back" + end + t_insert(positions, { name = names[i], index = nodeIndex, position = position }) + end + return positions +end + -- Info table for all types of item influence itemLib.influenceInfo = { ["all"] = { @@ -411,4 +481,4 @@ itemLib.wiki = { return key == itemLib.wiki.key end, triggered = false -} \ No newline at end of file +}