diff --git a/manifest.xml b/manifest.xml
index f1b269dca2..fe4baac0c1 100644
--- a/manifest.xml
+++ b/manifest.xml
@@ -150,7 +150,7 @@
-
+
@@ -162,7 +162,7 @@
-
+
@@ -171,6 +171,13 @@
+
+
+
+
+
+
+
@@ -193,7 +200,7 @@
-
+
@@ -1394,4 +1401,4 @@
-
\ No newline at end of file
+
diff --git a/spec/System/RadiusJewelFinderTestSupport.lua b/spec/System/RadiusJewelFinderTestSupport.lua
new file mode 100644
index 0000000000..cb62cb78b6
--- /dev/null
+++ b/spec/System/RadiusJewelFinderTestSupport.lua
@@ -0,0 +1,157 @@
+-- Shared fixture helpers and state checks for Radius Jewel system specs.
+
+local support = { }
+
+support.occVortex = LoadModule("../spec/TestBuilds/3.13/OccVortex.lua")
+support.mirageArcherToxicRain = LoadModule("../spec/TestBuilds/3.13/Mirage Archer Toxic Rain.lua")
+support.RadiusJewelData = LoadModule("Classes/RadiusJewelData")
+
+support.MIGHT_OF_MEEK_RAW_TEXT = [[Might of the Meek
+Crimson Jewel
+Radius: Large
+50% increased Effect of non-Keystone Passive Skills in Radius
+Notable Passive Skills in Radius grant nothing]]
+
+support.UNNATURAL_INSTINCT_RAW_TEXT = [[Unnatural Instinct
+Viridian Jewel
+Limited to: 1
+Radius: Small
+Allocated Small Passive Skills in Radius grant nothing
+Grants all bonuses of Unallocated Small Passive Skills in Radius]]
+
+support.ANATOMICAL_KNOWLEDGE_RAW_TEXT = [[Anatomical Knowledge
+Cobalt Jewel
+Source: No longer obtainable
+Radius: Large
+8% increased maximum Life
+Adds 1 to Maximum Life per 3 Intelligence Allocated in Radius]]
+
+function support.buildSplitPersonalityRawText(modLine)
+ return table.concat({
+ "Split Personality",
+ "Crimson Jewel",
+ "Variable",
+ "This Jewel's Socket has 25% increased effect per Allocated Passive Skill between it and your Class' starting location",
+ modLine,
+ "Corrupted",
+ }, "\n")
+end
+
+function support.buildImpossibleEscapeRawText(keystoneName)
+ return table.concat({
+ "Impossible Escape",
+ "Viridian Jewel",
+ "Limited to: 1",
+ "Small",
+ "Passive Skills in radius of " .. keystoneName .. " can be allocated without being connected to your tree",
+ "Corrupted",
+ }, "\n")
+end
+
+function support.makeFinder()
+ return new("RadiusJewelFinder"):RadiusJewelFinder({ build = build })
+end
+
+local function getRadiusIndex(label)
+ local radiusIndexByLabel = { }
+ for i, radius in ipairs(data.jewelRadius) do
+ if radius.inner == 0 and not radiusIndexByLabel[radius.label] then
+ radiusIndexByLabel[radius.label] = i
+ end
+ end
+ return radiusIndexByLabel[label]
+end
+
+function support.getLargeRadiusIndex()
+ return getRadiusIndex("Large")
+end
+
+function support.getSmallRadiusIndex()
+ return getRadiusIndex("Small")
+end
+
+function support.getRadiusIndexFromRawText(rawText)
+ local item = new("Item"):Item("Rarity: Unique\n" .. rawText)
+ return item.jewelRadiusIndex
+end
+
+function support.makeImpossibleEscapeTestVariant()
+ local smallRadiusIndex = support.getSmallRadiusIndex()
+ local allocNodes = build.spec.allocNodes
+ for keystoneName, node in pairs(build.spec.tree.keystoneMap or { }) do
+ if node and node.nodesInRadius and node.nodesInRadius[smallRadiusIndex] then
+ local hasCandidate = false
+ for nodeId, radiusNode in pairs(node.nodesInRadius[smallRadiusIndex]) do
+ if not allocNodes[nodeId] and not radiusNode.ascendancyName
+ and radiusNode.type ~= "Socket" and radiusNode.type ~= "ClassStart"
+ and radiusNode.type ~= "AscendClassStart" and radiusNode.type ~= "Mastery" then
+ hasCandidate = true
+ break
+ end
+ end
+ if hasCandidate then
+ return {
+ name = keystoneName,
+ keystoneName = keystoneName,
+ rawText = support.buildImpossibleEscapeRawText(keystoneName),
+ }
+ end
+ end
+ end
+end
+
+function support.makeThreadVariants()
+ return support.RadiusJewelData.getThreadOfHopeVariants()
+end
+
+function support.isSorted(results, key)
+ for i = 2, #results do
+ if results[i - 1][key] < results[i][key] then
+ return false
+ end
+ end
+ return true
+end
+
+function support.snapshotFinderState()
+ local socketSelItemIds = { }
+ for socketId, slot in pairs(build.itemsTab.sockets) do
+ socketSelItemIds[socketId] = slot.selItemId
+ end
+
+ local itemOrderList = { }
+ for i, itemId in ipairs(build.itemsTab.itemOrderList) do
+ itemOrderList[i] = itemId
+ end
+
+ local itemCount = 0
+ local itemStateById = { }
+ for itemId, item in pairs(build.itemsTab.items) do
+ itemCount = itemCount + 1
+ itemStateById[itemId] = item.BuildRaw and item:BuildRaw() or {
+ title = item.title,
+ name = item.name,
+ baseName = item.baseName,
+ limit = item.limit,
+ }
+ end
+
+ return {
+ socketSelItemIds = socketSelItemIds,
+ itemOrderList = itemOrderList,
+ itemCount = itemCount,
+ itemStateById = itemStateById,
+ jewels = copyTable(build.spec.jewels, true),
+ }
+end
+
+function support.assertFinderStateUnchanged(before, check)
+ local after = support.snapshotFinderState()
+ check.are.same(before.socketSelItemIds, after.socketSelItemIds)
+ check.are.same(before.itemOrderList, after.itemOrderList)
+ check.are.equal(before.itemCount, after.itemCount)
+ check.are.same(before.itemStateById, after.itemStateById)
+ check.are.same(before.jewels, after.jewels)
+end
+
+return support
diff --git a/spec/System/TestRadiusJewelActions_spec.lua b/spec/System/TestRadiusJewelActions_spec.lua
new file mode 100644
index 0000000000..d1ad2aad94
--- /dev/null
+++ b/spec/System/TestRadiusJewelActions_spec.lua
@@ -0,0 +1,749 @@
+-- Action planning and execution tests for RadiusJewelFinder.
+
+local support = LoadModule("../spec/System/RadiusJewelFinderTestSupport.lua")
+local occVortex = support.occVortex
+local RadiusJewelData = support.RadiusJewelData
+
+describe("RadiusJewelFinder actions #radius-jewel", function()
+ local originalOpenConfirmPopup
+
+ before_each(function()
+ originalOpenConfirmPopup = main.OpenConfirmPopup
+ loadBuildFromXML(occVortex.xml, "OccVortex")
+ end)
+
+ after_each(function()
+ main.OpenConfirmPopup = originalOpenConfirmPopup
+ while main.popups[1] do
+ main:ClosePopup()
+ end
+ end)
+
+ local function findControlIndex(list, needle)
+ for index, entry in ipairs(list) do
+ local label = type(entry) == "table" and entry.label or entry
+ if label == needle then
+ return index
+ end
+ end
+ end
+
+ local function findThreadVariant(name)
+ for _, variant in ipairs(RadiusJewelData.getThreadOfHopeVariants()) do
+ if variant.name == name then
+ return variant
+ end
+ end
+ end
+
+ local function findJewelType(name)
+ for _, jewelType in ipairs(RadiusJewelData.buildJewelTypes()) do
+ if jewelType.name == name then
+ return jewelType
+ end
+ end
+ end
+
+ local function findVariant(jewelType, name)
+ for _, variant in ipairs(jewelType.variants or { }) do
+ if variant.name == name then
+ return variant
+ end
+ end
+ end
+
+ local function allocatedNodeIds()
+ local ids = { }
+ for nodeId in pairs(build.spec.allocNodes) do
+ ids[nodeId] = true
+ end
+ return ids
+ end
+
+ local function assertUndoRestores(before, undoCount)
+ assert.are.equal(undoCount + 1, #build.itemsTab.undo)
+ build.itemsTab:Undo()
+ support.assertFinderStateUnchanged(before, assert)
+ end
+
+ local function tooltipText(control)
+ local tooltip = new("Tooltip"):Tooltip()
+ control.tooltipFunc(tooltip)
+ local texts = { }
+ for _, line in ipairs(tooltip.lines) do
+ if line.text and line.text ~= "" then
+ table.insert(texts, line.text)
+ end
+ end
+ return table.concat(texts, "\n")
+ end
+
+ local function listText(control)
+ local texts = { }
+ for _, line in ipairs(control.list) do
+ if line[1] and line[1] ~= "" then
+ table.insert(texts, line[1])
+ end
+ end
+ return table.concat(texts, "\n")
+ end
+
+ local function addJewelToSocket(rawText, socketId)
+ local item = new("Item"):Item("Rarity: Unique\n" .. rawText)
+ item:BuildModList()
+ build.itemsTab:AddItem(item, true)
+ build.itemsTab.sockets[socketId]:SetSelItemId(item.id)
+ build.itemsTab:PopulateSlots()
+ return item
+ end
+
+ local function addJewelToItems(rawText)
+ local item = new("Item"):Item("Rarity: Unique\n" .. rawText)
+ item:BuildModList()
+ build.itemsTab:AddItem(item, true)
+ return item
+ end
+
+ local function openThreadResult(sourceSocketId, targetSocketId, sourceVariant, targetVariant)
+ local sourceSlot = build.itemsTab.sockets[sourceSocketId]
+ local targetSlot = build.itemsTab.sockets[targetSocketId]
+ assert.is_not_nil(sourceSlot)
+ assert.is_not_nil(targetSlot)
+ sourceSlot:SetSelItemId(0)
+ targetSlot:SetSelItemId(0)
+ local sourceItem = addJewelToSocket(sourceVariant.rawText, sourceSocketId)
+ build.itemsTab:ResetUndo()
+
+ local finder = support.makeFinder()
+ finder.buildJewelSockets = function()
+ return { { id = targetSocketId, label = "Target socket", pathDist = 0 } }
+ end
+ finder.compute.computeThreadOfHopeSocketImpact = function(_, request)
+ return {
+ {
+ socket = request.sockets[1],
+ variant = targetVariant,
+ delta = 10,
+ baseOutput = { },
+ compareOutput = { },
+ },
+ }, 100
+ end
+
+ local popup = finder:Open()
+ local threadIndex = findControlIndex(popup.controls.jewelTypeSelect.list, "Thread of Hope")
+ assert.is_not_nil(threadIndex)
+ popup.controls.jewelTypeSelect.selFunc(threadIndex)
+ popup.controls.computeButton:Click()
+ while main.onFrameFuncs["RadiusJewelFinderCompute"] do
+ runCallback("OnFrame")
+ end
+ assert.are.equal(1, #popup.controls.resultsList.list)
+ return popup, popup.controls.resultsList.list[1], sourceItem
+ end
+
+ local function openStandardResult(jewelType, targetSocketId)
+ build.itemsTab.sockets[targetSocketId]:SetSelItemId(0)
+ build.itemsTab:PopulateSlots()
+ build.itemsTab:ResetUndo()
+ local finder = support.makeFinder()
+ finder.buildJewelSockets = function()
+ return { { id = targetSocketId, label = "Free target", pathDist = 0 } }
+ end
+ finder.compute.computeSocketImpact = function(_, request)
+ return {
+ {
+ socket = request.sockets[1],
+ delta = 10,
+ baseOutput = { },
+ compareOutput = { },
+ },
+ }, 100
+ end
+ local popup = finder:Open()
+ popup.controls.jewelTypeSelect.selFunc(findControlIndex(popup.controls.jewelTypeSelect.list, jewelType.name))
+ popup.controls.computeButton:Click()
+ while main.onFrameFuncs["RadiusJewelFinderCompute"] do
+ runCallback("OnFrame")
+ end
+ assert.are.equal(1, #popup.controls.resultsList.list)
+ return popup, popup.controls.resultsList.list[1]
+ end
+
+ local function openVariantResult(jewelType, variant, sourceSocketId, targetSocketId, replacedRawText)
+ local sourceSlot = build.itemsTab.sockets[sourceSocketId]
+ local targetSlot = build.itemsTab.sockets[targetSocketId]
+ sourceSlot:SetSelItemId(0)
+ targetSlot:SetSelItemId(0)
+ local sourceItem = addJewelToSocket(variant.rawText, sourceSocketId)
+ local replacedItem = replacedRawText and addJewelToSocket(replacedRawText, targetSocketId) or nil
+ build.itemsTab:ResetUndo()
+
+ local finder = support.makeFinder()
+ finder.buildJewelSockets = function()
+ return { { id = targetSocketId, label = "Target socket", pathDist = 0 } }
+ end
+ finder.compute.computeBestVariantSocketImpact = function(_, request)
+ return {
+ {
+ socket = request.sockets[1],
+ variant = variant,
+ delta = 10,
+ baseOutput = { },
+ compareOutput = { },
+ },
+ }, 100
+ end
+
+ local popup = finder:Open()
+ popup.controls.jewelTypeSelect.selFunc(findControlIndex(popup.controls.jewelTypeSelect.list, jewelType.name))
+ popup.controls.jewelVariantSelect.selFunc(findControlIndex(popup.controls.jewelVariantSelect.list, variant.name))
+ popup.controls.computeButton:Click()
+ while main.onFrameFuncs["RadiusJewelFinderCompute"] do
+ runCallback("OnFrame")
+ end
+ assert.are.equal(1, #popup.controls.resultsList.list)
+ assert.is_not_nil(popup.controls.resultsList.list[1].actionPlan,
+ popup.controls.statusLabel.label .. ": " .. tostring(popup.controls.resultsList.list[1].text))
+ return popup, popup.controls.resultsList.list[1], sourceItem, replacedItem
+ end
+
+ it("equips a new jewel in a free socket without allocating recommended passives", function()
+ local jewelType = findJewelType("Might of the Meek")
+ local targetSocketId = 33631
+ build.itemsTab.sockets[targetSocketId]:SetSelItemId(0)
+ build.itemsTab:PopulateSlots()
+ build.itemsTab:ResetUndo()
+ local before = support.snapshotFinderState()
+ local beforeAllocatedNodes = allocatedNodeIds()
+ local undoCount = #build.itemsTab.undo
+ local finder = support.makeFinder()
+ local plan = finder.itemActions:buildPlan({
+ socketId = targetSocketId,
+ socketLabel = "Free target",
+ targetIdentity = jewelType.variantIdentity,
+ targetRawText = jewelType.rawText,
+ })
+
+ assert.are.equal("equip", plan.kind)
+ assert.is_nil(plan.sourceItemId)
+ assert.are.equal(jewelType.variantIdentity, plan.targetIdentity)
+ assert.are.equal(jewelType.rawText, plan.targetRawText)
+ assert.is_true(finder.itemActions:executePlan(plan))
+ local equippedId = build.itemsTab.sockets[targetSocketId].selItemId
+ assert.is_true(equippedId ~= 0)
+ assert.are.equal("Might of the Meek", build.itemsTab.items[equippedId].title)
+ assert.are.same(beforeAllocatedNodes, allocatedNodeIds())
+ assertUndoRestores(before, undoCount)
+ end)
+
+ it("adds a new jewel to the build without changing sockets or passive allocations", function()
+ local jewelType = findJewelType("Might of the Meek")
+ local targetSocketId = 33631
+ build.itemsTab.sockets[targetSocketId]:SetSelItemId(0)
+ build.itemsTab:PopulateSlots()
+ build.itemsTab:ResetUndo()
+ local before = support.snapshotFinderState()
+ local beforeAllocatedNodes = allocatedNodeIds()
+ local undoCount = #build.itemsTab.undo
+ local itemCount = #build.itemsTab.itemOrderList
+ local finder = support.makeFinder()
+ local plan = finder.itemActions:buildPlan({
+ socketId = targetSocketId,
+ socketLabel = "Free target",
+ targetIdentity = jewelType.variantIdentity,
+ targetRawText = jewelType.rawText,
+ })
+
+ assert.is_false(plan.targetSocketAllocated)
+ assert.is_true(finder.itemActions:executeAddToBuildPlan(plan))
+ assert.are.equal(0, build.itemsTab.sockets[targetSocketId].selItemId)
+ assert.are.equal(itemCount + 1, #build.itemsTab.itemOrderList)
+ local addedItemId = build.itemsTab.itemOrderList[#build.itemsTab.itemOrderList]
+ assert.are.equal("Might of the Meek", build.itemsTab.items[addedItemId].title)
+ assert.are.same(beforeAllocatedNodes, allocatedNodeIds())
+ assertUndoRestores(before, undoCount)
+ end)
+
+ it("does not add a duplicate canonical jewel already present in the build", function()
+ local jewelType = findJewelType("Might of the Meek")
+ local targetSocketId = 33631
+ build.itemsTab.sockets[targetSocketId]:SetSelItemId(0)
+ local existingItem = addJewelToItems(jewelType.rawText)
+ build.itemsTab:ResetUndo()
+ local before = support.snapshotFinderState()
+ local undoCount = #build.itemsTab.undo
+ local finder = support.makeFinder()
+ local plan = finder.itemActions:buildPlan({
+ socketId = targetSocketId,
+ socketLabel = "Free target",
+ targetIdentity = jewelType.variantIdentity,
+ targetRawText = jewelType.rawText,
+ })
+
+ assert.are.equal(existingItem.id, plan.sourceItemId)
+ assert.is_false(finder.itemActions:executeAddToBuildPlan(plan))
+ assert.are.equal(undoCount, #build.itemsTab.undo)
+ support.assertFinderStateUnchanged(before, assert)
+ end)
+
+ it("adds a limited jewel variant without moving the equipped variant", function()
+ local jewelType = findJewelType("Unnatural Instinct")
+ local normalVariant = findVariant(jewelType, "Normal")
+ local foulbornVariant
+ for _, variant in ipairs(jewelType.variants) do
+ if variant.isFoulborn then
+ foulbornVariant = variant
+ break
+ end
+ end
+ assert.is_not_nil(foulbornVariant)
+ local sourceSocketId = 36634
+ local targetSocketId = 61419
+ build.itemsTab.sockets[sourceSocketId]:SetSelItemId(0)
+ build.itemsTab.sockets[targetSocketId]:SetSelItemId(0)
+ local sourceItem = addJewelToSocket(normalVariant.rawText, sourceSocketId)
+ build.itemsTab:ResetUndo()
+ local before = support.snapshotFinderState()
+ local undoCount = #build.itemsTab.undo
+ local finder = support.makeFinder()
+ local plan = finder.itemActions:buildPlan({
+ socketId = targetSocketId,
+ socketLabel = "Variant target",
+ targetIdentity = foulbornVariant.variantIdentity,
+ targetRawText = foulbornVariant.rawText,
+ })
+
+ assert.are.equal(sourceItem.id, plan.sourceItemId)
+ assert.is_false(plan.sourceMatchesTarget)
+ assert.is_true(finder.itemActions:executeAddToBuildPlan(plan))
+ assert.are.equal(sourceItem.id, build.itemsTab.sockets[sourceSocketId].selItemId)
+ assert.are.equal(0, build.itemsTab.sockets[targetSocketId].selItemId)
+ local addedItemId = build.itemsTab.itemOrderList[#build.itemsTab.itemOrderList]
+ assert.is_true(build.itemsTab.items[addedItemId].foulborn)
+ assertUndoRestores(before, undoCount)
+ end)
+
+ it("offers both actions and explains that an unallocated socket is hidden from Items", function()
+ local popup, row = openStandardResult(findJewelType("Might of the Meek"), 33631)
+
+ assert.are.equal("equip", row.actionPlan.kind)
+ assert.is_false(row.actionPlan.targetSocketAllocated)
+ assert.are.equal("Add to build", popup.controls.addToBuildButton:GetProperty("label"))
+ assert.is_true(popup.controls.addToBuildButton.enabled())
+ assert.are.equal("Equip", popup.controls.applyButton:GetProperty("label"))
+ assert.is_true(popup.controls.applyButton.enabled())
+ local addTooltip = tooltipText(popup.controls.addToBuildButton)
+ assert.is_true(addTooltip:find("without equipping", 1, true) ~= nil)
+ assert.is_true(addTooltip:find("no sockets or passive allocations change", 1, true) ~= nil)
+ assert.is_true(addTooltip:find("Recommended socket:", 1, true) ~= nil)
+ local equipTooltip = tooltipText(popup.controls.applyButton)
+ assert.is_true(equipTooltip:find("Current location:", 1, true) ~= nil)
+ assert.is_true(equipTooltip:find("Not in build", 1, true) ~= nil)
+ assert.is_true(equipTooltip:find("This socket is unallocated", 1, true) ~= nil)
+ assert.is_true(equipTooltip:find("hidden from the Items panel", 1, true) ~= nil)
+ assert.is_true(equipTooltip:find("not applied automatically", 1, true) ~= nil)
+ local details = listText(popup.controls.resultDetailList)
+ assert.is_true(details:find("Current location:", 1, true) ~= nil)
+ assert.is_true(details:find("Not in build", 1, true) ~= nil)
+ assert.is_nil(details:find("Source:", 1, true))
+ assert.is_nil(details:find("This socket is unallocated", 1, true))
+ assert.is_nil(details:find("hidden from the Items panel", 1, true))
+ assert.is_nil(details:find("not applied automatically", 1, true))
+ end)
+
+ it("adds from the result without equipping and then reports the jewel in the build", function()
+ local targetSocketId = 33631
+ local popup = openStandardResult(findJewelType("Might of the Meek"), targetSocketId)
+ local before = support.snapshotFinderState()
+ local undoCount = #build.itemsTab.undo
+
+ popup.controls.addToBuildButton:Click()
+
+ assert.are.equal(0, build.itemsTab.sockets[targetSocketId].selItemId)
+ assert.are.equal("In build", popup.controls.addToBuildButton:GetProperty("label"))
+ assert.is_false(popup.controls.addToBuildButton.enabled())
+ assert.is_false(popup.controls.applyButton.enabled())
+ assert.is_true(tooltipText(popup.controls.addToBuildButton):find("already in this build", 1, true) ~= nil)
+ assertUndoRestores(before, undoCount)
+ end)
+
+ it("requires confirmation before equipping into a socket hidden from Items", function()
+ local targetSocketId = 33631
+ local popup = openStandardResult(findJewelType("Might of the Meek"), targetSocketId)
+ local before = support.snapshotFinderState()
+ local undoCount = #build.itemsTab.undo
+ local confirmation
+ main.OpenConfirmPopup = function(_, title, message, confirmLabel, onConfirm)
+ confirmation = {
+ title = title,
+ message = message,
+ confirmLabel = confirmLabel,
+ onConfirm = onConfirm,
+ }
+ end
+
+ popup.controls.applyButton:Click()
+
+ assert.is_not_nil(confirmation)
+ assert.are.equal("Unallocated Jewel Socket", confirmation.title)
+ assert.are.equal("Equip", confirmation.confirmLabel)
+ assert.is_true(confirmation.message:find("Socket ", 1, true) == 1)
+ assert.is_nil(confirmation.message:find("The target ", 1, true))
+ assert.is_true(confirmation.message:find("hidden from the Items panel", 1, true) ~= nil)
+ assert.is_true(confirmation.message:find("No passive nodes will be allocated", 1, true) ~= nil)
+ assert.are.equal(0, build.itemsTab.sockets[targetSocketId].selItemId)
+ assert.are.equal(undoCount, #build.itemsTab.undo)
+
+ confirmation.onConfirm()
+ assert.is_true(build.itemsTab.sockets[targetSocketId].selItemId ~= 0)
+ assertUndoRestores(before, undoCount)
+ end)
+
+ it("treats the exact canonical jewel in the target socket as Equipped", function()
+ local jewelType = findJewelType("Might of the Meek")
+ local targetSocketId = 33631
+ local item = addJewelToSocket(jewelType.rawText, targetSocketId)
+ build.itemsTab:ResetUndo()
+ local before = support.snapshotFinderState()
+ local undoCount = #build.itemsTab.undo
+ local finder = support.makeFinder()
+ local plan = finder.itemActions:buildPlan({
+ socketId = targetSocketId,
+ socketLabel = "Exact target",
+ targetIdentity = jewelType.variantIdentity,
+ targetRawText = jewelType.rawText,
+ })
+
+ assert.are.equal("equipped", plan.kind)
+ assert.are.equal(item.id, plan.sourceItemId)
+ assert.is_false(finder.itemActions:executePlan(plan))
+ assert.are.equal(undoCount, #build.itemsTab.undo)
+ support.assertFinderStateUnchanged(before, assert)
+ end)
+
+ it("replaces an occupied target and restores its exact item state with Undo", function()
+ local jewelType = findJewelType("Might of the Meek")
+ local targetSocketId = 36634
+ local replacedItemId = build.itemsTab.sockets[targetSocketId].selItemId
+ local replacedItem = build.itemsTab.items[replacedItemId]
+ build.itemsTab:ResetUndo()
+ local before = support.snapshotFinderState()
+ local undoCount = #build.itemsTab.undo
+ local finder = support.makeFinder()
+ local plan = finder.itemActions:buildPlan({
+ socketId = targetSocketId,
+ socketLabel = "Occupied target",
+ targetIdentity = jewelType.variantIdentity,
+ targetRawText = jewelType.rawText,
+ })
+
+ assert.are.equal("replace", plan.kind)
+ assert.are.equal(replacedItem.id, plan.replacedTargetId)
+ assert.is_true(finder.itemActions:executePlan(plan))
+ assert.is_true(build.itemsTab.sockets[targetSocketId].selItemId ~= replacedItemId)
+ assert.are.equal(replacedItem, build.itemsTab.items[replacedItemId])
+ assertUndoRestores(before, undoCount)
+ end)
+
+ it("classifies a different Thread ring in the same socket as Replace", function()
+ local variants = RadiusJewelData.getThreadOfHopeVariants()
+ assert.is_true(#variants >= 2)
+ local popup, row, sourceItem = openThreadResult(36634, 36634, variants[1], variants[2])
+ local before = support.snapshotFinderState()
+ local undoCount = #build.itemsTab.undo
+
+ assert.are.equal("replace", row.action)
+ assert.are.equal("Replace", popup.controls.applyButton:GetProperty("label"))
+ popup.controls.applyButton:Click()
+ local replacementId = build.itemsTab.sockets[36634].selItemId
+ assert.is_true(replacementId ~= sourceItem.id)
+ assert.are.equal(variants[2].name .. " Ring", build.itemsTab.items[replacementId].variantList[build.itemsTab.items[replacementId].variant])
+ assertUndoRestores(before, undoCount)
+ end)
+
+ it("shows Equipped and disables the action for an exact Thread ring", function()
+ local variant = RadiusJewelData.getThreadOfHopeVariants()[1]
+ local popup, row = openThreadResult(36634, 36634, variant, variant)
+
+ assert.are.equal("equipped", row.action)
+ assert.are.equal("Equipped", popup.controls.applyButton:GetProperty("label"))
+ assert.is_false(popup.controls.applyButton.enabled())
+ assert.is_true(tooltipText(popup.controls.applyButton):find("already equipped", 1, true) ~= nil)
+ local details = listText(popup.controls.resultDetailList)
+ assert.is_true(details:find("Current location:", 1, true) ~= nil)
+ assert.is_true(details:find("This socket", 1, true) ~= nil)
+ end)
+
+ it("moves the exact limited jewel without duplicating it and records one undo state", function()
+ local jewelType = findJewelType("Unnatural Instinct")
+ local targetVariant
+ for _, variant in ipairs(jewelType.variants) do
+ if variant.name == "Normal" then
+ targetVariant = variant
+ break
+ end
+ end
+ assert.is_not_nil(targetVariant)
+ local sourceSocketId = 36634
+ local targetSocketId = 61419
+ local popup, row, sourceItem = openVariantResult(jewelType, targetVariant, sourceSocketId, targetSocketId)
+ local before = support.snapshotFinderState()
+ local undoCount = #build.itemsTab.undo
+
+ assert.are.equal("move", row.action)
+ assert.are.equal("Move", popup.controls.applyButton:GetProperty("label"))
+ popup.controls.applyButton:Click()
+
+ assert.are.equal(0, build.itemsTab.sockets[sourceSocketId].selItemId)
+ assert.are.equal(sourceItem.id, build.itemsTab.sockets[targetSocketId].selItemId)
+ assertUndoRestores(before, undoCount)
+ end)
+
+ it("moves a limited jewel while preserving an occupied target for Undo", function()
+ local jewelType = findJewelType("Unnatural Instinct")
+ local variant = findVariant(jewelType, "Normal")
+ local sourceSocketId = 36634
+ local targetSocketId = 61419
+ local popup, row, sourceItem, replacedItem = openVariantResult(
+ jewelType, variant, sourceSocketId, targetSocketId, support.MIGHT_OF_MEEK_RAW_TEXT)
+ local before = support.snapshotFinderState()
+ local undoCount = #build.itemsTab.undo
+
+ assert.are.equal("move", row.actionPlan.kind)
+ assert.are.equal(sourceItem.id, row.actionPlan.sourceItemId)
+ assert.are.equal(replacedItem.id, row.actionPlan.replacedTargetId)
+ assert.are.equal("Move", popup.controls.applyButton:GetProperty("label"))
+ local actionTooltip = tooltipText(popup.controls.applyButton)
+ assert.is_true(actionTooltip:find("Current location:", 1, true) ~= nil)
+ assert.is_true(actionTooltip:find("Replaces:", 1, true) ~= nil)
+ assert.is_true(actionTooltip:find("not applied automatically", 1, true) ~= nil)
+ local details = listText(popup.controls.resultDetailList)
+ assert.is_true(details:find("Socket: Target socket", 1, true) ~= nil)
+ assert.is_true(details:find("Current location:", 1, true) ~= nil)
+ assert.is_true(details:find("Will replace:", 1, true) ~= nil)
+ assert.is_nil(details:find("Move equipped jewel", 1, true))
+ assert.is_nil(details:find("not applied automatically", 1, true))
+
+ popup.controls.applyButton:Click()
+ assert.are.equal(0, build.itemsTab.sockets[sourceSocketId].selItemId)
+ assert.are.equal(sourceItem.id, build.itemsTab.sockets[targetSocketId].selItemId)
+ assert.are.equal(replacedItem, build.itemsTab.items[replacedItem.id])
+ assertUndoRestores(before, undoCount)
+ end)
+
+ it("moves an exact jewel stored in an unallocated socket", function()
+ local jewelType = findJewelType("Might of the Meek")
+ local sourceSocketId = 33631
+ local targetSocketId = 61419
+ assert.is_nil(build.spec.allocNodes[sourceSocketId])
+ build.itemsTab.sockets[sourceSocketId]:SetSelItemId(0)
+ build.itemsTab.sockets[targetSocketId]:SetSelItemId(0)
+ local sourceItem = addJewelToSocket(jewelType.rawText, sourceSocketId)
+ build.itemsTab:ResetUndo()
+ local before = support.snapshotFinderState()
+ local undoCount = #build.itemsTab.undo
+ local finder = support.makeFinder()
+ local plan = finder.itemActions:buildPlan({
+ socketId = targetSocketId,
+ socketLabel = "Allocated target",
+ targetIdentity = jewelType.variantIdentity,
+ targetRawText = jewelType.rawText,
+ })
+
+ assert.are.equal("move", plan.kind)
+ assert.are.equal(sourceSocketId, plan.sourceSocketId)
+ assert.are.equal(sourceItem.id, plan.sourceItemId)
+ assert.is_true(plan.sourceMatchesTarget)
+ assert.is_true(finder.itemActions:executePlan(plan))
+ assert.are.equal(0, build.itemsTab.sockets[sourceSocketId].selItemId)
+ assert.are.equal(sourceItem.id, build.itemsTab.sockets[targetSocketId].selItemId)
+ assertUndoRestores(before, undoCount)
+ end)
+
+ it("restores an unallocated source socket after consecutive Equip and Move actions", function()
+ local jewelType = findJewelType("Unnatural Instinct")
+ local variant = findVariant(jewelType, "Normal")
+ local sourceSocketId = 33631
+ local targetSocketId = 54127
+ assert.is_nil(build.spec.allocNodes[sourceSocketId])
+ assert.is_nil(build.spec.allocNodes[targetSocketId])
+ build.itemsTab.sockets[sourceSocketId]:SetSelItemId(0)
+ build.itemsTab.sockets[targetSocketId]:SetSelItemId(0)
+ build.itemsTab:ResetUndo()
+ local finder = support.makeFinder()
+ local equipPlan = finder.itemActions:buildPlan({
+ socketId = sourceSocketId,
+ socketLabel = "Unallocated source",
+ targetIdentity = variant.variantIdentity,
+ targetRawText = variant.rawText,
+ })
+
+ assert.are.equal("equip", equipPlan.kind)
+ assert.is_true(finder.itemActions:executePlan(equipPlan))
+ local itemId = build.itemsTab.sockets[sourceSocketId].selItemId
+ assert.is_true(itemId ~= 0)
+ local movePlan = finder.itemActions:buildPlan({
+ socketId = targetSocketId,
+ socketLabel = "Unallocated destination",
+ targetIdentity = variant.variantIdentity,
+ targetRawText = variant.rawText,
+ })
+
+ assert.are.equal("move", movePlan.kind)
+ assert.is_true(finder.itemActions:executePlan(movePlan))
+ assert.are.equal(0, build.itemsTab.sockets[sourceSocketId].selItemId)
+ assert.are.equal(itemId, build.itemsTab.sockets[targetSocketId].selItemId)
+ assert.are.equal(3, #build.itemsTab.undo)
+
+ build.itemsTab:Undo()
+ assert.are.equal(itemId, build.itemsTab.sockets[sourceSocketId].selItemId)
+ assert.are.equal(0, build.itemsTab.sockets[targetSocketId].selItemId)
+ assert.are.equal(jewelType.name, build.itemsTab.items[itemId].title)
+ end)
+
+ it("skips allocated duplicates when an exact jewel is stored in an unallocated socket", function()
+ local jewelType = findJewelType("Might of the Meek")
+ local allocatedSourceSocketId = 36634
+ local storedSourceSocketId = 54127
+ local targetSocketId = 61419
+ assert.is_not_nil(build.spec.allocNodes[allocatedSourceSocketId])
+ assert.is_nil(build.spec.allocNodes[storedSourceSocketId])
+ build.itemsTab.sockets[allocatedSourceSocketId]:SetSelItemId(0)
+ build.itemsTab.sockets[storedSourceSocketId]:SetSelItemId(0)
+ build.itemsTab.sockets[targetSocketId]:SetSelItemId(0)
+ addJewelToSocket(jewelType.rawText, allocatedSourceSocketId)
+ local storedItem = addJewelToSocket(jewelType.rawText, storedSourceSocketId)
+ build.itemsTab:ResetUndo()
+ local before = support.snapshotFinderState()
+ local undoCount = #build.itemsTab.undo
+ local finder = support.makeFinder()
+ local plan = finder.itemActions:buildPlan({
+ socketId = targetSocketId,
+ socketLabel = "Allocated target",
+ targetIdentity = jewelType.variantIdentity,
+ targetRawText = jewelType.rawText,
+ })
+
+ assert.are.equal("move", plan.kind)
+ assert.are.equal(storedSourceSocketId, plan.sourceSocketId)
+ assert.are.equal(storedItem.id, plan.sourceItemId)
+ assert.is_true(finder.itemActions:executePlan(plan))
+ assert.are.equal(0, build.itemsTab.sockets[storedSourceSocketId].selItemId)
+ assert.are.equal(storedItem.id, build.itemsTab.sockets[targetSocketId].selItemId)
+ assertUndoRestores(before, undoCount)
+ end)
+
+ it("invalidates an Items source that is socketed after planning", function()
+ local jewelType = findJewelType("Might of the Meek")
+ local targetSocketId = 33631
+ local relocatedSocketId = 36634
+ build.itemsTab.sockets[relocatedSocketId]:SetSelItemId(0)
+ local sourceItem = addJewelToItems(jewelType.rawText)
+ local popup, row = openStandardResult(jewelType, targetSocketId)
+
+ assert.are.equal(sourceItem.id, row.actionPlan.sourceItemId)
+ assert.is_nil(row.actionPlan.sourceSocketId)
+ local details = listText(popup.controls.resultDetailList)
+ assert.is_true(details:find("Current location:", 1, true) ~= nil)
+ assert.is_true(details:find("Items", 1, true) ~= nil)
+ assert.are.equal("In build", popup.controls.addToBuildButton:GetProperty("label"))
+ assert.is_false(popup.controls.addToBuildButton.enabled())
+ assert.is_true(popup.controls.applyButton.enabled())
+ build.itemsTab.sockets[relocatedSocketId]:SetSelItemId(sourceItem.id)
+ build.itemsTab:PopulateSlots()
+ local afterRelocation = support.snapshotFinderState()
+
+ assert.is_false(popup.controls.applyButton.enabled())
+ popup.controls.resultsList.OnSelClick(popup.controls.resultsList, 1, row, true)
+ support.assertFinderStateUnchanged(afterRelocation, assert)
+ end)
+
+ it("rebuilds passive dependencies for a limited variant change in the same socket", function()
+ local jewelType = findJewelType("Unnatural Instinct")
+ local normalVariant = findVariant(jewelType, "Normal")
+ local foulbornVariant
+ for _, variant in ipairs(jewelType.variants) do
+ if variant.isFoulborn then
+ foulbornVariant = variant
+ break
+ end
+ end
+ assert.is_not_nil(foulbornVariant)
+ local targetSocketId = 36634
+ build.itemsTab.sockets[targetSocketId]:SetSelItemId(0)
+ local sourceItem = addJewelToSocket(normalVariant.rawText, targetSocketId)
+ build.itemsTab:ResetUndo()
+ local before = support.snapshotFinderState()
+ local undoCount = #build.itemsTab.undo
+ local finder = support.makeFinder()
+ local plan = finder.itemActions:buildPlan({
+ socketId = targetSocketId,
+ socketLabel = "Variant target",
+ targetIdentity = foulbornVariant.variantIdentity,
+ targetRawText = foulbornVariant.rawText,
+ })
+ local originalBuildClusterJewelGraphs = build.spec.BuildClusterJewelGraphs
+ local graphBuildCount = 0
+ build.spec.BuildClusterJewelGraphs = function(spec, ...)
+ graphBuildCount = graphBuildCount + 1
+ return originalBuildClusterJewelGraphs(spec, ...)
+ end
+
+ assert.are.equal("replace", plan.kind)
+ assert.are.equal(sourceItem.id, plan.sourceItemId)
+ assert.is_false(plan.sourceMatchesTarget)
+ assert.is_true(finder.itemActions:executePlan(plan))
+ assert.are.equal(1, graphBuildCount)
+ local replacementItemId = build.itemsTab.sockets[targetSocketId].selItemId
+ assert.is_true(replacementItemId ~= sourceItem.id)
+ assert.is_nil(build.itemsTab.items[sourceItem.id])
+ assert.is_true(build.itemsTab.items[replacementItemId].foulborn)
+ assert.are.equal(undoCount + 1, #build.itemsTab.undo)
+
+ build.itemsTab:Undo()
+ build.spec.BuildClusterJewelGraphs = originalBuildClusterJewelGraphs
+ assert.are.equal(2, graphBuildCount)
+ support.assertFinderStateUnchanged(before, assert)
+ end)
+
+ it("clears a conflicting limited variant before equipping its replacement", function()
+ local jewelType = findJewelType("Unnatural Instinct")
+ local normalVariant = findVariant(jewelType, "Normal")
+ local foulbornVariant
+ for _, variant in ipairs(jewelType.variants) do
+ if variant.isFoulborn then
+ foulbornVariant = variant
+ break
+ end
+ end
+ assert.is_not_nil(foulbornVariant)
+ local sourceSocketId = 36634
+ local targetSocketId = 61419
+ build.itemsTab.sockets[sourceSocketId]:SetSelItemId(0)
+ build.itemsTab.sockets[targetSocketId]:SetSelItemId(0)
+ local sourceItem = addJewelToSocket(normalVariant.rawText, sourceSocketId)
+ build.itemsTab:ResetUndo()
+ local before = support.snapshotFinderState()
+ local undoCount = #build.itemsTab.undo
+ local finder = support.makeFinder()
+ local plan = finder.itemActions:buildPlan({
+ socketId = targetSocketId,
+ socketLabel = "Variant target",
+ targetIdentity = foulbornVariant.variantIdentity,
+ targetRawText = foulbornVariant.rawText,
+ })
+
+ assert.are.equal("move", plan.kind)
+ assert.are.equal(sourceItem.id, plan.sourceItemId)
+ assert.is_false(plan.sourceMatchesTarget)
+ assert.is_true(finder.itemActions:executePlan(plan))
+ assert.are.equal(0, build.itemsTab.sockets[sourceSocketId].selItemId)
+ local targetItem = build.itemsTab.items[build.itemsTab.sockets[targetSocketId].selItemId]
+ assert.is_true(targetItem.foulborn)
+ local equipped = finder:findEquippedJewelSockets(jewelType, foulbornVariant)
+ assert.are.equal(1, #equipped)
+ assertUndoRestores(before, undoCount)
+ end)
+
+end)
diff --git a/spec/System/TestRadiusJewelCompute_spec.lua b/spec/System/TestRadiusJewelCompute_spec.lua
new file mode 100644
index 0000000000..12fd4108ee
--- /dev/null
+++ b/spec/System/TestRadiusJewelCompute_spec.lua
@@ -0,0 +1,1798 @@
+-- Calculation and replacement-state tests for RadiusJewelFinder.
+
+local support = LoadModule("../spec/System/RadiusJewelFinderTestSupport.lua")
+
+local occVortex = support.occVortex
+local mirageArcherToxicRain = support.mirageArcherToxicRain
+local RadiusJewelData = support.RadiusJewelData
+local MIGHT_OF_MEEK_RAW_TEXT = support.MIGHT_OF_MEEK_RAW_TEXT
+local UNNATURAL_INSTINCT_RAW_TEXT = support.UNNATURAL_INSTINCT_RAW_TEXT
+local ANATOMICAL_KNOWLEDGE_RAW_TEXT = support.ANATOMICAL_KNOWLEDGE_RAW_TEXT
+local buildSplitPersonalityRawText = support.buildSplitPersonalityRawText
+local buildImpossibleEscapeRawText = support.buildImpossibleEscapeRawText
+local makeFinder = support.makeFinder
+local getLargeRadiusIndex = support.getLargeRadiusIndex
+local getSmallRadiusIndex = support.getSmallRadiusIndex
+local makeImpossibleEscapeTestVariant = support.makeImpossibleEscapeTestVariant
+local makeThreadVariants = support.makeThreadVariants
+local isSorted = support.isSorted
+local snapshotFinderState = support.snapshotFinderState
+local function assertFinderStateUnchanged(before)
+ support.assertFinderStateUnchanged(before, assert)
+end
+
+describe("RadiusJewelCompute #radius-jewel", function()
+
+ before_each(function()
+ loadBuildFromXML(occVortex.xml, "OccVortex")
+ end)
+
+ -- ── computeBestVariantSocketImpact (The Light of Meaning) ────────────────
+
+ describe("computeBestVariantSocketImpact (The Light of Meaning)", function()
+
+ local function getSockets()
+ return makeFinder():buildJewelSockets(getLargeRadiusIndex())
+ end
+
+ local function getLightOfMeaningVariants()
+ return RadiusJewelData.buildVariantsFromUniqueItem("The Light of Meaning")
+ end
+
+ it("returns one result per socket and uses the best variant", function()
+ local sockets = getSockets()
+ local variants = getLightOfMeaningVariants()
+ local results, baseline = makeFinder().compute:computeBestVariantSocketImpact({
+ sockets = sockets,
+ variants = variants,
+ impactStat = "Life",
+ })
+ assert.is_true(#results > 0, "expected at least one result")
+ assert.is_true(#results <= #sockets, "should return no more than socket count")
+ assert.is_number(baseline)
+ assert.is_true(baseline > 0)
+ for _, r in ipairs(results) do
+ assert.is_not_nil(r.socket)
+ assert.is_not_nil(r.variant)
+ assert.is_string(r.variant.name)
+ assert.is_number(r.delta)
+ end
+ end)
+
+ it("keeps comparison snapshots free of nested requirement sources", function()
+ local results = makeFinder().compute:computeBestVariantSocketImpact({
+ sockets = getSockets(),
+ variants = getLightOfMeaningVariants(),
+ impactStat = "Life",
+ })
+ local nestedRequirementKeys = {
+ "ReqStrFailList", "ReqDexFailList", "ReqIntFailList", "ReqOmniFailList",
+ "ReqStrItem", "ReqDexItem", "ReqIntItem", "ReqOmniItem",
+ }
+ assert.is_true(#results > 0, "expected comparison snapshots")
+ for _, result in ipairs(results) do
+ for _, key in ipairs(nestedRequirementKeys) do
+ assert.is_nil(result.baseOutput[key], "base snapshot should omit " .. key)
+ assert.is_nil(result.compareOutput[key], "comparison snapshot should omit " .. key)
+ end
+ end
+ end)
+
+ it("results are sorted by delta descending", function()
+ local sockets = getSockets()
+ local results, _ = makeFinder().compute:computeBestVariantSocketImpact({
+ sockets = sockets,
+ variants = getLightOfMeaningVariants(),
+ impactStat = "Life",
+ })
+ assert.is_true(isSorted(results, "delta"),
+ "results should be sorted by delta descending")
+ end)
+
+ it("Life variant selected on sockets where it is better than others", function()
+ local sockets = getSockets()
+ local results, _ = makeFinder().compute:computeBestVariantSocketImpact({
+ sockets = sockets,
+ variants = getLightOfMeaningVariants(),
+ impactStat = "Life",
+ })
+ local hasLife = false
+ for _, r in ipairs(results) do
+ if r.variant.name == "Life" then hasLife = true; break end
+ end
+ assert.is_true(hasLife, "expected Life variant to be best for at least one socket")
+ end)
+
+ it("restores TotalLife after compute", function()
+ local sockets = getSockets()
+ local before = build.calcsTab.mainOutput["Life"]
+ makeFinder().compute:computeBestVariantSocketImpact({
+ sockets = sockets,
+ variants = getLightOfMeaningVariants(),
+ impactStat = "Life",
+ })
+ local after = build.calcsTab.mainOutput["Life"]
+ assert.are.equal(before, after)
+ end)
+
+ it("restores socket and item state after compute", function()
+ local sockets = getSockets()
+ local before = snapshotFinderState()
+ makeFinder().compute:computeBestVariantSocketImpact({
+ sockets = sockets,
+ variants = getLightOfMeaningVariants(),
+ impactStat = "Life",
+ })
+ assertFinderStateUnchanged(before)
+ end)
+
+ it("respects occupiedMode filter", function()
+ local sockets = getSockets()
+ local results, _ = makeFinder().compute:computeBestVariantSocketImpact({
+ sockets = sockets,
+ variants = getLightOfMeaningVariants(),
+ impactStat = "Life",
+ occupiedMode = { id = "all" },
+ })
+ assert.is_true(#results > 0, "expected results with occupied mode 'all'")
+ end)
+
+ end)
+
+ describe("historic jewel replacements", function()
+
+ local function newHistoricJewel()
+ return new("Item"):Item("Rarity: UNIQUE\n"
+ .. "Lethal Pride\nTimeless Jewel\nRadius: Large\nImplicits: 0\n"
+ .. "Commanded leadership over 10000 warriors under Kaom\n")
+ end
+
+ it("rebuilds the passive spec when replacing a Historic jewel", function()
+ local socketId = 36634
+ local historic = newHistoricJewel()
+ build.itemsTab:AddItem(historic, true)
+ build.itemsTab.sockets[socketId].selItemId = historic.id
+ build.spec.jewels[socketId] = historic.id
+
+ local originalGetMiscCalculator = build.calcsTab.GetMiscCalculator
+ local usedComparisonSpec = false
+ build.calcsTab.GetMiscCalculator = function()
+ return function(override)
+ if override.spec then
+ usedComparisonSpec = true
+ end
+ return { Life = override.spec and 1 or 0 }
+ end, { Life = 0 }
+ end
+
+ local results = makeFinder().compute:computeBestVariantSocketImpact({
+ sockets = { {
+ id = socketId,
+ label = "Historic socket",
+ pathDist = 0,
+ } },
+ variants = { {
+ name = "Candidate",
+ rawText = MIGHT_OF_MEEK_RAW_TEXT,
+ } },
+ impactStat = "Life",
+ occupiedMode = { id = "all" },
+ })
+ build.calcsTab.GetMiscCalculator = originalGetMiscCalculator
+
+ assert.is_true(usedComparisonSpec)
+ assert.are.equal(1, results[1].value)
+ end)
+
+ it("rebuilds the passive spec for Intuitive Leap plans", function()
+ local finder = makeFinder()
+ local radiusIndex = getSmallRadiusIndex()
+ local testSocket
+ for _, socket in ipairs(finder:buildJewelSockets(radiusIndex)) do
+ local socketNode = build.spec.nodes[socket.id]
+ local candidates = finder.compute:collectDisconnectedPassiveCandidates(socketNode, {
+ radiusIndex = radiusIndex,
+ })
+ if build.spec.allocNodes[socket.id] and #candidates > 0 then
+ testSocket = socket
+ break
+ end
+ end
+ assert.is_not_nil(testSocket, "expected an allocated socket with an Intuitive Leap candidate")
+
+ local historic = newHistoricJewel()
+ build.itemsTab:AddItem(historic, true)
+ build.itemsTab.sockets[testSocket.id].selItemId = historic.id
+ build.spec.jewels[testSocket.id] = historic.id
+
+ local originalGetMiscCalculator = build.calcsTab.GetMiscCalculator
+ local usedComparisonSpec = false
+ build.calcsTab.GetMiscCalculator = function()
+ return function(override)
+ if override.spec then
+ usedComparisonSpec = true
+ end
+ return { Life = override.spec and 1 or 0 }
+ end, { Life = 0 }
+ end
+
+ local results = finder.compute:computeIntuitiveLeapSocketImpact({
+ sockets = { testSocket },
+ impactStat = "Life",
+ methodId = "fast",
+ planCache = { },
+ maxTotalPoints = 0,
+ occupiedMode = { id = "all" },
+ skipPlanSteps = true,
+ })
+ build.calcsTab.GetMiscCalculator = originalGetMiscCalculator
+
+ assert.is_true(usedComparisonSpec)
+ assert.are.equal(1, results[1].value)
+ end)
+
+ it("keeps Split Personality's preview distance after rebuilding the spec", function()
+ local socketId = 36634
+ local splitDistance = 42
+ local historic = newHistoricJewel()
+ build.itemsTab:AddItem(historic, true)
+ build.itemsTab.sockets[socketId].selItemId = historic.id
+ build.spec.jewels[socketId] = historic.id
+
+ local originalGetMiscCalculator = build.calcsTab.GetMiscCalculator
+ build.calcsTab.GetMiscCalculator = function()
+ return function(override)
+ local socketNode = override.spec and override.spec.nodes[socketId] or build.spec.nodes[socketId]
+ return { Life = socketNode.distanceToClassStart }
+ end, { Life = 0 }
+ end
+
+ local results = makeFinder().compute:computeSplitPersonalitySocketImpact({
+ sockets = { {
+ id = socketId,
+ label = "Historic socket",
+ classStartDist = splitDistance,
+ pathDist = 0,
+ } },
+ impactStat = "Life",
+ variants = { {
+ name = "Dexterity",
+ rawText = buildSplitPersonalityRawText("+5 to Dexterity"),
+ } },
+ occupiedMode = { id = "all" },
+ })
+ build.calcsTab.GetMiscCalculator = originalGetMiscCalculator
+
+ assert.are.equal(splitDistance, results[1].value)
+ end)
+
+ it("does not rebuild for a Historic jewel stored in an unallocated socket", function()
+ local finder = makeFinder()
+ local testSocket
+ for _, socket in ipairs(finder:buildJewelSockets(getLargeRadiusIndex())) do
+ if not build.spec.allocNodes[socket.id] then
+ testSocket = socket
+ break
+ end
+ end
+ assert.is_not_nil(testSocket, "expected an unallocated jewel socket")
+
+ local historic = newHistoricJewel()
+ build.itemsTab:AddItem(historic, true)
+ build.itemsTab.sockets[testSocket.id].selItemId = historic.id
+ build.spec.jewels[testSocket.id] = historic.id
+
+ local originalGetMiscCalculator = build.calcsTab.GetMiscCalculator
+ local usedComparisonSpec = false
+ build.calcsTab.GetMiscCalculator = function()
+ return function(override)
+ usedComparisonSpec = usedComparisonSpec or override.spec ~= nil
+ return { Life = 0 }
+ end, { Life = 0 }
+ end
+
+ makeFinder().compute:computeSplitPersonalitySocketImpact({
+ sockets = { {
+ id = testSocket.id,
+ label = "Stored Historic socket",
+ classStartDist = 42,
+ pathDist = 1,
+ } },
+ impactStat = "Life",
+ variants = { {
+ name = "Dexterity",
+ rawText = buildSplitPersonalityRawText("+5 to Dexterity"),
+ } },
+ occupiedMode = { id = "all" },
+ })
+ build.calcsTab.GetMiscCalculator = originalGetMiscCalculator
+
+ assert.is_false(usedComparisonSpec)
+ end)
+
+ end)
+
+ -- ── computeSocketImpact (MoM / UI / AK) ────────────────────────────────
+
+ describe("computeSocketImpact", function()
+
+ local function getSockets()
+ return makeFinder():buildJewelSockets(getLargeRadiusIndex())
+ end
+
+ local function compute(request)
+ request.sockets = request.sockets or getSockets()
+ request.impactStat = request.impactStat or "Life"
+ return makeFinder().compute:computeSocketImpact(request)
+ end
+
+ it("returns a table (may be empty if all sockets occupied)", function()
+ local results, baseline = compute({ rawText = MIGHT_OF_MEEK_RAW_TEXT })
+ assert.is_table(results)
+ assert.is_number(baseline)
+ end)
+
+ it("returns the current main output as baseline for the selected stat", function()
+ local expectedBaseline = build.calcsTab.mainOutput["Life"]
+ local _, baseline = compute({ rawText = MIGHT_OF_MEEK_RAW_TEXT })
+ assert.are.equal(expectedBaseline, baseline)
+ end)
+
+ it("returns at least one result for the fixture build", function()
+ local results, _ = compute({ rawText = MIGHT_OF_MEEK_RAW_TEXT })
+ assert.is_true(#results > 0, "expected at least one empty jewel socket result")
+ end)
+
+ it("MoM: only tests empty sockets (selItemId == 0)", function()
+ local results, _ = compute({ rawText = MIGHT_OF_MEEK_RAW_TEXT })
+ for _, r in ipairs(results) do
+ local slot = build.itemsTab.sockets[r.socket.id]
+ assert.are.equal(0, slot.selItemId,
+ "result socket " .. r.socket.id .. " should be empty after compute")
+ end
+ end)
+
+ it("MoM: results sorted by delta descending", function()
+ local results, _ = compute({ rawText = MIGHT_OF_MEEK_RAW_TEXT })
+ assert.is_true(isSorted(results, "delta"),
+ "MoM socket results should be sorted by delta descending")
+ end)
+
+ it("MoM: restores TotalLife after compute", function()
+ local before = build.calcsTab.mainOutput["Life"]
+ compute({ rawText = MIGHT_OF_MEEK_RAW_TEXT })
+ assert.are.equal(before, build.calcsTab.mainOutput["Life"])
+ end)
+
+ it("MoM: restores socket and item state after compute", function()
+ local before = snapshotFinderState()
+ compute({ rawText = MIGHT_OF_MEEK_RAW_TEXT })
+ assertFinderStateUnchanged(before)
+ end)
+
+ it("UI: restores TotalLife after compute", function()
+ local before = build.calcsTab.mainOutput["Life"]
+ compute({ rawText = UNNATURAL_INSTINCT_RAW_TEXT })
+ assert.are.equal(before, build.calcsTab.mainOutput["Life"])
+ end)
+
+ it("AK: restores TotalLife after compute", function()
+ local before = build.calcsTab.mainOutput["Life"]
+ compute({ rawText = ANATOMICAL_KNOWLEDGE_RAW_TEXT })
+ assert.are.equal(before, build.calcsTab.mainOutput["Life"])
+ end)
+
+ it("respects max total points for standard compute", function()
+ local maxPoints = 2
+ local results, _ = compute({
+ rawText = MIGHT_OF_MEEK_RAW_TEXT,
+ maxTotalPoints = maxPoints,
+ })
+ for _, r in ipairs(results) do
+ assert.is_true((r.socket.pathDist or 0) <= maxPoints,
+ "socket " .. r.socket.id .. " used too many points")
+ end
+ end)
+
+ it("occupied sockets (36634, 61419, 41263) are skipped", function()
+ local results, _ = compute({ rawText = MIGHT_OF_MEEK_RAW_TEXT })
+ local occupiedIds = { [36634] = true, [61419] = true, [41263] = true }
+ for _, r in ipairs(results) do
+ assert.is_nil(occupiedIds[r.socket.id],
+ "occupied socket " .. r.socket.id .. " should not appear in results")
+ end
+ end)
+
+ it("occupiedMode 'all' includes occupied sockets", function()
+ local results, _ = compute({
+ rawText = MIGHT_OF_MEEK_RAW_TEXT,
+ occupiedMode = { id = "all" },
+ })
+ local occupiedIds = { [36634] = true, [61419] = true, [41263] = true }
+ local foundOccupied = false
+ for _, r in ipairs(results) do
+ if occupiedIds[r.socket.id] then foundOccupied = true; break end
+ end
+ assert.is_true(foundOccupied,
+ "expected at least one occupied socket in results with mode 'all'")
+ end)
+
+ it("occupiedMode 'safe' returns at least as many results as 'free'", function()
+ local sockets = getSockets()
+ local freeResults, _ = compute({
+ sockets = sockets,
+ rawText = MIGHT_OF_MEEK_RAW_TEXT,
+ })
+ local safeResults, _ = compute({
+ sockets = sockets,
+ rawText = MIGHT_OF_MEEK_RAW_TEXT,
+ occupiedMode = { id = "safe" },
+ })
+ assert.is_true(#safeResults >= #freeResults,
+ "safe mode should include at least all free sockets")
+ end)
+
+ it("occupiedMode 'all' returns more results than 'free' (build has occupied sockets)", function()
+ local sockets = getSockets()
+ local freeResults, _ = compute({
+ sockets = sockets,
+ rawText = MIGHT_OF_MEEK_RAW_TEXT,
+ })
+ local allResults, _ = compute({
+ sockets = sockets,
+ rawText = MIGHT_OF_MEEK_RAW_TEXT,
+ occupiedMode = { id = "all" },
+ })
+ assert.is_true(#allResults > #freeResults,
+ "all mode should include more sockets than free mode (occupied sockets exist)")
+ end)
+
+ it("each result has socket, value and delta fields", function()
+ local results, _ = compute({ rawText = MIGHT_OF_MEEK_RAW_TEXT })
+ local seenSocketIds = {}
+ for _, r in ipairs(results) do
+ assert.is_not_nil(r.socket)
+ assert.is_number(r.socket.id)
+ assert.is_number(r.value)
+ assert.is_number(r.delta)
+ assert.is_nil(seenSocketIds[r.socket.id],
+ "duplicate socket result for socket " .. r.socket.id)
+ seenSocketIds[r.socket.id] = true
+ end
+ end)
+
+ end)
+
+ describe("disconnected passive max total points", function()
+
+ local function getSockets()
+ return makeFinder():buildJewelSockets(getLargeRadiusIndex())
+ end
+
+ local function computeIntuitiveLeap(request)
+ request.sockets = request.sockets or getSockets()
+ request.impactStat = request.impactStat or "Life"
+ request.planCache = request.planCache or { }
+ return makeFinder().compute:computeIntuitiveLeapSocketImpact(request)
+ end
+
+ it("respects max total points for Intuitive Leap", function()
+ local maxPoints = 4
+ local results, _ = computeIntuitiveLeap({
+ variant = false,
+ methodId = "simulated_greedy",
+ maxTotalPoints = maxPoints,
+ })
+ for _, r in ipairs(results) do
+ local totalPoints = (r.socket.pathDist or 0) + (r.addedNodeCount or 0)
+ assert.is_true(totalPoints <= maxPoints,
+ "socket " .. r.socket.id .. " plan used too many points")
+ end
+ end)
+
+ it("stops at jewel-only when the socket already uses all max points", function()
+ local targetSocket
+ for _, socket in ipairs(getSockets()) do
+ if socket.pathDist and socket.pathDist > 0 then
+ targetSocket = socket
+ break
+ end
+ end
+ assert.is_not_nil(targetSocket, "expected at least one socket with path points")
+ local maxPoints = targetSocket.pathDist
+ local sockets = { targetSocket }
+ local fastResults = computeIntuitiveLeap({
+ sockets = sockets,
+ variant = false,
+ methodId = "fast",
+ maxTotalPoints = maxPoints,
+ })
+ local simulatedResults = computeIntuitiveLeap({
+ sockets = sockets,
+ variant = false,
+ methodId = "simulated_greedy",
+ maxTotalPoints = maxPoints,
+ })
+ assert.are.equal(0, fastResults[1].addedNodeCount or 0)
+ assert.are.equal(0, simulatedResults[1].addedNodeCount or 0)
+ end)
+
+ end)
+
+ describe("computeDisconnectedPassiveFastPlan", function()
+
+ it("does not treat individual gains as a bound for combined interactions", function()
+ local finder = makeFinder()
+ local socketNode = { id = 1, name = "Socket" }
+ local firstNode = { id = 2, name = "First" }
+ local secondNode = { id = 3, name = "Second" }
+ local evaluatedCombinedNodes = false
+ finder.compute.buildSocketReplacementOverride = function(_, _, item, addNodes)
+ return { item = item, addNodes = addNodes }
+ end
+ local function calcFunc(override)
+ local hasFirst = override.addNodes[firstNode] == true
+ local hasSecond = override.addNodes[secondNode] == true
+ evaluatedCombinedNodes = evaluatedCombinedNodes or hasFirst and hasSecond
+ if hasFirst and hasSecond then
+ return { Life = 20 }
+ end
+ return { Life = (hasFirst or hasSecond) and 2 or 0 }
+ end
+ local previousBestDelta = 5
+
+ -- Keep passing the historical pruning threshold so this test fails if that unsafe bound is restored.
+ local result = finder.compute:computeDisconnectedPassiveFastPlan({
+ calcFunc = calcFunc,
+ replacementContext = { },
+ baseOutput = { Life = 0 },
+ baseValue = 0,
+ socketNode = socketNode,
+ item = { },
+ impactStat = "Life",
+ candidates = { firstNode, secondNode },
+ variantLabel = "Combined",
+ deltaCache = { },
+ maxAdditionalNodes = 2,
+ skipPlanSteps = true,
+ previousBestDelta = previousBestDelta,
+ })
+
+ assert.are.equal(20, result.delta)
+ assert.is_nil(result.pruned)
+ assert.is_true(evaluatedCombinedNodes)
+ end)
+
+ end)
+
+ describe("computeSplitPersonalitySocketImpact", function()
+
+ local function getSockets()
+ return makeFinder():buildJewelSockets(getLargeRadiusIndex())
+ end
+
+ local variants = {
+ { name = "Life", rawText = buildSplitPersonalityRawText("+5 to maximum Life") },
+ { name = "Mana", rawText = buildSplitPersonalityRawText("+5 to maximum Mana") },
+ }
+
+ local function computeSplit(request)
+ request.sockets = request.sockets or getSockets()
+ request.impactStat = request.impactStat or "Life"
+ request.variants = request.variants or variants
+ return makeFinder().compute:computeSplitPersonalitySocketImpact(request)
+ end
+
+ it("returns results and restores socket distance state", function()
+ local sockets = getSockets()
+ local before = snapshotFinderState()
+ local previousDistanceBySocketId = {}
+ for _, socket in ipairs(sockets) do
+ previousDistanceBySocketId[socket.id] = build.spec.nodes[socket.id] and build.spec.nodes[socket.id].distanceToClassStart
+ end
+
+ local results, baseline = computeSplit({ sockets = sockets })
+
+ assert.is_true(#results > 0, "expected split personality results")
+ assert.is_number(baseline)
+ for _, result in ipairs(results) do
+ assert.is_not_nil(result.variant)
+ assert.is_number(result.splitDistance)
+ assert.is_string(result.detailText)
+ end
+ for _, socket in ipairs(sockets) do
+ local node = build.spec.nodes[socket.id]
+ assert.are.equal(previousDistanceBySocketId[socket.id], node and node.distanceToClassStart)
+ end
+ assertFinderStateUnchanged(before)
+ end)
+
+ it("respects max total points", function()
+ local maxPoints = 4
+ local results, _ = computeSplit({ maxTotalPoints = maxPoints })
+ for _, result in ipairs(results) do
+ local totalPoints = (result.socket.pathDist or 0)
+ assert.is_true(totalPoints <= maxPoints,
+ "socket " .. result.socket.id .. " plan used too many points")
+ end
+ end)
+
+ it("restores socket distance when a suspended computation is cancelled", function()
+ local socket = getSockets()[1]
+ local socketNode = build.spec.nodes[socket.id]
+ local previousDistance = socketNode.distanceToClassStart
+ local splitDistance = (previousDistance or 0) + 100
+ local progress = { }
+ function progress:tick()
+ coroutine.yield()
+ end
+ function progress:child()
+ return self
+ end
+ local computation = coroutine.create(function()
+ computeSplit({
+ sockets = { {
+ id = socket.id,
+ label = socket.label,
+ classStartDist = splitDistance,
+ pathDist = socket.pathDist,
+ } },
+ progress = progress,
+ occupiedMode = { id = "all" },
+ })
+ end)
+
+ assert.is_true(coroutine.resume(computation))
+ assert.are.equal(previousDistance, socketNode.distanceToClassStart)
+ assert.is_true(coroutine.resume(computation))
+ assert.are.equal("suspended", coroutine.status(computation))
+ assert.are.equal(previousDistance, socketNode.distanceToClassStart)
+ end)
+
+ it("restores socket distance after a calculator error", function()
+ local socket = getSockets()[1]
+ local socketNode = build.spec.nodes[socket.id]
+ local previousDistance = socketNode.distanceToClassStart
+ local originalGetMiscCalculator = build.calcsTab.GetMiscCalculator
+ local callCount = 0
+ build.calcsTab.GetMiscCalculator = function()
+ return function()
+ callCount = callCount + 1
+ if callCount == 2 then
+ error("injected Split Personality calculator failure")
+ end
+ return { Life = 0 }
+ end, { Life = 0 }
+ end
+
+ local ok, err = pcall(function()
+ computeSplit({
+ sockets = { {
+ id = socket.id,
+ label = socket.label,
+ classStartDist = (previousDistance or 0) + 100,
+ pathDist = socket.pathDist,
+ } },
+ occupiedMode = { id = "all" },
+ })
+ end)
+ build.calcsTab.GetMiscCalculator = originalGetMiscCalculator
+
+ assert.is_false(ok)
+ assert.is_truthy(tostring(err):match("injected Split Personality calculator failure"))
+ assert.are.equal(previousDistance, socketNode.distanceToClassStart)
+ end)
+
+ end)
+
+ describe("cluster jewel replacements", function()
+
+ it("rebuilds the comparison tree without the replaced cluster subgraph", function()
+ loadBuildFromXML(mirageArcherToxicRain.xml, "Mirage Archer Toxic Rain")
+
+ local clusterSubgraph, allocatedClusterNodeIds
+ for _, candidateSubgraph in pairs(build.spec.subGraphs) do
+ local allocatedNodeIds = { }
+ for _, node in ipairs(candidateSubgraph.nodes) do
+ if node.alloc then
+ table.insert(allocatedNodeIds, node.id)
+ end
+ end
+ if #allocatedNodeIds > 0 then
+ clusterSubgraph = candidateSubgraph
+ allocatedClusterNodeIds = allocatedNodeIds
+ break
+ end
+ end
+ assert.is_not_nil(clusterSubgraph, "expected a cluster subgraph for the equipped cluster")
+ local socketId = clusterSubgraph.parentSocket.id
+ local clusterItem = build.spec:GetSocketedJewel(socketId)
+ assert.is_not_nil(clusterItem, "expected an allocated cluster jewel socket")
+ assert.is_not_nil(clusterItem.clusterJewel, "expected a cluster jewel in the allocated socket")
+
+ local comparisonSpec
+ local originalGetMiscCalculator = build.calcsTab.GetMiscCalculator
+ build.calcsTab.GetMiscCalculator = function()
+ return function(override)
+ comparisonSpec = comparisonSpec or override.spec
+ return { Life = 0 }
+ end, { Life = 0 }
+ end
+
+ makeFinder().compute:computeBestVariantSocketImpact({
+ sockets = { {
+ id = socketId,
+ label = "Cluster socket",
+ pathDist = 0,
+ } },
+ variants = { {
+ name = "Candidate",
+ rawText = MIGHT_OF_MEEK_RAW_TEXT,
+ } },
+ impactStat = "Life",
+ occupiedMode = { id = "all" },
+ })
+ build.calcsTab.GetMiscCalculator = originalGetMiscCalculator
+
+ assert.is_not_nil(comparisonSpec, "expected a comparison spec for the cluster replacement")
+ for _, subGraph in pairs(comparisonSpec.subGraphs) do
+ assert.are_not.equals(socketId, subGraph.parentSocket.id,
+ "replaced cluster should not remain as a comparison subgraph")
+ end
+ for _, nodeId in ipairs(allocatedClusterNodeIds) do
+ assert.is_nil(comparisonSpec.allocNodes[nodeId], "replaced cluster node should not remain allocated")
+ end
+ assert.is_true(comparisonSpec.jewels[socketId] ~= clusterItem.id,
+ "comparison spec should no longer equip the replaced cluster")
+ end)
+
+ end)
+
+ describe("computeImpossibleEscapeSocketImpact", function()
+
+ local function getSockets()
+ return makeFinder():buildJewelSockets(getLargeRadiusIndex())
+ end
+
+ local function computeImpossibleEscape(owner, request)
+ request.sockets = request.sockets or getSockets()
+ request.impactStat = request.impactStat or "Life"
+ request.planCache = request.planCache or { }
+ return owner:computeImpossibleEscapeSocketImpact(request)
+ end
+
+ it("shares fast cache keys except for structural jewel replacements", function()
+ local finder = makeFinder()
+ local sharedKey = finder.compute:getImpossibleEscapePlanCacheKey("Life", "Acrobatics", {
+ socketNode = { id = 36634 },
+ occupancy = { isOccupied = false },
+ })
+ local structuralItem = {
+ type = "Jewel",
+ jewelData = { conqueredBy = true },
+ }
+ local firstStructuralKey = finder.compute:getImpossibleEscapePlanCacheKey("Life", "Acrobatics", {
+ socketNode = { id = 36634 },
+ occupancy = { isOccupied = true, item = structuralItem },
+ })
+ local secondStructuralKey = finder.compute:getImpossibleEscapePlanCacheKey("Life", "Acrobatics", {
+ socketNode = { id = 61419 },
+ occupancy = { isOccupied = true, item = structuralItem },
+ })
+
+ assert.are.equal("IE|Life|Acrobatics", sharedKey)
+ assert.are.equal("IE|Life|Acrobatics|36634", firstStructuralKey)
+ assert.are.equal("IE|Life|Acrobatics|61419", secondStructuralKey)
+ end)
+
+ it("reuses fast calculations across ordinary socket groups", function()
+ local finder = makeFinder()
+ local variant = makeImpossibleEscapeTestVariant()
+ assert.is_not_nil(variant, "expected an Impossible Escape variant")
+ local sockets = { }
+ for _, socket in ipairs(getSockets()) do
+ if not build.spec.allocNodes[socket.id] then
+ table.insert(sockets, {
+ id = socket.id,
+ label = socket.label,
+ pathDist = #sockets,
+ })
+ if #sockets == 2 then
+ break
+ end
+ end
+ end
+ assert.are.equal(2, #sockets, "expected two free jewel sockets")
+
+ local originalGetMiscCalculator = build.calcsTab.GetMiscCalculator
+ local originalCollectCandidates = finder.compute.collectDisconnectedPassiveCandidates
+ local originalBuildOverride = finder.compute.buildSocketReplacementOverride
+ local originalCacheKey = finder.compute.getImpossibleEscapePlanCacheKey
+ local calculationCount = 0
+ build.calcsTab.GetMiscCalculator = function()
+ return function(override)
+ calculationCount = calculationCount + 1
+ local allocatedCount = 0
+ for _ in pairs(override.addNodes) do
+ allocatedCount = allocatedCount + 1
+ end
+ return { Life = allocatedCount }
+ end, { Life = 0 }
+ end
+ finder.compute.collectDisconnectedPassiveCandidates = function()
+ return {
+ { id = -101, name = "First" },
+ { id = -102, name = "Second" },
+ { id = -103, name = "Third" },
+ }
+ end
+ finder.compute.buildSocketReplacementOverride = function(_, _, _, addNodes)
+ return { addNodes = addNodes }
+ end
+
+ local function countCalculations(cacheKeyFunc)
+ finder.compute.getImpossibleEscapePlanCacheKey = cacheKeyFunc
+ calculationCount = 0
+ computeImpossibleEscape(finder.compute, {
+ sockets = sockets,
+ variants = { variant },
+ methodId = "fast",
+ maxTotalPoints = 2,
+ skipPlanSteps = true,
+ })
+ return calculationCount
+ end
+
+ local sharedCount = countCalculations(originalCacheKey)
+ local socketScopedCount = countCalculations(function(_, statField, variantName, replacementContext)
+ return string.format("IE|%s|%s|%s", statField, variantName, replacementContext.socketNode.id)
+ end)
+ build.calcsTab.GetMiscCalculator = originalGetMiscCalculator
+ finder.compute.collectDisconnectedPassiveCandidates = originalCollectCandidates
+ finder.compute.buildSocketReplacementOverride = originalBuildOverride
+ finder.compute.getImpossibleEscapePlanCacheKey = originalCacheKey
+
+ assert.is_true(sharedCount < socketScopedCount,
+ "expected shared cache to avoid repeated Impossible Escape calculations")
+ end)
+
+ it("returns results for both methods without changing finder state", function()
+ local variant = makeImpossibleEscapeTestVariant()
+ assert.is_not_nil(variant, "expected at least one keystone-based Impossible Escape variant")
+ local sockets = getSockets()
+ local before = snapshotFinderState()
+
+ local fastResults, fastBaseline = computeImpossibleEscape(makeFinder().compute, {
+ sockets = sockets,
+ variants = { variant },
+ methodId = "fast",
+ })
+ local simulatedResults, simulatedBaseline = computeImpossibleEscape(makeFinder().compute, {
+ sockets = sockets,
+ variants = { variant },
+ methodId = "simulated_greedy",
+ })
+
+ assert.is_true(#fastResults > 0, "expected fast Impossible Escape results")
+ assert.is_true(#simulatedResults > 0, "expected simulated Impossible Escape results")
+ assert.is_number(fastBaseline)
+ assert.are.equal(fastBaseline, simulatedBaseline)
+ assert.are.equal(variant.name, fastResults[1].variant.name)
+ assert.are.equal(variant.name, simulatedResults[1].variant.name)
+ assertFinderStateUnchanged(before)
+ end)
+
+ it("respects max total points", function()
+ local variant = makeImpossibleEscapeTestVariant()
+ assert.is_not_nil(variant, "expected at least one keystone-based Impossible Escape variant")
+ local maxPoints = 4
+ local results, _ = computeImpossibleEscape(makeFinder().compute, {
+ variants = { variant },
+ methodId = "simulated_greedy",
+ maxTotalPoints = maxPoints,
+ })
+ for _, result in ipairs(results) do
+ local totalPoints = (result.socket.pathDist or 0) + (result.addedNodeCount or 0)
+ assert.is_true(totalPoints <= maxPoints,
+ "socket " .. result.socket.id .. " plan used too many points")
+ end
+ end)
+
+ it("keeps plan details isolated between budget and replacement groups", function()
+ local finder = makeFinder()
+ local variant = makeImpossibleEscapeTestVariant()
+ assert.is_not_nil(variant, "expected an Impossible Escape variant")
+ local freeSocket = { id = 101, label = "Free socket", pathDist = 1 }
+ local occupiedSocket = { id = 202, label = "Occupied socket", pathDist = 3 }
+ local occupancyBySocketId = {
+ [101] = { isOccupied = false },
+ [202] = { isOccupied = true, replacedItemLabel = "Existing jewel" },
+ }
+ finder.socketMatchesOccupiedMode = function(_, socketId)
+ return true, occupancyBySocketId[socketId]
+ end
+ finder.getSocketOccupancyInfo = function(_, socketId)
+ return occupancyBySocketId[socketId]
+ end
+ finder.getSocketBasePoints = function(_, socket)
+ return socket.pathDist
+ end
+ finder.compute.collectDisconnectedPassiveCandidates = function()
+ return {
+ { id = -101, name = "First" },
+ { id = -102, name = "Second" },
+ { id = -103, name = "Third" },
+ }
+ end
+ finder.compute.buildSocketReplacementContext = function(_, _, socketId)
+ return {
+ socketNode = { id = socketId },
+ occupancy = occupancyBySocketId[socketId],
+ baselineOutput = { Life = 0 },
+ }
+ end
+ finder.compute.computeDisconnectedPassiveFastPlan = function(_, request)
+ local socketNode = request.socketNode
+ local result = {
+ delta = socketNode.id == freeSocket.id and 100 or 90,
+ addedNodeCount = request.maxAdditionalNodes,
+ resultNodes = { socketNode.id * 10 },
+ resultNodeLabels = { "Plan for " .. socketNode.id },
+ baseOutput = { Life = 0 },
+ compareOutput = { Life = socketNode.id },
+ detailText = "plan-" .. socketNode.id,
+ variantLabel = request.variantLabel,
+ }
+ if not request.skipPlanSteps then
+ result.planSteps = { { detailText = result.detailText } }
+ end
+ return result
+ end
+
+ local originalGetMiscCalculator = build.calcsTab.GetMiscCalculator
+ build.calcsTab.GetMiscCalculator = function()
+ return function() return { Life = 0 } end, { Life = 0 }
+ end
+ local results = computeImpossibleEscape(finder.compute, {
+ sockets = { freeSocket, occupiedSocket },
+ variants = { variant },
+ methodId = "fast",
+ maxTotalPoints = 5,
+ skipPlanSteps = false,
+ })
+ build.calcsTab.GetMiscCalculator = originalGetMiscCalculator
+
+ local resultBySocketId = { }
+ for _, result in ipairs(results) do
+ resultBySocketId[result.socket.id] = result
+ end
+ assert.are.equal("plan-101", resultBySocketId[101].detailText)
+ assert.are.equal("plan-202", resultBySocketId[202].detailText)
+ assert.are.same({ 1010 }, resultBySocketId[101].resultNodes)
+ assert.are.same({ 2020 }, resultBySocketId[202].resultNodes)
+ assert.are.equal(100, resultBySocketId[101].delta)
+ assert.are.equal(90, resultBySocketId[202].delta)
+ assert.are.equal(4, resultBySocketId[101].addedNodeCount)
+ assert.are.equal(2, resultBySocketId[202].addedNodeCount)
+ assert.is_nil(resultBySocketId[101].replacedItemLabel)
+ assert.are.equal("Existing jewel", resultBySocketId[202].replacedItemLabel)
+ assert.are.equal("free:4", resultBySocketId[101].impossibleEscapeGroupKey)
+ assert.are.equal("occupied:202", resultBySocketId[202].impossibleEscapeGroupKey)
+ end)
+
+ end)
+
+ describe("computeThreadOfHopeSocketImpact", function()
+
+ local function getSockets()
+ return makeFinder():buildJewelSockets(getLargeRadiusIndex())
+ end
+
+ local function getTestVariants()
+ local threadVariants = makeThreadVariants()
+ return { threadVariants[1], threadVariants[2] or threadVariants[1] }
+ end
+
+ local function getTestSockets(threadVariants)
+ for _, socket in ipairs(getSockets()) do
+ local slot = build.itemsTab.sockets[socket.id]
+ local node = build.spec.tree.nodes[socket.id]
+ if slot and slot.selItemId == 0 and node and node.nodesInRadius then
+ for _, variant in ipairs(threadVariants) do
+ local radiusNodes = node.nodesInRadius[variant.radiusIndex]
+ if radiusNodes and next(radiusNodes) then
+ return { socket }
+ end
+ end
+ end
+ end
+ return { getSockets()[1] }
+ end
+
+ local function computeThread(owner, request)
+ request.impactStat = request.impactStat or "Life"
+ request.planCache = request.planCache or { }
+ return owner:computeThreadOfHopeSocketImpact(request)
+ end
+
+ it("returns results for both methods without changing finder state", function()
+ local threadVariants = getTestVariants()
+ assert.is_true(#threadVariants > 0, "expected Thread of Hope ring variants")
+ local sockets = getTestSockets(threadVariants)
+ local before = snapshotFinderState()
+
+ local fastResults, fastBaseline = computeThread(makeFinder().compute, {
+ sockets = sockets,
+ variants = threadVariants,
+ methodId = "fast",
+ })
+ local simulatedResults, simulatedBaseline = computeThread(makeFinder().compute, {
+ sockets = sockets,
+ variants = threadVariants,
+ methodId = "simulated_greedy",
+ })
+
+ assert.is_true(#fastResults > 0, "expected fast Thread of Hope results")
+ assert.is_true(#simulatedResults > 0, "expected simulated Thread of Hope results")
+ assert.is_number(fastBaseline)
+ assert.are.equal(fastBaseline, simulatedBaseline)
+ assert.is_not_nil(fastResults[1].variant)
+ assert.is_not_nil(simulatedResults[1].variant)
+ assert.is_number(fastResults[1].variant.radiusIndex)
+ assert.is_number(simulatedResults[1].variant.radiusIndex)
+ assert.is_string(fastResults[1].detailText)
+ assert.is_string(simulatedResults[1].detailText)
+ assertFinderStateUnchanged(before)
+ end)
+
+ it("respects max total points", function()
+ local threadVariants = getTestVariants()
+ assert.is_true(#threadVariants > 0, "expected Thread of Hope ring variants")
+ local maxPoints = 4
+ local results, _ = computeThread(makeFinder().compute, {
+ sockets = getTestSockets(threadVariants),
+ variants = threadVariants,
+ methodId = "simulated_greedy",
+ maxTotalPoints = maxPoints,
+ })
+ for _, result in ipairs(results) do
+ local totalPoints = (result.socket.pathDist or 0) + (result.addedNodeCount or 0)
+ assert.is_true(totalPoints <= maxPoints,
+ "socket " .. result.socket.id .. " plan used too many points")
+ end
+ end)
+
+ local function runSyntheticFastThreadCompute(sockets, deltaBySocketId)
+ local finder = makeFinder()
+ local originalGetMiscCalculator = build.calcsTab.GetMiscCalculator
+ build.calcsTab.GetMiscCalculator = function()
+ return function() return { Life = 0 } end, { Life = 0 }
+ end
+ finder.socketMatchesOccupiedMode = function()
+ return true, nil
+ end
+ finder.getSocketBasePoints = function(_, socket)
+ return socket.pathDist or 0
+ end
+ finder.compute.buildSocketReplacementContext = function(_, _, socketId)
+ return {
+ socketNode = { id = socketId },
+ baselineOutput = { Life = 0 },
+ }
+ end
+ finder.compute.collectDisconnectedPassiveCandidates = function(_, socketNode)
+ return { { id = socketNode.id * 10, name = "Candidate " .. socketNode.id } }
+ end
+ finder.compute.computeDisconnectedPassiveFastPlan = function(_, request)
+ local socketNode = request.socketNode
+ local result = {
+ delta = deltaBySocketId[socketNode.id],
+ addedNodeCount = 1,
+ resultNodes = { socketNode.id * 10 },
+ resultNodeLabels = { "Candidate " .. socketNode.id },
+ baseOutput = { Life = 0 },
+ compareOutput = { Life = deltaBySocketId[socketNode.id] },
+ detailText = "plan-" .. socketNode.id,
+ variantLabel = request.variantLabel,
+ }
+ if not request.skipPlanSteps then
+ result.planSteps = { { detailText = result.detailText } }
+ end
+ return result
+ end
+
+ local results = computeThread(finder.compute, {
+ sockets = sockets,
+ variants = { getTestVariants()[1] },
+ methodId = "fast",
+ skipPlanSteps = false,
+ })
+ build.calcsTab.GetMiscCalculator = originalGetMiscCalculator
+ return results
+ end
+
+ it("keeps every fast plan attached to its socket after sorting", function()
+ local results = runSyntheticFastThreadCompute({
+ { id = 101, label = "Lower gain", pathDist = 1 },
+ { id = 202, label = "Higher gain", pathDist = 1 },
+ }, {
+ [101] = 10,
+ [202] = 20,
+ })
+
+ assert.are.equal(2, #results)
+ assert.are.equal(202, results[1].socket.id)
+ for _, result in ipairs(results) do
+ assert.are.equal("plan-" .. result.socket.id, result.detailText)
+ assert.is_not_nil(result.planSteps)
+ end
+ end)
+
+ it("builds plan details for a percent-per-point leader outside the top five gains", function()
+ local sockets = { }
+ local deltas = { }
+ for index, delta in ipairs({ 100, 90, 80, 70, 60, 10 }) do
+ local socketId = 300 + index
+ table.insert(sockets, {
+ id = socketId,
+ label = "Socket " .. index,
+ pathDist = index == 6 and 0 or 99,
+ })
+ deltas[socketId] = delta
+ end
+ local results = runSyntheticFastThreadCompute(sockets, deltas)
+ local efficiencyLeader = results[6]
+ local leaderEfficiency = efficiencyLeader.delta
+ / (efficiencyLeader.socket.pathDist + efficiencyLeader.addedNodeCount)
+
+ assert.are.equal(10, efficiencyLeader.delta)
+ assert.is_true(leaderEfficiency > results[1].delta
+ / (results[1].socket.pathDist + results[1].addedNodeCount))
+ assert.is_not_nil(efficiencyLeader.planSteps)
+ assert.are.equal("plan-" .. efficiencyLeader.socket.id, efficiencyLeader.detailText)
+ end)
+
+ end)
+
+ -- ── Jewel limit parsing ─────────────────────────────────────────────────
+
+ describe("jewel limit parsing from raw text", function()
+
+ it("parses Limited to: 1 from Impossible Escape raw text", function()
+ local rawText = buildImpossibleEscapeRawText("Acrobatics")
+ local limitKey = rawText:match("^([^\n]+)")
+ local limit = tonumber(rawText:match("Limited to: (%d+)"))
+ assert.are.equals("Impossible Escape", limitKey)
+ assert.are.equals(1, limit)
+ end)
+
+ it("parses Limited to: 1 from Unnatural Instinct raw text", function()
+ local limitKey = UNNATURAL_INSTINCT_RAW_TEXT:match("^([^\n]+)")
+ local limit = tonumber(UNNATURAL_INSTINCT_RAW_TEXT:match("Limited to: (%d+)"))
+ assert.are.equals("Unnatural Instinct", limitKey)
+ assert.are.equals(1, limit)
+ end)
+
+ it("returns nil limit for jewels without Limited to", function()
+ local limit = tonumber(MIGHT_OF_MEEK_RAW_TEXT:match("Limited to: (%d+)"))
+ assert.is_nil(limit)
+ end)
+
+ end)
+
+ -- ── filterBestPerSocket ────────────────────────────────────────────────
+
+ describe("filterBestPerSocket", function()
+
+ local function makeRow(socketId, score, options)
+ options = options or {}
+ return {
+ socketId = socketId,
+ sortValue = score,
+ isEffectSocketIndependent = options.isEffectSocketIndependent,
+ jewelLimitKey = options.jewelLimitKey,
+ jewelLimit = options.jewelLimit,
+ points = options.points,
+ name = options.name or ("row-" .. socketId),
+ }
+ end
+
+ it("keeps one result per socket, highest score is kept", function()
+ local rows = {
+ makeRow(1, 10, { name = "A" }),
+ makeRow(1, 20, { name = "B" }),
+ makeRow(2, 15, { name = "C" }),
+ }
+ local result = makeFinder():filterBestPerSocket(rows)
+ assert.are.equal(2, #result)
+ local ids = {}
+ for _, r in ipairs(result) do ids[r.socketId] = r.name end
+ assert.are.equal("B", ids[1])
+ assert.are.equal("C", ids[2])
+ end)
+
+ it("results are sorted by score descending", function()
+ local rows = {
+ makeRow(1, 5),
+ makeRow(2, 30),
+ makeRow(3, 15),
+ }
+ local result = makeFinder():filterBestPerSocket(rows)
+ assert.are.equal(3, #result)
+ assert.are.equal(2, result[1].socketId)
+ assert.are.equal(3, result[2].socketId)
+ assert.are.equal(1, result[3].socketId)
+ end)
+
+ it("applies jewelLimit per jewelLimitKey", function()
+ local rows = {
+ makeRow(1, 30, { jewelLimitKey = "IE", jewelLimit = 1 }),
+ makeRow(2, 20, { jewelLimitKey = "IE", jewelLimit = 1 }),
+ makeRow(3, 10),
+ }
+ local result = makeFinder():filterBestPerSocket(rows)
+ assert.are.equal(2, #result)
+ local ids = {}
+ for _, r in ipairs(result) do ids[r.socketId] = true end
+ assert.is_true(ids[1], "best IE should be kept")
+ assert.is_true(ids[3], "unlimited jewel should be kept")
+ assert.is_nil(ids[2], "second IE should be dropped (limit 1)")
+ end)
+
+ it("allows multiple copies up to the limit", function()
+ local rows = {
+ makeRow(1, 30, { jewelLimitKey = "CF", jewelLimit = 2 }),
+ makeRow(2, 20, { jewelLimitKey = "CF", jewelLimit = 2 }),
+ makeRow(3, 10, { jewelLimitKey = "CF", jewelLimit = 2 }),
+ }
+ local result = makeFinder():filterBestPerSocket(rows)
+ assert.are.equal(2, #result)
+ assert.are.equal(1, result[1].socketId)
+ assert.are.equal(2, result[2].socketId)
+ end)
+
+ it("socket-dependent jewels are assigned before socket-independent", function()
+ -- Socket 1: dependent score 10, independent score 20
+ -- The dependent should get socket 1, independent goes to socket 2
+ local rows = {
+ makeRow(1, 10, { name = "dependent" }),
+ makeRow(1, 20, { name = "independent", isEffectSocketIndependent = true }),
+ makeRow(2, 5, { name = "independent2", isEffectSocketIndependent = true }),
+ }
+ local result = makeFinder():filterBestPerSocket(rows)
+ assert.are.equal(2, #result)
+ local bySocket = {}
+ for _, r in ipairs(result) do bySocket[r.socketId] = r.name end
+ -- The independent with score 20 cannot take socket 1 (dependent uses it)
+ -- It should go to socket 2 instead
+ assert.are.equal("dependent", bySocket[1])
+ end)
+
+ it("socket-independent jewels use remaining sockets after dependent allocation", function()
+ local rows = {
+ makeRow(1, 30, { name = "dependent-1" }),
+ makeRow(2, 25, { name = "dependent-2" }),
+ makeRow(1, 20, { name = "independent-1", isEffectSocketIndependent = true }),
+ makeRow(2, 15, { name = "independent-2", isEffectSocketIndependent = true }),
+ makeRow(3, 10, { name = "independent-3", isEffectSocketIndependent = true }),
+ }
+ local result = makeFinder():filterBestPerSocket(rows)
+ local bySocket = {}
+ for _, r in ipairs(result) do bySocket[r.socketId] = r.name end
+ assert.are.equal("dependent-1", bySocket[1])
+ assert.are.equal("dependent-2", bySocket[2])
+ assert.are.equal("independent-3", bySocket[3])
+ end)
+
+ it("socket-independent tie-break uses fewer points", function()
+ local rows = {
+ makeRow(1, 20, { isEffectSocketIndependent = true, points = 5 }),
+ makeRow(2, 20, { isEffectSocketIndependent = true, points = 2 }),
+ }
+ local result = makeFinder():filterBestPerSocket(rows)
+ assert.are.equal(2, #result)
+ -- Both are kept (different sockets), but fewer points should come first at equal score
+ -- Actually both have different sockets so both are included
+ -- The tie-break matters when multiple rows can use the same remaining sockets
+ end)
+
+ it("socket-independent tie-break: at equal score, fewer points is kept", function()
+ -- Two independent jewels can use a single remaining socket
+ local rows = {
+ makeRow(1, 50, { name = "dependent" }), -- takes socket 1
+ makeRow(1, 20, { name = "ie-high-points", isEffectSocketIndependent = true, points = 8 }),
+ makeRow(2, 20, { name = "ie-low-points", isEffectSocketIndependent = true, points = 2 }),
+ }
+ local result = makeFinder():filterBestPerSocket(rows)
+ local bySocket = {}
+ for _, r in ipairs(result) do bySocket[r.socketId] = r.name end
+ assert.are.equal("dependent", bySocket[1])
+ assert.are.equal("ie-low-points", bySocket[2])
+ end)
+
+ it("limits are shared between dependent and independent jewels", function()
+ -- IE limited to 1: if a dependent row with same limitKey is placed first,
+ -- independent rows with that key are blocked
+ local rows = {
+ makeRow(1, 30, { name = "dependent-ie", jewelLimitKey = "IE", jewelLimit = 1 }),
+ makeRow(2, 20, { name = "independent-ie", isEffectSocketIndependent = true, jewelLimitKey = "IE", jewelLimit = 1 }),
+ makeRow(3, 10, { name = "other" }),
+ }
+ local result = makeFinder():filterBestPerSocket(rows)
+ assert.are.equal(2, #result)
+ local names = {}
+ for _, r in ipairs(result) do names[r.name] = true end
+ assert.is_true(names["dependent-ie"])
+ assert.is_true(names["other"])
+ assert.is_nil(names["independent-ie"], "second IE should be blocked by shared limit")
+ end)
+
+ it("returns empty table for empty input", function()
+ local result = makeFinder():filterBestPerSocket({})
+ assert.are.equal(0, #result)
+ end)
+
+ it("does not change the input rows table", function()
+ local rows = {
+ makeRow(2, 10),
+ makeRow(1, 20),
+ }
+ local originalLen = #rows
+ local originalFirst = rows[1]
+ makeFinder():filterBestPerSocket(rows)
+ assert.are.equal(originalLen, #rows)
+ assert.are.equal(originalFirst, rows[1])
+ end)
+
+ end)
+
+ -- ── Move-aware compute helpers ─────────────────────────────────────────
+
+ describe("move-aware compute helpers", function()
+
+ local ALLOC_SOCKET_IDS = { 36634, 61419, 41263 }
+
+ local function findUnallocatedSocketId()
+ for socketId, socketData in pairs(build.spec.nodes) do
+ if socketData.isJewelSocket and socketData.name ~= "Charm Socket"
+ and build.itemsTab.sockets[socketId] and not build.spec.allocNodes[socketId] then
+ return socketId
+ end
+ end
+ error("expected at least one unallocated jewel socket")
+ end
+
+ local function equipFakeJewel(socketId, title, limit, extraItemFields)
+ local slot = build.itemsTab.sockets[socketId]
+ assert.is_not_nil(slot, "socket " .. socketId .. " should exist")
+ local fakeItemId = 999000 + socketId
+ local item = { title = title, limit = limit }
+ if extraItemFields then
+ for k, v in pairs(extraItemFields) do item[k] = v end
+ end
+ build.itemsTab.items[fakeItemId] = item
+ slot.selItemId = fakeItemId
+ build.spec.jewels[socketId] = fakeItemId
+ return item, fakeItemId
+ end
+
+ local function getTestRadiusIndex()
+ return getLargeRadiusIndex()
+ end
+
+ -- Find a jewel socket whose radius contains at least one unallocated node
+ -- with NO allocated linked nodes outside the radius ("isolated").
+ -- Note: `linked` is on spec.nodes, not spec.tree.nodes.
+ local function findIsolatedRadiusNode(radiusIndex)
+ local treeData = build.spec.tree
+ for socketId, socketData in pairs(build.spec.nodes) do
+ if socketData.isJewelSocket then
+ local socketNode = treeData.nodes[socketId]
+ if socketNode and socketNode.nodesInRadius and socketNode.nodesInRadius[radiusIndex] then
+ local radiusNodes = socketNode.nodesInRadius[radiusIndex]
+ for nodeId, _ in pairs(radiusNodes) do
+ if not build.spec.allocNodes[nodeId] then
+ local specNode = build.spec.nodes[nodeId]
+ local isolated = true
+ if specNode and specNode.linked then
+ for _, other in ipairs(specNode.linked) do
+ if build.spec.allocNodes[other.id] and not radiusNodes[other.id] then
+ isolated = false
+ break
+ end
+ end
+ end
+ if isolated then
+ return socketId, nodeId
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+
+ -- Find an unallocated radius node that has at least one linked node
+ -- OUTSIDE the radius. Returns socketId, nodeId, outsideLinkedNodeId.
+ -- Note: `linked` is on spec.nodes, not spec.tree.nodes.
+ local function findRadiusNodeWithOutsideLinkedNode(radiusIndex)
+ local treeData = build.spec.tree
+ for socketId, socketData in pairs(build.spec.nodes) do
+ if socketData.isJewelSocket then
+ local socketNode = treeData.nodes[socketId]
+ if socketNode and socketNode.nodesInRadius and socketNode.nodesInRadius[radiusIndex] then
+ local radiusNodes = socketNode.nodesInRadius[radiusIndex]
+ for nodeId, _ in pairs(radiusNodes) do
+ if not build.spec.allocNodes[nodeId] then
+ local specNode = build.spec.nodes[nodeId]
+ if specNode and specNode.linked then
+ for _, other in ipairs(specNode.linked) do
+ if not radiusNodes[other.id] then
+ return socketId, nodeId, other.id
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+
+ -- ── findEquippedJewelSockets ────────────────────────────────────
+
+ describe("findEquippedJewelSockets", function()
+
+ it("returns empty when no jewel of that type is equipped", function()
+ local result = makeFinder():findEquippedJewelSockets({ name = "Thread of Hope" })
+ assert.are.equal(0, #result)
+ end)
+
+ it("ignores jewels stored in unallocated sockets", function()
+ local socketId = findUnallocatedSocketId()
+ equipFakeJewel(socketId, "Thread of Hope", 1)
+ local finder = makeFinder()
+ local occupancy = finder:getSocketOccupancyInfo(socketId)
+ local allowed = finder:socketMatchesOccupiedMode(socketId, { id = "free" })
+
+ assert.is_false(occupancy.isOccupied)
+ assert.are.equal("Thread of Hope", occupancy.storedUnallocatedItemLabel)
+ assert.is_true(allowed)
+ assert.are.equal(7, finder:getSocketBasePoints({ id = socketId, pathDist = 7 }, occupancy))
+
+ local result = finder:findEquippedJewelSockets({ name = "Thread of Hope" })
+ assert.are.equal(0, #result)
+ assert.is_false(result.atLimit)
+ end)
+
+ it("returns entry but atLimit=false when equipped jewel has no limit", function()
+ equipFakeJewel(ALLOC_SOCKET_IDS[1], "Might of the Meek", nil)
+ local result = makeFinder():findEquippedJewelSockets({ name = "Might of the Meek" })
+ assert.are.equal(1, #result)
+ assert.are.equal(ALLOC_SOCKET_IDS[1], result[1].socketId)
+ assert.is_false(result.atLimit)
+ end)
+
+ it("allows ordinary jewels in Safe occupied and labels their base type", function()
+ local socketId = ALLOC_SOCKET_IDS[1]
+ local itemId = 999000 + socketId
+ local item = new("Item"):Item("Rarity: RARE\nChimeric Creed\nCrimson Jewel\n")
+ item.id = itemId
+ build.itemsTab.items[itemId] = item
+ build.itemsTab.sockets[socketId].selItemId = itemId
+ build.spec.jewels[socketId] = itemId
+ local finder = makeFinder()
+ local isAllowed, occupancy = finder:socketMatchesOccupiedMode(socketId, { id = "safe" })
+
+ assert.is_nil(next(item.jewelData.impossibleEscapeKeystones))
+ assert.is_true(isAllowed)
+ assert.are.equal("Chimeric Creed (Crimson Jewel)", occupancy.replacedItemLabel)
+ end)
+
+ it("keeps ordinary Abyss jewels safe but excludes Abyss Timeless jewels", function()
+ local socketId = ALLOC_SOCKET_IDS[1]
+ local ordinaryAbyssJewel = equipFakeJewel(socketId, "Hypnotic Eye Jewel", nil, {
+ type = "Jewel",
+ jewelData = { },
+ })
+ local finder = makeFinder()
+
+ local isOrdinaryAbyssAllowed = finder:socketMatchesOccupiedMode(socketId, { id = "safe" })
+ assert.is_true(isOrdinaryAbyssAllowed)
+
+ ordinaryAbyssJewel.jewelData.conqueredBy = { conqueror = { type = "Abyss" } }
+ local isAbyssTimelessAllowed = finder:socketMatchesOccupiedMode(socketId, { id = "safe" })
+ assert.is_false(isAbyssTimelessAllowed)
+ assert.is_true(finder.compute:socketReplacementChangesPassiveTree({
+ occupancy = { isOccupied = true, item = ordinaryAbyssJewel },
+ }, { type = "Jewel", jewelData = { } }))
+ end)
+
+ it("returns entries with atLimit=true when limited jewel count reaches limit", function()
+ equipFakeJewel(ALLOC_SOCKET_IDS[1], "Thread of Hope", 1)
+ local result = makeFinder():findEquippedJewelSockets({ name = "Thread of Hope" })
+ assert.are.equal(1, #result)
+ assert.are.equal(ALLOC_SOCKET_IDS[1], result[1].socketId)
+ assert.are.equal("Thread of Hope", result[1].item.title)
+ assert.is_true(result.atLimit)
+ end)
+
+ it("matches an equipped Foulborn jewel against its base unique name", function()
+ equipFakeJewel(ALLOC_SOCKET_IDS[1], "Foulborn Intuitive Leap", 1)
+ local result = makeFinder():findEquippedJewelSockets({ name = "Intuitive Leap" })
+ assert.are.equal(1, #result)
+ assert.are.equal("Foulborn Intuitive Leap", result[1].item.title)
+ assert.is_true(result.atLimit)
+ end)
+
+ it("matches grouped families through the selected canonical variant", function()
+ local jewelTypes = RadiusJewelData.buildJewelTypes()
+ local function findJewelType(name)
+ for _, jewelType in ipairs(jewelTypes) do
+ if jewelType.name == name then
+ return jewelType
+ end
+ end
+ end
+ local function findVariant(jewelType, name)
+ for _, variant in ipairs(jewelType.variants or { }) do
+ if variant.name == name then
+ return variant
+ end
+ end
+ end
+
+ local cases = {
+ { socketId = ALLOC_SOCKET_IDS[1], family = "Dreams & Nightmares", variant = "The Red Nightmare", limit = 1 },
+ { socketId = ALLOC_SOCKET_IDS[2], family = "Stat Conversion", variant = "Healthy Mind", limit = 1 },
+ { socketId = ALLOC_SOCKET_IDS[3], family = "Tempered & Transcendent", variant = "Tempered Flesh" },
+ }
+ for _, testCase in ipairs(cases) do
+ equipFakeJewel(testCase.socketId, testCase.variant, testCase.limit)
+ end
+
+ local finder = makeFinder()
+ for _, testCase in ipairs(cases) do
+ local jewelType = findJewelType(testCase.family)
+ local variant = findVariant(jewelType, testCase.variant)
+ local result = finder:findEquippedJewelSockets(jewelType, variant)
+ assert.are.equal(1, #result, "expected canonical match for " .. testCase.variant)
+ assert.are.equal(testCase.socketId, result[1].socketId)
+ assert.are.equal(testCase.limit ~= nil, result.atLimit)
+ end
+ end)
+
+ it("returns entry but atLimit=false when equipped count is below limit", function()
+ equipFakeJewel(ALLOC_SOCKET_IDS[1], "Combat Focus", 2)
+ local result = makeFinder():findEquippedJewelSockets({ name = "Combat Focus" })
+ assert.are.equal(1, #result, "1 equipped < limit 2")
+ assert.is_false(result.atLimit)
+ end)
+
+ it("returns all entries with atLimit=true when count equals limit", function()
+ equipFakeJewel(ALLOC_SOCKET_IDS[1], "Combat Focus", 2)
+ equipFakeJewel(ALLOC_SOCKET_IDS[2], "Combat Focus", 2)
+ local result = makeFinder():findEquippedJewelSockets({ name = "Combat Focus" })
+ assert.are.equal(2, #result)
+ assert.is_true(result.atLimit)
+ end)
+
+ it("does not match jewels with different title", function()
+ equipFakeJewel(ALLOC_SOCKET_IDS[1], "Thread of Hope", 1)
+ local result = makeFinder():findEquippedJewelSockets({ name = "Impossible Escape" })
+ assert.are.equal(0, #result)
+ end)
+
+ end)
+
+ it("computeSocketImpact treats jewels stored in unallocated sockets as free sockets", function()
+ local socketId = findUnallocatedSocketId()
+ equipFakeJewel(socketId, "Unnatural Instinct", 1)
+ local finder = makeFinder()
+ local results = finder.compute:computeSocketImpact({
+ sockets = {
+ { id = socketId, label = "Test socket", pathDist = 7 },
+ },
+ rawText = MIGHT_OF_MEEK_RAW_TEXT,
+ impactStat = "Life",
+ occupiedMode = { id = "free" },
+ })
+
+ assert.are.equal(1, #results)
+ assert.is_nil(results[1].replacedItemLabel)
+ assert.are.equal("Unnatural Instinct", results[1].storedUnallocatedItemLabel)
+ end)
+
+ -- ── findDisconnectedPassiveDependentNodes ─────────────────────────────
+
+ describe("findDisconnectedPassiveDependentNodes", function()
+
+ it("returns empty for items without disconnected passive properties", function()
+ local result = makeFinder():findDisconnectedPassiveDependentNodes(ALLOC_SOCKET_IDS[1], { title = "Might of the Meek" })
+ assert.are.equal(0, #result)
+ end)
+
+ it("returns empty for invalid socketId", function()
+ local item = { jewelRadiusIndex = getTestRadiusIndex() }
+ local result = makeFinder():findDisconnectedPassiveDependentNodes(999999, item)
+ assert.are.equal(0, #result)
+ end)
+
+ it("returns empty when no nodes are allocated in radius", function()
+ local treeData = build.spec.tree
+ local smallRI = getTestRadiusIndex()
+ local testSocketId
+ for socketId, _ in pairs(build.itemsTab.sockets) do
+ local node = treeData.nodes[socketId]
+ if node and node.nodesInRadius and node.nodesInRadius[smallRI]
+ and next(node.nodesInRadius[smallRI]) then
+ local hasAllocated = false
+ for nodeId, _ in pairs(node.nodesInRadius[smallRI]) do
+ if build.spec.allocNodes[nodeId] then
+ hasAllocated = true
+ break
+ end
+ end
+ if not hasAllocated then
+ testSocketId = socketId
+ break
+ end
+ end
+ end
+ if not testSocketId then pending("no empty radius socket found") end
+ local item = { jewelRadiusIndex = smallRI }
+ local result = makeFinder():findDisconnectedPassiveDependentNodes(testSocketId, item)
+ assert.are.equal(0, #result)
+ end)
+
+ it("returns isolated allocated nodes in radius as dependent", function()
+ local smallRI = getTestRadiusIndex()
+ local testSocketId, testNodeId = findIsolatedRadiusNode(smallRI)
+ if not testSocketId then pending("no isolated radius node found") end
+
+ build.spec.allocNodes[testNodeId] = build.spec.tree.nodes[testNodeId]
+
+ local item = { jewelRadiusIndex = smallRI }
+ local result = makeFinder():findDisconnectedPassiveDependentNodes(testSocketId, item)
+
+ assert.is_true(#result > 0, "expected at least one dependent node")
+ local found = false
+ for _, nodeId in ipairs(result) do
+ if nodeId == testNodeId then found = true; break end
+ end
+ assert.is_true(found, "expected node " .. testNodeId .. " in dependent nodes")
+ end)
+
+ it("excludes nodes connected from outside the radius", function()
+ local treeData = build.spec.tree
+ local ri = getTestRadiusIndex()
+ local testSocketId, testNodeId, outsideLinkedNodeId = findRadiusNodeWithOutsideLinkedNode(ri)
+ if not testSocketId then pending("no radius node with outside linked node found") end
+
+ -- Allocate both the radius node and its outside linked node
+ build.spec.allocNodes[testNodeId] = treeData.nodes[testNodeId]
+ build.spec.allocNodes[outsideLinkedNodeId] = treeData.nodes[outsideLinkedNodeId]
+
+ local item = { jewelRadiusIndex = ri }
+ local result = makeFinder():findDisconnectedPassiveDependentNodes(testSocketId, item)
+
+ local found = false
+ for _, nodeId in ipairs(result) do
+ if nodeId == testNodeId then found = true; break end
+ end
+ assert.is_false(found, "node connected from outside radius should not be dependent")
+ end)
+
+ it("handles IE keystoneMap path", function()
+ local variant = makeImpossibleEscapeTestVariant()
+ if not variant then pending("no IE keystone variant found") end
+
+ local item = {
+ jewelData = { impossibleEscapeKeystones = { [variant.keystoneName] = true } },
+ }
+ -- Should return empty since no extra nodes are allocated in the keystone radius
+ local result = makeFinder():findDisconnectedPassiveDependentNodes(ALLOC_SOCKET_IDS[1], item)
+ assert.is_table(result)
+ end)
+
+ end)
+
+ -- ── removeEquippedJewels / restoreEquippedJewels ────────────────
+
+ describe("removeEquippedJewels / restoreEquippedJewels", function()
+
+ it("remove+restore keeps state identical", function()
+ equipFakeJewel(ALLOC_SOCKET_IDS[1], "Thread of Hope", 1, {
+ jewelRadiusIndex = getTestRadiusIndex(),
+ })
+ local finder = makeFinder()
+ local equippedList = finder:findEquippedJewelSockets({ name = "Thread of Hope" })
+ assert.are.equal(1, #equippedList)
+
+ local beforeSlotId = build.itemsTab.sockets[ALLOC_SOCKET_IDS[1]].selItemId
+ local beforeSpecJewel = build.spec.jewels[ALLOC_SOCKET_IDS[1]]
+ local beforeAllocKeys = {}
+ for nodeId, _ in pairs(build.spec.allocNodes) do
+ beforeAllocKeys[nodeId] = true
+ end
+
+ finder:removeEquippedJewels(equippedList)
+ finder:restoreEquippedJewels(equippedList)
+
+ assert.are.equal(beforeSlotId, build.itemsTab.sockets[ALLOC_SOCKET_IDS[1]].selItemId)
+ assert.are.equal(beforeSpecJewel, build.spec.jewels[ALLOC_SOCKET_IDS[1]])
+ for nodeId, _ in pairs(beforeAllocKeys) do
+ assert.is_not_nil(build.spec.allocNodes[nodeId],
+ "allocNode " .. nodeId .. " should be restored")
+ end
+ end)
+
+ it("remove clears slot.selItemId and spec.jewels", function()
+ equipFakeJewel(ALLOC_SOCKET_IDS[1], "Thread of Hope", 1)
+ local finder = makeFinder()
+ local equippedList = finder:findEquippedJewelSockets({ name = "Thread of Hope" })
+
+ finder:removeEquippedJewels(equippedList)
+
+ assert.are.equal(0, build.itemsTab.sockets[ALLOC_SOCKET_IDS[1]].selItemId)
+ assert.are.equal(0, build.spec.jewels[ALLOC_SOCKET_IDS[1]])
+
+ finder:restoreEquippedJewels(equippedList)
+ end)
+
+ it("remove clears dependent disconnected passive nodes from allocNodes", function()
+ local smallRI = getTestRadiusIndex()
+ local testSocketId, testNodeId = findIsolatedRadiusNode(smallRI)
+ if not testSocketId then pending("no isolated radius node found") end
+
+ -- Allocate the isolated node as a disconnected passive jewel would.
+ build.spec.allocNodes[testSocketId] = build.spec.tree.nodes[testSocketId]
+ build.spec.allocNodes[testNodeId] = build.spec.tree.nodes[testNodeId]
+
+ equipFakeJewel(testSocketId, "Intuitive Leap", 1, {
+ jewelRadiusIndex = smallRI,
+ })
+
+ local finder = makeFinder()
+ local equippedList = finder:findEquippedJewelSockets({ name = "Intuitive Leap" })
+ assert.are.equal(1, #equippedList)
+
+ finder:removeEquippedJewels(equippedList)
+ assert.is_nil(build.spec.allocNodes[testNodeId],
+ "dependent node " .. testNodeId .. " should be removed")
+
+ finder:restoreEquippedJewels(equippedList)
+ assert.is_not_nil(build.spec.allocNodes[testNodeId],
+ "dependent node " .. testNodeId .. " should be restored")
+ end)
+
+ it("remove preserves nodes connected from outside the radius", function()
+ local treeData = build.spec.tree
+ local ri = getTestRadiusIndex()
+ local testSocketId, testNodeId, outsideLinkedNodeId = findRadiusNodeWithOutsideLinkedNode(ri)
+ if not testSocketId then pending("no radius node with outside linked node found") end
+
+ build.spec.allocNodes[testSocketId] = treeData.nodes[testSocketId]
+ build.spec.allocNodes[testNodeId] = treeData.nodes[testNodeId]
+ build.spec.allocNodes[outsideLinkedNodeId] = treeData.nodes[outsideLinkedNodeId]
+
+ equipFakeJewel(testSocketId, "Intuitive Leap", 1, {
+ jewelRadiusIndex = ri,
+ })
+
+ local finder = makeFinder()
+ local equippedList = finder:findEquippedJewelSockets({ name = "Intuitive Leap" })
+ assert.are.equal(1, #equippedList)
+
+ finder:removeEquippedJewels(equippedList)
+ assert.is_not_nil(build.spec.allocNodes[testNodeId],
+ "connected node " .. testNodeId .. " should NOT be removed")
+
+ finder:restoreEquippedJewels(equippedList)
+ end)
+
+ end)
+
+ end)
+
+end)
diff --git a/spec/System/TestRadiusJewelData_spec.lua b/spec/System/TestRadiusJewelData_spec.lua
new file mode 100644
index 0000000000..514bd52fb7
--- /dev/null
+++ b/spec/System/TestRadiusJewelData_spec.lua
@@ -0,0 +1,469 @@
+-- Data and variant tests for RadiusJewelData.
+
+local support = LoadModule("../spec/System/RadiusJewelFinderTestSupport.lua")
+local occVortex = support.occVortex
+local RadiusJewelData = support.RadiusJewelData
+local makeFinder = support.makeFinder
+local getSmallRadiusIndex = support.getSmallRadiusIndex
+local getRadiusIndexFromRawText = support.getRadiusIndexFromRawText
+
+describe("RadiusJewelData #radius-jewel", function()
+
+ before_each(function()
+ loadBuildFromXML(occVortex.xml, "OccVortex")
+ end)
+
+ -- ── buildVariantsFromUniqueItem ──────────────────────────────────────────
+
+ describe("buildVariantsFromUniqueItem", function()
+
+ it("builds Light of Meaning variants with valid name and rawText", function()
+ local variants = RadiusJewelData.buildVariantsFromUniqueItem("The Light of Meaning")
+ assert.is_true(#variants > 0, "expected at least one Light of Meaning variant")
+ for _, v in ipairs(variants) do
+ assert.is_string(v.name)
+ assert.is_string(v.rawText)
+ assert.is_true(#v.name > 0, "variant name should not be empty")
+ assert.is_true(#v.rawText > 0, "variant rawText should not be empty")
+ assert.are.equal(getRadiusIndexFromRawText(v.rawText), v.radiusIndex,
+ "variant radiusIndex should come from raw unique text: " .. v.name)
+ end
+ end)
+
+ it("builds Split Personality variants with unique names", function()
+ local variants = RadiusJewelData.buildVariantsFromUniqueItem("Split Personality")
+ assert.is_true(#variants > 0, "expected at least one Split Personality variant")
+ local seenNames = {}
+ for _, v in ipairs(variants) do
+ assert.is_string(v.name)
+ assert.is_string(v.rawText)
+ assert.is_nil(seenNames[v.name], "duplicate variant name: " .. v.name)
+ seenNames[v.name] = true
+ end
+ end)
+
+ it("variant rawText contains Selected Variant header", function()
+ local variants = RadiusJewelData.buildVariantsFromUniqueItem("The Light of Meaning")
+ for _, v in ipairs(variants) do
+ assert.is_not_nil(v.rawText:match("Selected Variant: %d+"), "rawText should contain Selected Variant: " .. v.name)
+ end
+ end)
+
+ end)
+
+ -- ── buildJewelTypes ──────────────────────────────────────────────────────
+
+ describe("buildJewelTypes", function()
+ it("gives every jewel descriptor its preview", function()
+ for _, jewelType in ipairs(RadiusJewelData.buildJewelTypes()) do
+ assert.is_function(jewelType.preview,
+ "missing preview function for " .. jewelType.name)
+ end
+ end)
+
+ it("preserves base previews when descriptors have raw text and groups variants-only descriptors", function()
+ local jewelTypesByName = { }
+ for _, jewelType in ipairs(RadiusJewelData.buildJewelTypes()) do
+ jewelTypesByName[jewelType.name] = jewelType
+ end
+ local function previewText(jewelType, variant)
+ local text = { }
+ for _, line in ipairs(jewelType.preview(variant)) do
+ if line[1] then
+ text[#text + 1] = line[1]
+ end
+ end
+ return table.concat(text, "\n")
+ end
+
+ local intuitivePreview = previewText(jewelTypesByName["Intuitive Leap"])
+ assert.is_not_nil(intuitivePreview:find("Radius: Small", 1, true))
+ assert.is_nil(intuitivePreview:find("Finder group", 1, true))
+
+ local groupPreview = previewText(jewelTypesByName["Tempered & Transcendent"])
+ assert.is_not_nil(groupPreview:find("Finder group", 1, true))
+
+ local splitPersonality = jewelTypesByName["Split Personality"]
+ local splitVariant = splitPersonality.variants[1]
+ local splitPreview = previewText(splitPersonality, splitVariant)
+ assert.is_not_nil(splitPreview:find("Split Personality (" .. splitVariant.name .. ")", 1, true))
+ end)
+
+ it("assigns one evaluation strategy to every jewel type", function()
+ local strategy = RadiusJewelData.JEWEL_STRATEGY
+ local expectedSpecialStrategies = {
+ ["Intuitive Leap"] = strategy.INTUITIVE_LEAP,
+ ["Thread of Hope"] = strategy.THREAD_OF_HOPE,
+ ["Impossible Escape"] = strategy.IMPOSSIBLE_ESCAPE,
+ ["Split Personality"] = strategy.SPLIT_PERSONALITY,
+ }
+ for _, jewelType in ipairs(RadiusJewelData.buildJewelTypes()) do
+ assert.are.equal(expectedSpecialStrategies[jewelType.name] or strategy.RADIUS, jewelType.strategy,
+ "unexpected strategy for " .. jewelType.name)
+ end
+ end)
+
+ it("assigns canonical identities to grouped variants and Thread rings", function()
+ local jewelTypes = RadiusJewelData.buildJewelTypes()
+ local function findJewelType(name)
+ for _, jewelType in ipairs(jewelTypes) do
+ if jewelType.name == name then
+ return jewelType
+ end
+ end
+ end
+ local function findVariant(jewelType, name)
+ for _, variant in ipairs(jewelType.variants or { }) do
+ if variant.name == name then
+ return variant
+ end
+ end
+ end
+
+ for _, expected in ipairs({
+ { family = "Dreams & Nightmares", variant = "The Red Nightmare", uniqueName = "The Red Nightmare", limit = 1 },
+ { family = "Stat Conversion", variant = "Healthy Mind", uniqueName = "Healthy Mind", limit = 1 },
+ { family = "Tempered & Transcendent", variant = "Tempered Flesh", uniqueName = "Tempered Flesh" },
+ }) do
+ local variant = findVariant(findJewelType(expected.family), expected.variant)
+ assert.is_not_nil(variant, "missing grouped variant " .. expected.variant)
+ assert.are.equal(expected.family, variant.variantIdentity.family)
+ assert.are.equal(expected.uniqueName, variant.variantIdentity.uniqueName)
+ assert.are.equal(expected.uniqueName, variant.variantIdentity.limitKey)
+ assert.are.equal(expected.limit, variant.variantIdentity.limit)
+ assert.are.equal(variant.rawText, variant.variantIdentity.rawText)
+ assert.are.equal(variant.radiusIndex, variant.variantIdentity.radiusIndex)
+ end
+
+ local threadVariants = RadiusJewelData.getThreadOfHopeVariants()
+ assert.is_true(#threadVariants > 0, "expected Thread of Hope ring variants")
+ for _, variant in ipairs(threadVariants) do
+ assert.are.equal("Thread of Hope", variant.variantIdentity.family)
+ assert.are.equal("Thread of Hope", variant.variantIdentity.uniqueName)
+ assert.are.equal("Thread of Hope", variant.variantIdentity.limitKey)
+ assert.are.equal(variant.rawText, variant.variantIdentity.rawText)
+ assert.are.equal(getRadiusIndexFromRawText(variant.rawText), variant.radiusIndex)
+ end
+ end)
+
+ it("keeps raw-backed radius indexes aligned with item data", function()
+ local jewelTypes = RadiusJewelData.buildJewelTypes()
+ local checkedTypes = 0
+ local checkedVariants = 0
+
+ for _, jewelType in ipairs(jewelTypes) do
+ if jewelType.rawText then
+ local radiusIndex = getRadiusIndexFromRawText(jewelType.rawText)
+ if radiusIndex then
+ assert.are.equal(radiusIndex, jewelType.radiusIndex,
+ "jewel type radiusIndex should match raw unique text: " .. jewelType.name)
+ checkedTypes = checkedTypes + 1
+ end
+ end
+ for _, variant in ipairs(jewelType.variants or { }) do
+ if variant.rawText then
+ local radiusIndex = getRadiusIndexFromRawText(variant.rawText)
+ if radiusIndex then
+ assert.are.equal(radiusIndex, variant.radiusIndex,
+ "variant radiusIndex should match raw unique text: "
+ .. (variant.dropdownLabel or variant.name))
+ checkedVariants = checkedVariants + 1
+ end
+ end
+ end
+ end
+
+ assert.is_true(checkedTypes > 0, "expected at least one raw-backed jewel type")
+ assert.is_true(checkedVariants > 0, "expected at least one raw-backed jewel variant")
+ end)
+
+ it("keeps Foulborn Dream and Nightmare variants in their jewel family", function()
+ local jewelTypes = RadiusJewelData.buildJewelTypes()
+ local dreamsAndNightmares
+ for _, jewelType in ipairs(jewelTypes) do
+ if jewelType.name == "Dreams & Nightmares" then
+ dreamsAndNightmares = jewelType
+ break
+ end
+ end
+ assert.is_not_nil(dreamsAndNightmares)
+
+ local expectedFamilies = {
+ "The Red Dream", "The Red Nightmare", "The Green Dream",
+ "The Green Nightmare", "The Blue Dream", "The Blue Nightmare",
+ }
+ for _, family in ipairs(expectedFamilies) do
+ local familyVariants = { }
+ for _, variant in ipairs(dreamsAndNightmares.variants) do
+ if variant.variantGroup == family then
+ familyVariants[#familyVariants + 1] = variant
+ end
+ end
+ assert.are.equal(4, #familyVariants, "expected normal plus three Foulborn subsets for " .. family)
+ local foulbornCount = 0
+ for _, variant in ipairs(familyVariants) do
+ if variant.isFoulborn then
+ foulbornCount = foulbornCount + 1
+ local item = new("Item"):Item("Rarity: Unique\n" .. variant.rawText)
+ assert.is_true(item.foulborn, "expected Foulborn item data for " .. variant.name)
+ end
+ end
+ assert.are.equal(3, foulbornCount, "expected three Foulborn subsets for " .. family)
+ end
+ end)
+
+ end)
+
+ -- ── Foulborn radius-jewel variants ───────────────────────────────────────
+
+ describe("buildFoulbornVariants", function()
+
+ local function countEntries(tbl)
+ local count = 0
+ for _ in pairs(tbl) do
+ count = count + 1
+ end
+ return count
+ end
+
+ local function hasMutation(variant, modId)
+ for _, newModId in ipairs(variant.newModIds) do
+ if newModId == modId then
+ return true
+ end
+ end
+ return false
+ end
+
+ local function hasMutatedMod(item, modId)
+ for _, modLine in ipairs(item.explicitModLines) do
+ if modLine.modId == modId and modLine.mutated then
+ return true
+ end
+ end
+ return false
+ end
+
+ it("uses the current Foulborn map instead of generated unique data", function()
+ local map = data.foulbornMap
+ assert.are.equal(1, countEntries(map["Might of the Meek"]))
+ assert.are.equal(2, countEntries(map["Unnatural Instinct"]))
+ assert.are.equal(1, countEntries(map["Inspired Learning"]))
+ assert.are.equal(1, countEntries(map["Lioneye's Fall"]))
+ assert.are.equal(1, countEntries(map["Intuitive Leap"]))
+ assert.are.equal(
+ "MutatedUniqueJewel3GainRandomRareMonsterModOnKillWhileXSmallPassivesAllocatedInRadius",
+ map["Inspired Learning"]["StealRareModUniqueJewel3"])
+ assert.are.equal(
+ "MutatedUniqueJewel125AllocatedNotablePassiveSkillsInRadiusDoNothing",
+ map["Unnatural Instinct"]["AllocatedNonNotablesGrantNothingUnique__1_"])
+ assert.are.equal(
+ "MutatedUniqueJewel125GrantsAllBonusesOfUnallocatedNotablesInRadius",
+ map["Unnatural Instinct"]["GrantsStatsFromNonNotablesInRadiusUnique__1"])
+ assert.are.equal(
+ "MutatedUniqueJewel6KeystoneCanBeAllocatedInMassiveRadiusWithoutBeingConnected",
+ map["Intuitive Leap"]["JewelUniqueAllocateDisconnectedPassives"])
+ end)
+
+ it("accepts an injected map fixture and round-trips the mutation", function()
+ local originalModId, newModId = next(data.foulbornMap["Unnatural Instinct"])
+ local variants = RadiusJewelData.buildFoulbornVariants("Unnatural Instinct", nil, {
+ ["Unnatural Instinct"] = { [originalModId] = newModId },
+ })
+ assert.are.equal(1, #variants)
+ assert.are.same({ newModId }, variants[1].newModIds)
+
+ local imported = new("Item"):Item("Rarity: Unique\n" .. variants[1].rawText)
+ assert.is_true(imported.foulborn)
+ assert.is_true(hasMutatedMod(imported, newModId))
+ end)
+
+ it("returns no variants when a unique has no Foulborn mapping", function()
+ assert.are.equal(0, #RadiusJewelData.buildFoulbornVariants("Anatomical Knowledge"))
+ end)
+
+ it("builds every non-empty Unnatural Instinct mutation subset", function()
+ local variants = RadiusJewelData.buildFoulbornVariants("Unnatural Instinct")
+ assert.are.equal(3, #variants)
+
+ for _, variant in ipairs(variants) do
+ assert.is_true(variant.isFoulborn)
+ assert.is_true(#variant.newModIds >= 1)
+ assert.is_true(#variant.newModIds <= 2)
+ assert.is_string(variant.name)
+ assert.is_string(variant.rawText)
+
+ local imported = new("Item"):Item("Rarity: Unique\n" .. variant.rawText)
+ assert.is_true(imported.foulborn)
+ for _, newModId in ipairs(variant.newModIds) do
+ assert.is_true(hasMutatedMod(imported, newModId))
+ end
+ end
+ end)
+
+ it("scores each Unnatural Instinct Foulborn combination from its mutations", function()
+ local gainNotable = "MutatedUniqueJewel125GrantsAllBonusesOfUnallocatedNotablesInRadius"
+ local loseNotable = "MutatedUniqueJewel125AllocatedNotablePassiveSkillsInRadiusDoNothing"
+ local nodes = {
+ allocatedNormalA = { type = "Normal" },
+ allocatedNormalB = { type = "Normal" },
+ allocatedNotableA = { type = "Notable" },
+ allocatedNotableB = { type = "Notable" },
+ allocatedNotableC = { type = "Notable" },
+ allocatedNotableD = { type = "Notable" },
+ unallocatedNormalA = { type = "Normal" },
+ unallocatedNormalB = { type = "Normal" },
+ unallocatedNormalC = { type = "Normal" },
+ unallocatedNotableA = { type = "Notable" },
+ unallocatedNotableB = { type = "Notable" },
+ unallocatedNotableC = { type = "Notable" },
+ unallocatedNotableD = { type = "Notable" },
+ unallocatedNotableE = { type = "Notable" },
+ }
+ local allocNodes = {
+ allocatedNormalA = true,
+ allocatedNormalB = true,
+ allocatedNotableA = true,
+ allocatedNotableB = true,
+ allocatedNotableC = true,
+ allocatedNotableD = true,
+ }
+
+ for _, variant in ipairs(RadiusJewelData.buildFoulbornVariants("Unnatural Instinct")) do
+ local expectedScore
+ if hasMutation(variant, gainNotable) and hasMutation(variant, loseNotable) then
+ expectedScore = 1 -- 5 unallocated notables - 4 allocated notables
+ elseif hasMutation(variant, gainNotable) then
+ expectedScore = 3 -- 5 unallocated notables - 2 allocated small passives
+ else
+ expectedScore = -1 -- 3 unallocated small passives - 4 allocated notables
+ end
+ assert.are.equal(expectedScore, variant.score(nodes, allocNodes))
+ end
+ end)
+
+ it("uses the mapped Inspired Learning mutation and excludes Foulborn Might of the Meek", function()
+ local inspired = RadiusJewelData.buildFoulbornVariants("Inspired Learning")
+ assert.are.equal(1, #inspired)
+ assert.are.equal("alloc small passives", inspired[1].scoreLabel)
+ assert.are.equal(2, inspired[1].score({
+ allocatedNormalA = { type = "Normal" },
+ allocatedNormalB = { type = "Normal" },
+ unallocatedNotable = { type = "Notable" },
+ }, {
+ allocatedNormalA = true,
+ allocatedNormalB = true,
+ }))
+
+ assert.is_not_nil(data.foulbornMap["Might of the Meek"])
+ assert.are.equal(0, #RadiusJewelData.buildFoulbornVariants("Might of the Meek"))
+ end)
+
+ it("marks Foulborn Intuitive Leap as Massive Radius keystone-only in preview and compute", function()
+ local previousJewelRadius = data.jewelRadius
+ local previousMaxJewelRadius = data.maxJewelRadius
+ data.setJewelRadiiGlobally("3_29")
+ local variants = RadiusJewelData.buildFoulbornVariants("Intuitive Leap")
+ assert.are.equal(1, #variants)
+ local variant = variants[1]
+ assert.is_true(variant.isMassiveRadius)
+ assert.is_true(variant.keystoneOnly)
+ assert.are.same({ "Massive Radius", "Keystone Passive Skills only" }, variant.previewMeta)
+
+ local intuitiveLeap
+ for _, jewelType in ipairs(RadiusJewelData.buildJewelTypes()) do
+ if jewelType.name == "Intuitive Leap" then
+ intuitiveLeap = jewelType
+ break
+ end
+ end
+ assert.is_not_nil(intuitiveLeap)
+ local preview = intuitiveLeap.preview(variant)
+ local previewText = { }
+ for _, line in ipairs(preview) do
+ if line[1] then
+ previewText[#previewText + 1] = line[1]
+ end
+ end
+ assert.is_true(table.concat(previewText, "\n"):find("Massive Radius", 1, true) ~= nil)
+ assert.is_true(table.concat(previewText, "\n"):find("Keystone Passive Skills only", 1, true) ~= nil)
+
+ local finder = makeFinder()
+ local capturedOptions
+ local originalCollect = finder.compute.collectDisconnectedPassiveCandidates
+ function finder.compute:collectDisconnectedPassiveCandidates(socketNode, options)
+ capturedOptions = options
+ return { }
+ end
+ local sockets = finder:buildJewelSockets(getSmallRadiusIndex())
+ finder.compute:computeIntuitiveLeapSocketImpact({
+ sockets = { sockets[1] },
+ impactStat = "Life",
+ variant = variant,
+ methodId = "fast",
+ planCache = { },
+ occupiedMode = { id = "all" },
+ })
+ finder.compute.collectDisconnectedPassiveCandidates = originalCollect
+
+ assert.is_not_nil(capturedOptions)
+ assert.is_true(capturedOptions.keystoneOnly)
+
+ local massiveRadiusIndex = RadiusJewelData.getJewelRadiusIndex("Massive")
+ assert.is_not_nil(massiveRadiusIndex, "expected canonical Massive radius data")
+ assert.are.equal(2880, data.jewelRadius[massiveRadiusIndex].outer)
+ assert.are.equal(massiveRadiusIndex, variant.radiusIndex)
+ assert.are.equal(massiveRadiusIndex, capturedOptions.radiusIndex)
+ assert.is_nil(capturedOptions.collectNodes)
+ local massiveKeystone = { id = "foulbornMassiveKeystone", type = "Keystone" }
+ local syntheticSocket = {
+ nodesInRadius = {
+ [getSmallRadiusIndex()] = { normalPassive = { id = "normalPassive", type = "Normal" } },
+ [massiveRadiusIndex] = { foulbornMassiveKeystone = massiveKeystone },
+ },
+ }
+ local candidates = finder.compute:collectDisconnectedPassiveCandidates(syntheticSocket, capturedOptions)
+ assert.are.same({ massiveKeystone }, candidates)
+ data.jewelRadius = previousJewelRadius
+ data.maxJewelRadius = previousMaxJewelRadius
+ end)
+
+ it("compares Intuitive Leap normal and Foulborn variants while retaining the winner", function()
+ local intuitiveVariants
+ for _, jewelType in ipairs(RadiusJewelData.buildJewelTypes()) do
+ if jewelType.name == "Intuitive Leap" then
+ intuitiveVariants = jewelType.variants
+ break
+ end
+ end
+ assert.are.equal(2, #intuitiveVariants)
+
+ local finder = makeFinder()
+ local computedVariants = { }
+ function finder.compute:computeIntuitiveLeapSocketImpact(request)
+ computedVariants[#computedVariants + 1] = request.variant
+ return {
+ {
+ socket = request.sockets[1],
+ delta = request.variant.isFoulborn and 2 or 1,
+ addedNodeCount = 0,
+ },
+ }, 100
+ end
+ local results, baseline = finder.compute:computeBestIntuitiveLeapSocketImpact({
+ sockets = { { id = "testSocket" } },
+ impactStat = "Life",
+ variants = intuitiveVariants,
+ methodId = "fast",
+ planCache = { },
+ })
+ assert.are.equal(2, #computedVariants)
+ assert.are.equal(100, baseline)
+ assert.are.equal(1, #results)
+ assert.is_true(results[1].variant.isFoulborn)
+ assert.is_true(results[1].variant.rawText:find("{mutated}", 1, true) ~= nil)
+ end)
+
+ end)
+
+end)
diff --git a/spec/System/TestRadiusJewelFinder_spec.lua b/spec/System/TestRadiusJewelFinder_spec.lua
new file mode 100644
index 0000000000..689db254fc
--- /dev/null
+++ b/spec/System/TestRadiusJewelFinder_spec.lua
@@ -0,0 +1,1749 @@
+-- Popup and interaction tests for RadiusJewelFinder.
+
+local support = LoadModule("../spec/System/RadiusJewelFinderTestSupport.lua")
+local occVortex = support.occVortex
+local makeFinder = support.makeFinder
+local getLargeRadiusIndex = support.getLargeRadiusIndex
+local RadiusJewelData = support.RadiusJewelData
+describe("RadiusJewelFinder #radius-jewel", function()
+
+ before_each(function()
+ loadBuildFromXML(occVortex.xml, "OccVortex")
+ end)
+
+ -- ── buildJewelSockets ───────────────────────────────────────────────────
+
+ describe("buildJewelSockets", function()
+
+ it("returns a non-empty list", function()
+ local sockets = makeFinder():buildJewelSockets(getLargeRadiusIndex())
+ assert.is_true(#sockets > 0, "expected at least one jewel socket")
+ end)
+
+ it("each entry has id (number) and label (string)", function()
+ local sockets = makeFinder():buildJewelSockets(getLargeRadiusIndex())
+ for _, s in ipairs(sockets) do
+ assert.is_number(s.id)
+ assert.is_string(s.label)
+ end
+ end)
+
+ it("uses the standard zone labels for sockets without nearby Keystones", function()
+ local socketsById = { }
+ for _, socket in ipairs(makeFinder():buildJewelSockets(getLargeRadiusIndex())) do
+ socketsById[socket.id] = socket
+ end
+ for socketId, expectedLabel in pairs({ [26725] = "Marauder", [54127] = "Duelist", [7960] = "Templar/Witch" }) do
+ assert.matches("^" .. expectedLabel .. " %(" .. socketId .. "%)", socketsById[socketId].label)
+ end
+ end)
+
+ it("marks the 3 allocated sockets with # prefix", function()
+ local sockets = makeFinder():buildJewelSockets(getLargeRadiusIndex())
+ local allocIds = { [36634] = true, [61419] = true, [41263] = true }
+ for _, s in ipairs(sockets) do
+ if allocIds[s.id] then
+ assert.is_true(s.label:sub(1, 2) == "# ",
+ "socket " .. s.id .. " should start with '# ', was: " .. s.label)
+ end
+ end
+ end)
+
+ it("unallocated sockets without # prefix", function()
+ local sockets = makeFinder():buildJewelSockets(getLargeRadiusIndex())
+ local allocIds = { [36634] = true, [61419] = true, [41263] = true }
+ for _, s in ipairs(sockets) do
+ if not allocIds[s.id] then
+ assert.is_false(s.label:sub(1, 2) == "# ",
+ "socket " .. s.id .. " should NOT start with '# '")
+ end
+ end
+ end)
+
+ it("list is sorted alphabetically by label", function()
+ local sockets = makeFinder():buildJewelSockets(getLargeRadiusIndex())
+ for i = 2, #sockets do
+ assert.is_true(sockets[i - 1].label <= sockets[i].label,
+ "sockets not sorted at index " .. i)
+ end
+ end)
+
+ it("includes known occupied and empty sockets from the fixture build", function()
+ local sockets = makeFinder():buildJewelSockets(getLargeRadiusIndex())
+ local seenIds = {}
+ for _, socket in ipairs(sockets) do
+ seenIds[socket.id] = true
+ end
+
+ assert.is_true(seenIds[36634], "expected occupied socket 36634 to be present")
+ assert.is_true(seenIds[61419], "expected occupied socket 61419 to be present")
+ assert.is_true(seenIds[41263], "expected occupied socket 41263 to be present")
+ assert.is_true(seenIds[33631], "expected empty socket 33631 to be present")
+ end)
+
+ end)
+
+ describe("popup integration", function()
+ local previousJewelRadius
+ local previousMaxJewelRadius
+ local previousGetCursorPos
+ local syntheticAllocatedNodeIds
+ local syntheticRadiusRestores
+
+ before_each(function()
+ previousJewelRadius = data.jewelRadius
+ previousMaxJewelRadius = data.maxJewelRadius
+ previousGetCursorPos = GetCursorPos
+ syntheticAllocatedNodeIds = { }
+ syntheticRadiusRestores = { }
+ end)
+
+ after_each(function()
+ while main.popups[1] do
+ main:ClosePopup()
+ end
+ for index = #syntheticRadiusRestores, 1, -1 do
+ syntheticRadiusRestores[index]()
+ end
+ for _, nodeId in ipairs(syntheticAllocatedNodeIds) do
+ build.spec.allocNodes[nodeId] = nil
+ end
+ data.jewelRadius = previousJewelRadius
+ data.maxJewelRadius = previousMaxJewelRadius
+ GetCursorPos = previousGetCursorPos
+ end)
+
+ local function findControlIndex(list, needle)
+ for index, entry in ipairs(list) do
+ local label = type(entry) == "table" and entry.label or entry
+ if label == needle or (type(label) == "string" and label:find(needle, 1, true)) then
+ return index
+ end
+ end
+ end
+
+ local function runPopupCompute(popup)
+ popup.controls.computeButton:Click()
+ while main.onFrameFuncs["RadiusJewelFinderCompute"] do
+ runCallback("OnFrame")
+ end
+ end
+
+ local function getDropdownTooltipText(control, index)
+ local tooltip = new("Tooltip"):Tooltip()
+ control.tooltipFunc(tooltip, "DROP", index, control.list[index])
+ local lines = { }
+ for _, line in ipairs(tooltip.lines) do
+ table.insert(lines, line.text or "")
+ end
+ return table.concat(lines, "\n")
+ end
+
+ local function openResultContextTestPopup(yieldDuringCompute)
+ build.radiusJewelFinderState = nil
+ local finder = makeFinder()
+ local computeCompleted = false
+ finder.buildJewelSockets = function()
+ return { { id = 33631, label = "Synthetic socket", pathDist = 1 } }
+ end
+ finder.compute.computeBestIntuitiveLeapSocketImpact = function(_, request)
+ request.planCache["result-context-test"] = request.methodId
+ if yieldDuringCompute then
+ coroutine.yield()
+ end
+ computeCompleted = true
+ return {
+ {
+ socket = request.sockets[1],
+ variant = request.variants[1],
+ delta = 1,
+ addedNodeCount = 0,
+ baseOutput = { },
+ compareOutput = { },
+ },
+ }, 100
+ end
+ local popup = finder:Open()
+ popup.controls.jewelTypeSelect.selFunc(findControlIndex(popup.controls.jewelTypeSelect.list, "Intuitive Leap"))
+ return finder, popup, function() return computeCompleted end
+ end
+
+ local function findJewelType(name)
+ for _, jewelType in ipairs(RadiusJewelData.buildJewelTypes()) do
+ if jewelType.name == name then
+ return jewelType
+ end
+ end
+ end
+
+ local function setSyntheticRadiusNodes(treeNode, radiusIndices, nodeType, count, allocated)
+ local previousNodesInRadius = treeNode.nodesInRadius
+ local previousNodesByRadius = { }
+ for _, radiusIndex in ipairs(radiusIndices) do
+ previousNodesByRadius[radiusIndex] = previousNodesInRadius and previousNodesInRadius[radiusIndex] or false
+ end
+ table.insert(syntheticRadiusRestores, function()
+ if not previousNodesInRadius then
+ treeNode.nodesInRadius = nil
+ return
+ end
+ for _, radiusIndex in ipairs(radiusIndices) do
+ local previousNodes = previousNodesByRadius[radiusIndex]
+ previousNodesInRadius[radiusIndex] = previousNodes ~= false and previousNodes or nil
+ end
+ end)
+ local nodes = { }
+ for index = 1, count do
+ local nodeId = -(treeNode.id * 10 + index)
+ local node = {
+ id = nodeId,
+ name = "Synthetic " .. nodeType .. " " .. index,
+ type = nodeType,
+ }
+ nodes[nodeId] = node
+ if allocated then
+ build.spec.allocNodes[nodeId] = node
+ table.insert(syntheticAllocatedNodeIds, nodeId)
+ end
+ end
+ treeNode.nodesInRadius = treeNode.nodesInRadius or { }
+ for _, radiusIndex in ipairs(radiusIndices) do
+ treeNode.nodesInRadius[radiusIndex] = nodes
+ end
+ end
+
+ local function assertResultsCleared(popup, message)
+ assert.are.equal("message", popup.controls.resultsList.mode, message)
+ assert.are.equal(0, #popup.controls.resultsList.list, message)
+ assert.is_nil(popup.controls.resultsList.selIndex, message)
+ assert.is_false(popup.controls.applyButton.enabled(), message)
+ end
+
+ local function assertStaleResultsRemainVisible(popup, resultContextKey, expectedCount, expectedMode, message)
+ assert.are.equal(expectedMode, popup.controls.resultsList.mode, message)
+ assert.are.equal(expectedCount, #popup.controls.resultsList.list, message)
+ assert.are.equal(resultContextKey, popup.controls.resultsList.list[1].resultContextKey, message)
+ assert.is_not_nil(popup.controls.resultsList.selIndex, message)
+ assert.is_false(popup.controls.applyButton.enabled(), message)
+ end
+
+ local function detailText(popup)
+ local lines = { }
+ for _, line in ipairs(popup.controls.resultDetailList.list) do
+ table.insert(lines, line[1] or "")
+ end
+ return table.concat(lines, "\n")
+ end
+
+ local function countPlainText(text, needle)
+ local count = 0
+ local offset = 1
+ while true do
+ local startPos = text:find(needle, offset, true)
+ if not startPos then
+ return count
+ end
+ count = count + 1
+ offset = startPos + #needle
+ end
+ end
+
+ local function countDetailNodeLines(popup)
+ local count = 0
+ for _, line in ipairs(popup.controls.resultDetailList.list) do
+ if line.nodeId then
+ count = count + 1
+ end
+ end
+ return count
+ end
+
+ local function makeDetailRow(overrides)
+ local row = {
+ socketId = 36634,
+ socketLabel = "Worst-case occupied socket label",
+ variantLabel = "Long selected jewel variant label",
+ points = 2,
+ delta = 10,
+ pct = 10,
+ pctPerPoint = 5,
+ sortValue = 10,
+ detailText = "Worst-case dynamic detail summary",
+ resultNodes = {
+ { label = "Passive Alpha", nodeId = 36634 },
+ { label = "Passive Beta", nodeId = 61419 },
+ },
+ actionPlan = {
+ kind = "replace",
+ targetSocketAllocated = true,
+ sourceItemId = 999001,
+ sourceItemLabel = "Source jewel",
+ targetIdentity = { uniqueName = "Test jewel" },
+ replacedTargetId = build.itemsTab.sockets[36634].selItemId,
+ replacedTargetLabel = "Existing jewel with a long label",
+ },
+ }
+ for key, value in pairs(overrides or { }) do
+ row[key] = value
+ end
+ return row
+ end
+
+ it("drives rendering, sorting, and hover roles from each result mode schema", function()
+ build.radiusJewelFinderState = nil
+ local resultsList = makeFinder():Open().controls.resultsList
+ local rows = {
+ {
+ jewelName = "Zeta Jewel",
+ socketLabel = "Zeta socket",
+ points = 2,
+ delta = 1,
+ pct = 1,
+ pctPerPoint = 0.5,
+ sortValue = 0.5,
+ score = 1,
+ scorePerPoint = 0.5,
+ variantLabel = "Large",
+ detailText = "Zeta detail",
+ action = "move",
+ baseOutput = { },
+ compareOutput = { },
+ itemTooltipLines = { { height = 16, [1] = "Zeta preview" } },
+ },
+ {
+ jewelName = "Alpha Jewel",
+ socketLabel = "Alpha socket",
+ points = 1,
+ delta = 2,
+ pct = 2,
+ pctPerPoint = 2,
+ sortValue = 2,
+ score = 2,
+ scorePerPoint = 2,
+ variantLabel = "Small",
+ detailText = "Alpha detail",
+ action = "equip",
+ baseOutput = { },
+ compareOutput = { },
+ itemTooltipLines = { { height = 16, [1] = "Alpha preview" } },
+ },
+ }
+ local modeCases = {
+ { mode = "computeSocket", sortColumn = 3, expectedRow = rows[2], valueColumn = 3,
+ expectedValue = "^2+2.0", socketColumn = 1, statColumn = 3, detailColumn = 6 },
+ { mode = "computeSocketAll", sortColumn = 1, expectedRow = rows[2], valueColumn = 1,
+ expectedValue = "Alpha Jewel", socketColumn = 2, statColumn = 4, detailColumn = 7 },
+ { mode = "find", sortColumn = 3, expectedRow = rows[2], valueColumn = 3,
+ expectedValue = "^72", socketColumn = 1, detailColumn = 5 },
+ { mode = "findThread", sortColumn = 5, expectedRow = rows[1], valueColumn = 5,
+ expectedValue = "Large", socketColumn = 1, detailColumn = 6 },
+ }
+
+ for _, case in ipairs(modeCases) do
+ local modeRows = { rows[1], rows[2] }
+ resultsList:SetMode(case.mode, modeRows, "")
+ resultsList:ReSort(case.sortColumn)
+ assert.are.equal(case.expectedRow, modeRows[1], case.mode .. " sort should follow its column descriptor")
+ assert.are.equal(case.expectedValue, resultsList:GetRowValue(case.valueColumn, 1, modeRows[1]),
+ case.mode .. " rendering should follow its column descriptor")
+ assert.is_true(resultsList:GetHoverInfo(case.socketColumn, modeRows[1]).showViewer,
+ case.mode .. " socket column should show the passive viewer")
+ assert.is_true(resultsList:GetHoverInfo(case.detailColumn, modeRows[1]).showItemTooltip,
+ case.mode .. " detail column should show the item preview")
+ if case.statColumn then
+ assert.is_true(resultsList:GetHoverInfo(case.statColumn, modeRows[1]).showStatTooltip,
+ case.mode .. " stat column should show the stat comparison")
+ end
+ end
+ end)
+
+ it("uses full-height Details without changing Results", function()
+ build.radiusJewelFinderState = nil
+ local popup = makeFinder():Open()
+ local popupWidth, popupHeight = popup:GetSize()
+ assert.are.equal(1020, popupWidth)
+ assert.are.equal(474, popupHeight)
+ assert.is_nil(popup.controls.previewList)
+ assert.is_nil(popup.controls.resultPassivesButton)
+
+ local resultsWidth, resultsHeight = popup.controls.resultsList:GetSize()
+ assert.are.equal(580, resultsWidth)
+ assert.are.equal(352, resultsHeight)
+ local _, detailsHeight = popup.controls.resultDetailList:GetSize()
+ assert.are.equal(334, detailsHeight)
+ assert.are.equal("", popup.controls.resultsList.defaultText,
+ "the status line should not be repeated inside empty Results")
+ end)
+
+ it("keeps a long Search error once in Results with a short status", function()
+ build.radiusJewelFinderState = nil
+ local finder = makeFinder()
+ local popup = finder:Open()
+ popup.controls.jewelTypeSelect.selFunc(findControlIndex(popup.controls.jewelTypeSelect.list, "Might of the Meek"))
+ local searchError = "synthetic search failure with diagnostic context beyond the status width"
+ finder.socketMatchesOccupiedMode = function()
+ error(searchError)
+ end
+
+ popup.controls.findButton:Click()
+
+ assert.are.equal("^1Search failed", popup.controls.statusLabel.label)
+ assert.are.equal("message", popup.controls.resultsList.mode)
+ assert.are.equal("", popup.controls.resultsList.defaultText)
+ assert.are.equal(1, #popup.controls.resultsList.list)
+ local resultError = popup.controls.resultsList.list[1].text
+ assert.are.equal(1, countPlainText(resultError, searchError))
+ assert.are.equal(1, countPlainText(popup.controls.statusLabel.label .. resultError, searchError),
+ "the detailed Search error should appear exactly once across status and Results")
+ end)
+
+ it("keeps a long Compute error once in Results with a short status", function()
+ build.radiusJewelFinderState = nil
+ local finder = makeFinder()
+ local popup = finder:Open()
+ popup.controls.jewelTypeSelect.selFunc(findControlIndex(popup.controls.jewelTypeSelect.list, "Might of the Meek"))
+ local computeError = "synthetic compute failure with diagnostic context beyond the status width"
+ finder.compute.computeSocketImpact = function()
+ error(computeError)
+ end
+
+ runPopupCompute(popup)
+
+ assert.are.equal("^1Compute failed", popup.controls.statusLabel.label)
+ assert.are.equal("message", popup.controls.resultsList.mode)
+ assert.are.equal("", popup.controls.resultsList.defaultText)
+ assert.are.equal(1, #popup.controls.resultsList.list)
+ local resultError = popup.controls.resultsList.list[1].text
+ assert.are.equal(1, countPlainText(resultError, computeError))
+ assert.are.equal(1, countPlainText(popup.controls.statusLabel.label .. resultError, computeError),
+ "the detailed Compute error should appear exactly once across status and Results")
+ end)
+
+ it("shows a computed variant once under Variant in Details", function()
+ build.radiusJewelFinderState = nil
+ local finder = makeFinder()
+ finder.buildJewelSockets = function()
+ return { { id = 33631, label = "Synthetic socket", pathDist = 1 } }
+ end
+ finder.compute.computeBestVariantSocketImpact = function(_, request)
+ return {
+ {
+ socket = request.sockets[1],
+ variant = request.variants[1],
+ delta = 10,
+ addedNodeCount = 0,
+ baseOutput = { },
+ compareOutput = { },
+ },
+ }, 100
+ end
+ local popup = finder:Open()
+ popup.controls.jewelTypeSelect.selFunc(findControlIndex(popup.controls.jewelTypeSelect.list, "The Light of Meaning"))
+ popup.controls.jewelVariantSelect.selFunc(findControlIndex(popup.controls.jewelVariantSelect.list, "Armour"))
+
+ runPopupCompute(popup)
+
+ local row = popup.controls.resultsList.list[1]
+ assert.are.equal("Armour", row.detailText,
+ "Results Detail should keep the general summary")
+ assert.are.equal("Armour", row.variantLabel,
+ "the computed variant should remain available to Details")
+ local text = detailText(popup)
+ assert.is_true(text:find("Variant: Armour", 1, true) ~= nil)
+ assert.are.equal(1, countPlainText(text, "Armour"),
+ "Details should not repeat the variant as a generic detail line")
+ end)
+
+ it("builds stable Details hover tooltips only once", function()
+ build.radiusJewelFinderState = nil
+ local popup = makeFinder():Open()
+ local control = popup.controls.resultDetailList
+ local viewPort = { x = 0, y = 0, width = 1024, height = 768 }
+ GetCursorPos = function() return 620, 150 end
+ local secondPopup = main.popups[2]
+ main.popups[2] = nil
+
+ local item = build.itemsTab.items[build.itemsTab.sockets[36634].selItemId]
+ assert.is_not_nil(item)
+ local itemLine = { height = 16, [1] = "Replacement", item = item }
+ control.GetHoverLine = function() return itemLine end
+ local itemTooltipBuilds = 0
+ local originalAddItemTooltip = build.itemsTab.AddItemTooltip
+ build.itemsTab.AddItemTooltip = function(_, tooltip)
+ itemTooltipBuilds = itemTooltipBuilds + 1
+ tooltip:AddLine(16, "Item tooltip")
+ end
+ control:Draw(viewPort)
+ control:Draw(viewPort)
+ build.itemsTab.AddItemTooltip = originalAddItemTooltip
+
+ local node = build.spec.nodes[33631] or build.spec.tree.nodes[33631]
+ assert.is_not_nil(node)
+ local nodeLine = { height = 16, [1] = "Passive", nodeId = node.id }
+ control.GetHoverLine = function() return nodeLine end
+ local nodeTooltipBuilds = 0
+ local originalViewerDraw = control.socketViewer.Draw
+ local originalAddNodeTooltip = control.socketViewer.AddNodeTooltip
+ control.socketViewer.Draw = function() end
+ control.socketViewer.AddNodeTooltip = function(_, tooltip)
+ nodeTooltipBuilds = nodeTooltipBuilds + 1
+ tooltip:AddLine(16, "Node tooltip")
+ end
+ control:Draw(viewPort)
+ control:Draw(viewPort)
+ control.socketViewer.Draw = originalViewerDraw
+ control.socketViewer.AddNodeTooltip = originalAddNodeTooltip
+ main.popups[2] = secondPopup
+ assert.are.equal(1, itemTooltipBuilds,
+ "an unchanged item hover should reuse its tooltip between frames")
+ assert.are.equal(1, nodeTooltipBuilds,
+ "an unchanged passive hover should reuse its tooltip between frames")
+ end)
+
+ it("shows fact-only Details and passive rows immediately", function()
+ build.radiusJewelFinderState = nil
+ local popup = makeFinder():Open()
+ popup.controls.jewelTypeSelect.selFunc(findControlIndex(popup.controls.jewelTypeSelect.list, "Intuitive Leap"))
+ local firstRow = makeDetailRow()
+ local secondRow = makeDetailRow({
+ socketId = 33631,
+ socketLabel = "Second socket",
+ resultNodes = { { label = "Passive Gamma", nodeId = 33631 } },
+ })
+ popup.controls.resultsList:SetMode("computeSocket", { firstRow, secondRow }, "")
+
+ local text = detailText(popup)
+ for _, expected in ipairs({
+ "Jewel:",
+ "Test jewel",
+ "Variant: Long selected jewel variant label",
+ "Socket: Worst-case occupied socket label",
+ "Current location:",
+ "Items",
+ "Will replace:",
+ "Existing jewel with a long label",
+ "Worst-case dynamic detail summary",
+ "Recommended passives (2):",
+ "Passive Alpha",
+ "Passive Beta",
+ }) do
+ assert.is_true(text:find(expected, 1, true) ~= nil, "expected detail: " .. expected)
+ end
+ for _, redundant in ipairs({
+ "Use occupied socket",
+ "Use free socket",
+ "Move equipped jewel",
+ "Already equipped",
+ "This socket is unallocated",
+ "Passive allocations are not applied automatically",
+ "Source:",
+ "New Test jewel",
+ }) do
+ assert.is_nil(text:find(redundant, 1, true), "unexpected Details text: " .. redundant)
+ end
+ local jewelPos = assert(text:find("Jewel:", 1, true))
+ local variantPos = assert(text:find("Variant: Long selected jewel variant label", 1, true))
+ local socketPos = assert(text:find("Socket: Worst-case occupied socket label", 1, true))
+ local locationPos = assert(text:find("Current location:", 1, true))
+ assert.is_true(jewelPos < variantPos and variantPos < socketPos and socketPos < locationPos,
+ "Details should read Jewel, Variant, Socket, then Current location")
+ assert.are.equal(2, countDetailNodeLines(popup))
+
+ local replacementLine
+ for _, line in ipairs(popup.controls.resultDetailList.list) do
+ if line[1] and line[1]:find("Will replace", 1, true) then
+ replacementLine = line
+ break
+ end
+ end
+ assert.is_not_nil(replacementLine)
+ assert.is_not_nil(replacementLine.item, "replacement item tooltip should remain available")
+
+ popup.controls.resultDetailList.controls.scrollBar.offset = 80
+ popup.controls.resultsList:SelectIndex(2)
+ assert.are.equal(0, popup.controls.resultDetailList.controls.scrollBar.offset)
+ assert.are.equal(1, countDetailNodeLines(popup))
+ assert.is_true(detailText(popup):find("Passive Gamma", 1, true) ~= nil)
+ for _, line in ipairs(popup.controls.resultDetailList.list) do
+ if line.nodeId then
+ assert.is_not_nil(build.spec.nodes[line.nodeId] or build.spec.tree.nodes[line.nodeId],
+ "passive lines should resolve to nodes for their tooltips")
+ end
+ end
+ end)
+
+ it("uses precise zero-passive labels", function()
+ build.radiusJewelFinderState = nil
+ local popup = makeFinder():Open()
+ popup.controls.resultsList:SetMode("computeSocket", { makeDetailRow({ resultNodes = { } }) }, "")
+ assert.is_true(detailText(popup):find("No recommended passives", 1, true) ~= nil)
+
+ local findRow = makeDetailRow({ topNodes = { } })
+ findRow.resultNodes = nil
+ popup.controls.resultsList:SetMode("find", { findRow }, "")
+ assert.is_true(detailText(popup):find("No notables or keystones in range", 1, true) ~= nil)
+ end)
+
+ it("omits a Detail summary already shown by Variant and recommended passives", function()
+ build.radiusJewelFinderState = nil
+ local popup = makeFinder():Open()
+ popup.controls.resultsList:SetMode("computeSocket", { makeDetailRow({
+ variantLabel = "Normal",
+ detailText = "Normal | 2 nodes",
+ }) }, "")
+
+ local text = detailText(popup)
+ assert.is_true(text:find("Variant: Normal", 1, true) ~= nil)
+ assert.is_true(text:find("Recommended passives (2):", 1, true) ~= nil)
+ assert.is_nil(text:find("Normal | 2 nodes", 1, true),
+ "Details should not repeat the Results summary")
+ end)
+
+ it("keeps Split Personality distance ranking without a passive block", function()
+ build.radiusJewelFinderState = nil
+ local finder = makeFinder()
+ finder.buildJewelSockets = function()
+ return {
+ { id = 33631, label = "Near split socket", pathDist = 1, classStartDist = 3 },
+ { id = 54127, label = "Far split socket", pathDist = 1, classStartDist = 9 },
+ }
+ end
+ local popup = finder:Open()
+ popup.controls.jewelTypeSelect.selFunc(findControlIndex(popup.controls.jewelTypeSelect.list, "Split Personality"))
+ popup.controls.jewelVariantSelect.selFunc(2)
+ popup.controls.findButton:Click()
+
+ local row = popup.controls.resultsList.list[1]
+ assert.are.equal("Far split socket", row.socketLabel)
+ assert.are.equal("dist to start 9", row.detailText)
+ assert.is_nil(row.topNodes)
+ local text = detailText(popup)
+ assert.is_true(text:find("dist to start 9", 1, true) ~= nil)
+ assert.is_nil(text:find("passive", 1, true))
+ assert.is_nil(text:find("in range", 1, true))
+ end)
+
+ it("dispatches every jewel strategy to its compute owner", function()
+ build.radiusJewelFinderState = nil
+ local finder = makeFinder()
+ local calls = { }
+ local computeMethods = {
+ "computeSocketImpact",
+ "computeBestVariantSocketImpact",
+ "computeBestIntuitiveLeapSocketImpact",
+ "computeThreadOfHopeSocketImpact",
+ "computeImpossibleEscapeSocketImpact",
+ "computeSplitPersonalitySocketImpact",
+ }
+ for _, methodName in ipairs(computeMethods) do
+ local capturedMethodName = methodName
+ finder.compute[capturedMethodName] = function(_, request)
+ table.insert(calls, { methodName = capturedMethodName, request = request })
+ return { }, 100
+ end
+ end
+
+ local popup = finder:Open()
+ local cases = {
+ { jewelType = "Might of the Meek", methodName = "computeSocketImpact", field = "rawText" },
+ { jewelType = "The Light of Meaning", methodName = "computeBestVariantSocketImpact", field = "variants" },
+ { jewelType = "Intuitive Leap", methodName = "computeBestIntuitiveLeapSocketImpact", field = "variants" },
+ { jewelType = "Thread of Hope", methodName = "computeThreadOfHopeSocketImpact", field = "variants" },
+ { jewelType = "Impossible Escape", methodName = "computeImpossibleEscapeSocketImpact", field = "variants" },
+ { jewelType = "Split Personality", methodName = "computeSplitPersonalitySocketImpact", field = "variants" },
+ }
+ for _, case in ipairs(cases) do
+ calls = { }
+ popup.controls.jewelTypeSelect.selFunc(findControlIndex(popup.controls.jewelTypeSelect.list, case.jewelType))
+ runPopupCompute(popup)
+ assert.is_true(#calls > 0, "expected a compute call for " .. case.jewelType)
+ for _, call in ipairs(calls) do
+ assert.are.equal(case.methodName, call.methodName, "unexpected compute owner for " .. case.jewelType)
+ assert.is_not_nil(call.request[case.field], "missing " .. case.field .. " for " .. case.jewelType)
+ end
+ end
+
+ calls = { }
+ popup.controls.jewelTypeSelect.selFunc(findControlIndex(popup.controls.jewelTypeSelect.list, "All jewels"))
+ runPopupCompute(popup)
+ local seenMethods = { }
+ local expandedPlanMethods = {
+ computeBestIntuitiveLeapSocketImpact = true,
+ computeThreadOfHopeSocketImpact = true,
+ computeImpossibleEscapeSocketImpact = true,
+ }
+ for _, call in ipairs(calls) do
+ seenMethods[call.methodName] = true
+ if expandedPlanMethods[call.methodName] then
+ assert.is_true(call.request.skipPlanSteps, "All jewels should skip expanded plan steps")
+ end
+ end
+ for _, methodName in ipairs(computeMethods) do
+ assert.is_true(seenMethods[methodName], "All jewels did not dispatch " .. methodName)
+ end
+ end)
+
+ it("uses the canonical Massive radius for Foulborn Intuitive Leap Find", function()
+ data.setJewelRadiiGlobally("3_29")
+ local massiveRadiusIndex = RadiusJewelData.getJewelRadiusIndex("Massive")
+ local syntheticSocketId = 990001
+ local syntheticKeystone = { id = 990002, type = "Keystone", name = "Synthetic Keystone" }
+ build.spec.tree.nodes[syntheticSocketId] = {
+ id = syntheticSocketId,
+ nodesInRadius = {
+ [massiveRadiusIndex] = { [syntheticKeystone.id] = syntheticKeystone },
+ },
+ }
+ local finder = makeFinder()
+ finder.buildJewelSockets = function()
+ return { { id = syntheticSocketId, label = "Synthetic socket", pathDist = 1 } }
+ end
+ local popup = finder:Open()
+ local function findIndex(list, needle)
+ for index, entry in ipairs(list) do
+ local label = type(entry) == "table" and entry.label or entry
+ if label == needle or (type(label) == "string" and label:find(needle, 1, true)) then
+ return index
+ end
+ end
+ end
+
+ popup.controls.jewelTypeSelect.selFunc(findIndex(popup.controls.jewelTypeSelect.list, "Intuitive Leap"))
+ popup.controls.jewelVariantSelect.selFunc(findIndex(popup.controls.jewelVariantSelect.list, "Foulborn:"))
+ popup.controls.findButton:Click()
+
+ assert.are.equal(1, #popup.controls.resultsList.list)
+ assert.are.equal(1, popup.controls.resultsList.list[1].score)
+ end)
+
+ it("enables Apply for Thread of Hope Find and Compute results", function()
+ local targetSocketId = 33631
+ local finder = makeFinder()
+ finder.buildJewelSockets = function()
+ return { { id = targetSocketId, label = "Target socket", pathDist = 1 } }
+ end
+ local popup = finder:Open()
+ local function findIndex(list, needle)
+ for index, entry in ipairs(list) do
+ local label = type(entry) == "table" and entry.label or entry
+ if label == needle then
+ return index
+ end
+ end
+ end
+
+ popup.controls.jewelTypeSelect.selFunc(findIndex(popup.controls.jewelTypeSelect.list, "Thread of Hope"))
+ popup.controls.findButton:Click()
+ assert.are.equal(1, #popup.controls.resultsList.list)
+ assert.is_not_nil(popup.controls.resultsList.list[1].actionPlan,
+ "Find rows should consume the shared action planner")
+ assert.matches("^Thread of Hope\n", popup.controls.resultsList.list[1].actionPlan.targetRawText)
+ popup.controls.resultsList.selIndex = 1
+ assert.is_true(popup.controls.applyButton.enabled())
+
+ finder.compute.computeThreadOfHopeSocketImpact = function(_, request)
+ return {
+ {
+ socket = request.sockets[1],
+ variant = request.variants[1],
+ delta = 1,
+ addedNodeCount = 0,
+ baseOutput = { },
+ compareOutput = { },
+ },
+ }, 100
+ end
+ popup.controls.computeButton:Click()
+ while main.onFrameFuncs["RadiusJewelFinderCompute"] do
+ runCallback("OnFrame")
+ end
+ assert.are.equal(1, #popup.controls.resultsList.list)
+ assert.is_not_nil(popup.controls.resultsList.list[1].actionPlan,
+ "Compute rows should consume the shared action planner")
+ assert.matches("^Thread of Hope\n", popup.controls.resultsList.list[1].actionPlan.targetRawText)
+ popup.controls.resultsList.selIndex = 1
+ assert.is_true(popup.controls.applyButton.enabled())
+
+ end)
+
+ it("keeps stale results visible and blocks Apply when criteria change", function()
+ local _, popup = openResultContextTestPopup()
+ local criteriaChangedMessage = "^xFFAA33Criteria changed. ^8Run Find or Compute again."
+ local computeOnlyCriteriaChangedMessage = "^xFFAA33Criteria changed. ^8Run Compute again."
+ local intuitiveLeapIndex = findControlIndex(popup.controls.jewelTypeSelect.list, "Intuitive Leap")
+ local threadOfHopeIndex = findControlIndex(popup.controls.jewelTypeSelect.list, "Thread of Hope")
+
+ local changes = {
+ {
+ name = "variant",
+ change = function() popup.controls.jewelVariantSelect.selFunc(2) end,
+ restore = function() popup.controls.jewelVariantSelect.selFunc(1) end,
+ message = criteriaChangedMessage,
+ },
+ {
+ name = "impact stat",
+ change = function() popup.controls.impactStatSelect.selFunc(2) end,
+ restore = function() popup.controls.impactStatSelect.selFunc(1) end,
+ message = computeOnlyCriteriaChangedMessage,
+ },
+ {
+ name = "compute method",
+ change = function() popup.controls.computeMethodSelect.selFunc(2) end,
+ restore = function() popup.controls.computeMethodSelect.selFunc(1) end,
+ message = computeOnlyCriteriaChangedMessage,
+ },
+ {
+ name = "max points",
+ change = function() popup.controls.maxPointsEdit:SetText("21", true) end,
+ restore = function() popup.controls.maxPointsEdit:SetText("20", true) end,
+ message = computeOnlyCriteriaChangedMessage,
+ },
+ {
+ name = "occupied sockets",
+ change = function() popup.controls.occupiedModeSelect.selFunc(2) end,
+ restore = function() popup.controls.occupiedModeSelect.selFunc(1) end,
+ message = computeOnlyCriteriaChangedMessage,
+ },
+ {
+ name = "jewel type",
+ change = function() popup.controls.jewelTypeSelect.selFunc(threadOfHopeIndex) end,
+ restore = function() popup.controls.jewelTypeSelect.selFunc(intuitiveLeapIndex) end,
+ message = criteriaChangedMessage,
+ },
+ }
+ for _, criterion in ipairs(changes) do
+ runPopupCompute(popup)
+ local staleRow = popup.controls.resultsList.list[1]
+ local resultMode = popup.controls.resultsList.mode
+ assert.is_not_nil(staleRow)
+ criterion.change()
+ assertStaleResultsRemainVisible(popup, staleRow.resultContextKey, 1, resultMode,
+ criterion.name .. " should keep the previous results visible but stale")
+ assert.are.equal(criterion.message, popup.controls.statusLabel.label,
+ criterion.name .. " should explain how to refresh results")
+ assert.is_nil(main.onFrameFuncs["RadiusJewelFinderCompute"],
+ criterion.name .. " should not start Compute automatically")
+ local beforeApply = support.snapshotFinderState()
+ popup.controls.resultsList.OnSelClick(popup.controls.resultsList, 1, staleRow, true)
+ support.assertFinderStateUnchanged(beforeApply, assert)
+ criterion.restore()
+ end
+ end)
+
+ it("filters standard Find by Points and keeps Score independent", function()
+ build.radiusJewelFinderState = nil
+ local jewelType = findJewelType("Might of the Meek")
+ assert.is_not_nil(jewelType)
+ setSyntheticRadiusNodes(build.spec.tree.nodes[36634], { jewelType.radiusIndex }, "Normal", 3, true)
+ setSyntheticRadiusNodes(build.spec.tree.nodes[33631], { jewelType.radiusIndex }, "Normal", 3, true)
+
+ local finder = makeFinder()
+ finder.buildJewelSockets = function()
+ return {
+ { id = 36634, label = "Occupied zero-cost socket", pathDist = 9 },
+ { id = 33631, label = "Within Max points socket", pathDist = 1 },
+ { id = 33631, label = "Above Max points socket", pathDist = 2 },
+ }
+ end
+ local popup = finder:Open()
+ popup.controls.jewelTypeSelect.selFunc(findControlIndex(popup.controls.jewelTypeSelect.list, "Might of the Meek"))
+ popup.controls.occupiedModeSelect.selFunc(3)
+ popup.controls.maxPointsEdit:SetText("1", true)
+ popup.controls.findButton:Click()
+
+ assert.are.equal(2, #popup.controls.resultsList.list)
+ local maxPointsOneContextKey = popup.controls.resultsList.list[1].resultContextKey
+ local sawZeroCost = false
+ for _, row in ipairs(popup.controls.resultsList.list) do
+ assert.is_true(row.points <= 1, "Find returned a row above Max points")
+ assert.are.equal(3, row.score)
+ assert.is_true(row.score > 1, "Score should not be capped by Max points")
+ sawZeroCost = sawZeroCost or row.points == 0
+ end
+ assert.is_true(sawZeroCost, "expected the occupied zero-cost socket to remain eligible")
+ assert.matches("2 results", popup.controls.statusLabel.label, 1, true)
+
+ popup.controls.maxPointsEdit:SetText("0", true)
+ popup.controls.findButton:Click()
+ assert.are.equal(1, #popup.controls.resultsList.list)
+ assert.are.equal(0, popup.controls.resultsList.list[1].points)
+
+ popup.controls.maxPointsEdit:SetText("1", true)
+ popup.controls.findButton:Click()
+ assert.are.equal(2, #popup.controls.resultsList.list)
+ assert.are.equal(maxPointsOneContextKey, popup.controls.resultsList.list[1].resultContextKey)
+ end)
+
+ it("applies Max points to Thread Find including zero-result searches", function()
+ build.radiusJewelFinderState = nil
+ local socketId = 33631
+ local radiusIndices = { }
+ for _, variant in ipairs(RadiusJewelData.getThreadOfHopeVariants()) do
+ table.insert(radiusIndices, variant.radiusIndex)
+ end
+ setSyntheticRadiusNodes(build.spec.tree.nodes[socketId], radiusIndices, "Notable", 4, false)
+ local finder = makeFinder()
+ finder.buildJewelSockets = function()
+ return { { id = socketId, label = "Thread Max points socket", pathDist = 2 } }
+ end
+ local popup = finder:Open()
+ popup.controls.jewelTypeSelect.selFunc(findControlIndex(popup.controls.jewelTypeSelect.list, "Thread of Hope"))
+ popup.controls.maxPointsEdit:SetText("1", true)
+ popup.controls.findButton:Click()
+
+ assert.are.equal("findThread", popup.controls.resultsList.mode)
+ assert.are.equal(0, #popup.controls.resultsList.list)
+
+ popup.controls.maxPointsEdit:SetText("2", true)
+ popup.controls.findButton:Click()
+ assert.are.equal(1, #popup.controls.resultsList.list)
+ assert.are.equal(2, popup.controls.resultsList.list[1].points)
+ assert.is_true(popup.controls.resultsList.list[1].score > 0)
+ end)
+
+ it("applies Max points to Impossible Escape Find", function()
+ build.radiusJewelFinderState = nil
+ local jewelType = findJewelType("Impossible Escape")
+ local variant = jewelType and jewelType.variants[1]
+ local keystoneNode = variant and build.spec.tree.keystoneMap[variant.keystoneName]
+ assert.is_not_nil(keystoneNode)
+ setSyntheticRadiusNodes(keystoneNode, { RadiusJewelData.getJewelRadiusIndex("Small") }, "Notable", 4, false)
+ local finder = makeFinder()
+ finder.buildJewelSockets = function()
+ return { { id = 33631, label = "Impossible Escape Max points socket", pathDist = 3 } }
+ end
+ local popup = finder:Open()
+ popup.controls.jewelTypeSelect.selFunc(findControlIndex(popup.controls.jewelTypeSelect.list, "Impossible Escape"))
+ popup.controls.maxPointsEdit:SetText("2", true)
+ popup.controls.findButton:Click()
+
+ assert.are.equal("find", popup.controls.resultsList.mode)
+ assert.are.equal(0, #popup.controls.resultsList.list)
+
+ popup.controls.maxPointsEdit:SetText("3", true)
+ popup.controls.findButton:Click()
+ assert.are.equal(1, #popup.controls.resultsList.list)
+ assert.are.equal(3, popup.controls.resultsList.list[1].points)
+ end)
+
+ it("keeps Find discoverable when an exact variant is required", function()
+ build.radiusJewelFinderState = nil
+ local finder = makeFinder()
+ local popup = finder:Open()
+ local computeOnlyCriteriaChangedMessage = "^xFFAA33Criteria changed. ^8Run Compute again."
+
+ local function tooltipText(control, mode, index)
+ local tooltip = new("Tooltip"):Tooltip()
+ control.tooltipFunc(tooltip, mode, index, index and control.list and control.list[index])
+ local texts = { }
+ for _, line in ipairs(tooltip.lines) do
+ if line.text and line.text ~= "" then
+ texts[#texts + 1] = line.text
+ end
+ end
+ return table.concat(texts, "\n")
+ end
+
+ for _, jewelTypeName in ipairs({
+ "Intuitive Leap",
+ "Dreams & Nightmares",
+ "Tempered & Transcendent",
+ "Split Personality",
+ }) do
+ popup.controls.jewelTypeSelect.selFunc(findControlIndex(popup.controls.jewelTypeSelect.list, jewelTypeName))
+ assert.is_true(popup.controls.findButton:IsShown(), jewelTypeName .. " should keep Find visible")
+ assert.is_false(popup.controls.findButton:IsEnabled(), jewelTypeName .. " should require an exact variant")
+ assert.are.equal(computeOnlyCriteriaChangedMessage, popup.controls.statusLabel.label)
+ local statusBeforeClick = popup.controls.statusLabel.label
+ popup.controls.findButton:Click()
+ assert.are.equal(statusBeforeClick, popup.controls.statusLabel.label,
+ "disabled Find should not start a search")
+ end
+
+ local allVariantsTooltip = tooltipText(popup.controls.jewelVariantSelect, "DROP", 1)
+ assert.matches("Find ranks sockets for one exact variant.", allVariantsTooltip, 1, true)
+ assert.matches("Compute to compare the displayed variants by the selected stat.", allVariantsTooltip, 1, true)
+
+ popup.controls.jewelTypeSelect.selFunc(findControlIndex(popup.controls.jewelTypeSelect.list, "Dreams & Nightmares"))
+ popup.controls.variantGroupSelect.selFunc(2)
+ assert.is_false(popup.controls.findButton:IsEnabled(), "a filtered All variants selection should still require one variant")
+ popup.controls.jewelVariantSelect.selFunc(2)
+ assert.is_true(popup.controls.findButton:IsEnabled(), "an exact grouped variant should enable Find")
+
+ popup.controls.jewelTypeSelect.selFunc(findControlIndex(popup.controls.jewelTypeSelect.list, "Impossible Escape"))
+ assert.is_true(popup.controls.findButton:IsShown())
+ assert.is_true(popup.controls.findButton:IsEnabled(), "Impossible Escape should keep its All variants Find contract")
+ assert.matches("every displayed Keystone variant", tooltipText(popup.controls.jewelVariantSelect, "DROP", 1), 1, true)
+
+ popup.controls.jewelTypeSelect.selFunc(findControlIndex(popup.controls.jewelTypeSelect.list, "Thread of Hope"))
+ assert.is_true(popup.controls.findButton:IsShown())
+ assert.is_true(popup.controls.findButton:IsEnabled(), "Thread should keep its Any ring Find contract")
+ assert.matches("every ring", tooltipText(popup.controls.findButton), 1, true)
+
+ popup.controls.jewelTypeSelect.selFunc(findControlIndex(popup.controls.jewelTypeSelect.list, "All jewels"))
+ assert.is_false(popup.controls.findButton:IsShown(), "All jewels should remain Compute-only")
+
+ popup.controls.jewelTypeSelect.selFunc(findControlIndex(popup.controls.jewelTypeSelect.list, "Tempered & Transcendent"))
+ popup.controls.closeButton:Click()
+ local reopenedPopup = finder:Open()
+ assert.is_true(reopenedPopup.controls.findButton:IsShown())
+ assert.is_false(reopenedPopup.controls.findButton:IsEnabled())
+ assert.are.equal("^8Select a variant for Find, or click Compute", reopenedPopup.controls.statusLabel.label)
+ end)
+
+ it("tracks grouped variants and the legacy All jewels option in result identity", function()
+ build.radiusJewelFinderState = nil
+ local finder = makeFinder()
+ finder.buildJewelSockets = function()
+ return { { id = 33631, label = "Synthetic socket", pathDist = 1 } }
+ end
+ finder.compute.computeBestVariantSocketImpact = function(_, request)
+ return {
+ {
+ socket = request.sockets[1],
+ variant = request.variants[1],
+ delta = 1,
+ baseOutput = { },
+ compareOutput = { },
+ },
+ }, 100
+ end
+ finder.compute.computeSocketImpact = function() return { }, 100 end
+ finder.compute.computeBestIntuitiveLeapSocketImpact = function() return { }, 100 end
+ finder.compute.computeThreadOfHopeSocketImpact = function() return { }, 100 end
+ finder.compute.computeImpossibleEscapeSocketImpact = function() return { }, 100 end
+ finder.compute.computeSplitPersonalitySocketImpact = function() return { }, 100 end
+
+ local popup = finder:Open()
+ popup.controls.jewelTypeSelect.selFunc(findControlIndex(popup.controls.jewelTypeSelect.list, "Dreams & Nightmares"))
+ runPopupCompute(popup)
+ local groupedContextKey = popup.controls.resultsList.list[1].resultContextKey
+ local groupedResultCount = #popup.controls.resultsList.list
+ assert.is_true(#popup.controls.variantGroupSelect.list > 1)
+
+ popup.controls.variantGroupSelect.selFunc(2)
+ runPopupCompute(popup)
+ assert.are_not.equal(groupedContextKey, popup.controls.resultsList.list[1].resultContextKey)
+ popup.controls.variantGroupSelect.selFunc(1)
+ runPopupCompute(popup)
+ assert.are.equal(groupedResultCount, #popup.controls.resultsList.list)
+ assert.are.equal(groupedContextKey, popup.controls.resultsList.list[1].resultContextKey)
+
+ popup.controls.jewelTypeSelect.selFunc(findControlIndex(popup.controls.jewelTypeSelect.list, "All jewels"))
+ runPopupCompute(popup)
+ local allJewelsContextKey = popup.controls.resultsList.list[1].resultContextKey
+ local allJewelsResultCount = #popup.controls.resultsList.list
+ popup.controls.allJewelsViewSelect.selFunc(2)
+ assert.is_true(#popup.controls.resultsList.list <= allJewelsResultCount)
+ popup.controls.allJewelsViewSelect.selFunc(1)
+ assert.are.equal(allJewelsResultCount, #popup.controls.resultsList.list)
+
+ popup.controls.showLegacyCheck.changeFunc(true)
+ assert.are.equal("^xFFAA33Criteria changed. ^8Run Compute again.",
+ popup.controls.statusLabel.label)
+ local staleAllJewelsMode = popup.controls.resultsList.mode
+ for _, viewIndex in ipairs({ 2, 1 }) do
+ popup.controls.allJewelsViewSelect.selFunc(viewIndex)
+ assertStaleResultsRemainVisible(popup, allJewelsContextKey, allJewelsResultCount,
+ staleAllJewelsMode, "changing the All-jewels view should keep stale results visible")
+ assert.are.equal("^xFFAA33Criteria changed. ^8Run Compute again.",
+ popup.controls.statusLabel.label)
+ end
+ runPopupCompute(popup)
+ assert.are_not.equal(allJewelsContextKey, popup.controls.resultsList.list[1].resultContextKey)
+ popup.controls.showLegacyCheck.changeFunc(false)
+ runPopupCompute(popup)
+ assert.are.equal(allJewelsResultCount, #popup.controls.resultsList.list)
+ assert.are.equal(allJewelsContextKey, popup.controls.resultsList.list[1].resultContextKey)
+ end)
+
+ it("filters Thread Find and Compute by the selected ring", function()
+ local threadVariants = RadiusJewelData.getThreadOfHopeVariants()
+ local targetSocketId = 33631
+ local finder = makeFinder()
+ finder.buildJewelSockets = function()
+ return { { id = targetSocketId, label = "Target socket", pathDist = 1 } }
+ end
+ local computedVariants
+ finder.compute.computeThreadOfHopeSocketImpact = function(_, request)
+ computedVariants = request.variants
+ return {
+ {
+ socket = request.sockets[1],
+ variant = request.variants[1],
+ delta = 1,
+ addedNodeCount = 0,
+ baseOutput = { },
+ compareOutput = { },
+ },
+ }, 100
+ end
+ local popup = finder:Open()
+ popup.controls.jewelTypeSelect.selFunc(findControlIndex(popup.controls.jewelTypeSelect.list, "Thread of Hope"))
+ assert.are.equal("^7Ring:", popup.controls.threadVariantLabel.label)
+ assert.are.equal("Any ring", popup.controls.threadVariantSelect.list[1])
+ for index, variant in ipairs(threadVariants) do
+ assert.are.equal(variant.ringLabel, popup.controls.threadVariantSelect.list[index + 1])
+ end
+ local anyRingTooltip = getDropdownTooltipText(popup.controls.threadVariantSelect, 1)
+ assert.matches("Multiple ring sizes available", anyRingTooltip, 1, true)
+ for _, variant in ipairs(threadVariants) do
+ assert.is_nil(anyRingTooltip:find(variant.ringLabel, 1, true))
+ end
+ popup.controls.findButton:Click()
+ local findRow = popup.controls.resultsList.list[1]
+ assert.is_not_nil(findRow)
+ local anyRingContextKey = findRow.resultContextKey
+
+ popup.controls.threadVariantSelect.selFunc(2)
+ local explicitRingTooltip = getDropdownTooltipText(popup.controls.threadVariantSelect, 2)
+ assert.matches(threadVariants[1].ringLabel, explicitRingTooltip, 1, true)
+ assert.is_nil(explicitRingTooltip:find("Multiple ring sizes available", 1, true))
+ popup.controls.findButton:Click()
+ local explicitRingRow = popup.controls.resultsList.list[1]
+
+ assert.are.equal("findThread", popup.controls.resultsList.mode)
+ assert.are.equal(threadVariants[1].ringLabel, explicitRingRow.variantLabel)
+ assert.are_not.equal(anyRingContextKey, explicitRingRow.resultContextKey)
+
+ runPopupCompute(popup)
+ local row = popup.controls.resultsList.list[1]
+ assert.is_not_nil(row)
+ assert.are.equal(1, #computedVariants)
+ assert.are.equal(threadVariants[1].name, computedVariants[1].name)
+ assert.matches(threadVariants[1].ringLabel, row.detailText, 1, true)
+ assert.matches(threadVariants[1].ringLabel, popup.controls.statusLabel.label, 1, true)
+
+ popup.controls.threadVariantSelect.selFunc(1)
+
+ assert.is_nil(build.radiusJewelFinderState.threadVariantName)
+ end)
+
+ it("restores an explicit Thread ring selection", function()
+ local finder = makeFinder()
+ local popup = finder:Open()
+ popup.controls.jewelTypeSelect.selFunc(findControlIndex(popup.controls.jewelTypeSelect.list, "Thread of Hope"))
+ popup.controls.threadVariantSelect.selFunc(2)
+ popup.controls.findButton:Click()
+ popup.controls.closeButton:Click()
+ local reopenedPopup = finder:Open()
+
+ assert.are.equal(2, reopenedPopup.controls.threadVariantSelect.selIndex)
+ assert.are.equal(RadiusJewelData.getThreadOfHopeVariants()[1].name,
+ build.radiusJewelFinderState.threadVariantName)
+ end)
+
+ it("cancels a suspended Compute when a result criterion changes", function()
+ local _, popup, computeCompleted = openResultContextTestPopup(true)
+ popup.controls.computeButton:Click()
+ runCallback("OnFrame")
+ assert.is_not_nil(main.onFrameFuncs["RadiusJewelFinderCompute"])
+ assert.is_false(computeCompleted())
+
+ popup.controls.impactStatSelect.selFunc(2)
+
+ assert.is_nil(main.onFrameFuncs["RadiusJewelFinderCompute"])
+ assert.is_false(computeCompleted())
+ assertResultsCleared(popup)
+ end)
+
+ it("cancels a suspended Compute after a build revision", function()
+ local _, popup, computeCompleted = openResultContextTestPopup(true)
+ popup.controls.computeButton:Click()
+ runCallback("OnFrame")
+ assert.is_not_nil(main.onFrameFuncs["RadiusJewelFinderCompute"])
+
+ build.outputRevision = build.outputRevision + 1
+ runCallback("OnFrame")
+
+ assert.is_nil(main.onFrameFuncs["RadiusJewelFinderCompute"])
+ assert.is_false(computeCompleted())
+ assertResultsCleared(popup)
+ end)
+
+ it("blocks stale Apply after a build revision", function()
+ local finder, popup = openResultContextTestPopup()
+ runPopupCompute(popup)
+ local staleRow = popup.controls.resultsList.list[1]
+ assert.is_not_nil(staleRow)
+ popup.controls.resultsList.selIndex = 1
+
+ popup.controls.closeButton:Click()
+ build.outputRevision = build.outputRevision + 1
+ local beforeApply = support.snapshotFinderState()
+ assert.is_false(popup.controls.applyButton.enabled())
+ popup.controls.resultsList.OnSelClick(popup.controls.resultsList, 1, staleRow, true)
+ support.assertFinderStateUnchanged(beforeApply, assert)
+
+ local reopenedPopup = finder:Open()
+ assertResultsCleared(reopenedPopup)
+ end)
+
+ it("isolates grouped limit identities for All variants and All jewels Compute", function()
+ local sourceSocketId = 36634
+ local targetSocketId = 61419
+ local sourceSlot = build.itemsTab.sockets[sourceSocketId]
+ local targetSlot = build.itemsTab.sockets[targetSocketId]
+ assert.is_not_nil(sourceSlot)
+ assert.is_not_nil(targetSlot)
+ local redNightmare
+ for _, jewelType in ipairs(RadiusJewelData.buildJewelTypes()) do
+ if jewelType.name == "Dreams & Nightmares" then
+ for _, variant in ipairs(jewelType.variants) do
+ if variant.variantIdentity.limitKey == "The Red Nightmare" and not variant.isFoulborn then
+ redNightmare = variant
+ break
+ end
+ end
+ end
+ end
+ assert.is_not_nil(redNightmare)
+ local equippedItem = new("Item"):Item("Rarity: Unique\n" .. redNightmare.rawText)
+ equippedItem:BuildModList()
+ build.itemsTab:AddItem(equippedItem, true)
+ sourceSlot:SetSelItemId(equippedItem.id)
+ targetSlot:SetSelItemId(0)
+ build.itemsTab:PopulateSlots()
+ local equippedItemId = equippedItem.id
+
+ local finder = makeFinder()
+ finder.buildJewelSockets = function()
+ return {
+ { id = sourceSocketId, label = "Source socket", pathDist = 1 },
+ { id = targetSocketId, label = "Target socket", pathDist = 2 },
+ }
+ end
+
+ local observedPartitions = { }
+ finder.compute.computeBestVariantSocketImpact = function(_, request)
+ local identity = request.variants[1].variantIdentity
+ local limitKey = identity.limitKey
+ for _, variant in ipairs(request.variants) do
+ assert.are.equal(limitKey, variant.variantIdentity.limitKey,
+ "each compute call should contain one canonical limit partition")
+ end
+ if identity.family ~= "Dreams & Nightmares" then
+ return { }, 100
+ end
+ observedPartitions[limitKey] = sourceSlot.selItemId
+ local sourceDelta = limitKey == "The Red Nightmare" and 10 or 1
+ local targetDelta = limitKey == "The Red Nightmare" and 15 or 2
+ return {
+ { socket = request.sockets[1], variant = request.variants[1], delta = sourceDelta, baseOutput = { }, compareOutput = { } },
+ { socket = request.sockets[2], variant = request.variants[1], delta = targetDelta, baseOutput = { }, compareOutput = { } },
+ }, 100
+ end
+ finder.compute.computeSocketImpact = function() return { }, 100 end
+ finder.compute.computeBestIntuitiveLeapSocketImpact = function() return { }, 100 end
+ finder.compute.computeThreadOfHopeSocketImpact = function() return { }, 100 end
+ finder.compute.computeImpossibleEscapeSocketImpact = function() return { }, 100 end
+ finder.compute.computeSplitPersonalitySocketImpact = function() return { }, 100 end
+
+ local popup = finder:Open()
+ local function findIndex(list, needle)
+ for index, entry in ipairs(list) do
+ local label = type(entry) == "table" and entry.label or entry
+ if label == needle then
+ return index
+ end
+ end
+ end
+ local function runCompute()
+ popup.controls.computeButton:Click()
+ while main.onFrameFuncs["RadiusJewelFinderCompute"] do
+ runCallback("OnFrame")
+ end
+ end
+ local function assertCanonicalPartitioning()
+ assert.are.equal(0, observedPartitions["The Red Nightmare"],
+ "matching limited unique should be removed for its partition")
+ assert.are.equal(equippedItemId, observedPartitions["The Green Dream"],
+ "other unique partitions should retain the equipped jewel")
+ assert.are.equal(equippedItemId, sourceSlot.selItemId, "equipped jewel should be restored")
+ assert.are.equal(equippedItemId, build.spec.jewels[sourceSocketId])
+ end
+
+ popup.controls.jewelTypeSelect.selFunc(findIndex(popup.controls.jewelTypeSelect.list, "Dreams & Nightmares"))
+ runCompute()
+ assertCanonicalPartitioning()
+ local rowsBySocket = { }
+ for _, row in ipairs(popup.controls.resultsList.list) do
+ rowsBySocket[row.socketId] = row
+ end
+ assert.are.equal("equipped", rowsBySocket[sourceSocketId].action)
+ assert.are.equal("move", rowsBySocket[targetSocketId].action)
+ assert.are.equal(5, rowsBySocket[targetSocketId].delta)
+ assert.are.equal("The Red Nightmare", rowsBySocket[targetSocketId].jewelLimitKey)
+ assert.are.equal(1, rowsBySocket[targetSocketId].jewelLimit)
+
+ observedPartitions = { }
+ popup.controls.jewelTypeSelect.selFunc(findIndex(popup.controls.jewelTypeSelect.list, "All jewels"))
+ runCompute()
+ assertCanonicalPartitioning()
+ end)
+
+ it("opens the popup with expected jewel types and controls", function()
+ local function listLabels(list)
+ local labels = {}
+ for i, entry in ipairs(list) do
+ labels[i] = type(entry) == "table" and entry.label or entry
+ end
+ return labels
+ end
+
+ local function tooltipTexts(control, index)
+ local tooltip = new("Tooltip"):Tooltip()
+ control.tooltipFunc(tooltip, "DROP", index, control.list[index])
+ local texts = {}
+ for _, line in ipairs(tooltip.lines) do
+ if line.text and line.text ~= "" then
+ texts[#texts + 1] = line.text
+ end
+ end
+ return texts
+ end
+ local function buttonTooltipTexts(control, ...)
+ local tooltip = new("Tooltip"):Tooltip()
+ control.tooltipFunc(tooltip, ...)
+ local texts = {}
+ for _, line in ipairs(tooltip.lines) do
+ if line.text and line.text ~= "" then
+ texts[#texts + 1] = line.text
+ end
+ end
+ return texts
+ end
+
+ local function findIndex(list, needle)
+ for i, label in ipairs(listLabels(list)) do
+ if label == needle then
+ return i
+ end
+ end
+ end
+ local function assertAlphabetical(labels, message)
+ for i = 2, #labels do
+ assert.is_true(labels[i - 1] <= labels[i], message or ("labels not sorted at index " .. i))
+ end
+ end
+
+ while main.popups[1] do
+ main:ClosePopup()
+ end
+
+ build.radiusJewelFinderState = nil
+ local finder = makeFinder()
+ local popup = finder:Open()
+ assert.is_not_nil(popup)
+ assert.are.equal("Find Radius Jewel", popup.title)
+ local popupWidth, popupHeight = popup:GetSize()
+ assert.is_true(popupWidth <= 1020, "popup should fit within a 1024px-wide screen")
+ local popupX, popupY = popup:GetPos()
+ local function assertControlInsidePopup(controlName)
+ local control = popup.controls[controlName]
+ assert.is_not_nil(control, "expected control: " .. controlName)
+ local x, y = control:GetPos()
+ local width, height = control:GetSize()
+ assert.is_true(x >= popupX, controlName .. " should not extend past the popup left edge")
+ assert.is_true(y >= popupY, controlName .. " should not extend past the popup top edge")
+ assert.is_true(x + width <= popupX + popupWidth, controlName .. " should not extend past the popup right edge")
+ assert.is_true(y + height <= popupY + popupHeight, controlName .. " should not extend past the popup bottom edge")
+ end
+ for _, controlName in ipairs({
+ "computeButton",
+ "impactStatSelect",
+ "resultDetailList",
+ "findButton",
+ "addToBuildButton",
+ "applyButton",
+ "closeButton",
+ }) do
+ assertControlInsidePopup(controlName)
+ end
+ for _, controlName in ipairs({ "findButton", "addToBuildButton", "applyButton", "closeButton" }) do
+ local control = popup.controls[controlName]
+ local _, y = control:GetPos()
+ local _, height = control:GetSize()
+ assert.are.equal(10, popupY + popupHeight - (y + height), controlName .. " should keep the bottom action margin")
+ end
+ local occupiedX = popup.controls.occupiedModeSelect:GetPos()
+ local occupiedWidth = popup.controls.occupiedModeSelect:GetSize()
+ local addToBuildX = popup.controls.addToBuildButton:GetPos()
+ local addToBuildWidth = popup.controls.addToBuildButton:GetSize()
+ local applyX = popup.controls.applyButton:GetPos()
+ assert.is_true(occupiedX + occupiedWidth <= addToBuildX,
+ "Add to build should not overlap the Sockets selector")
+ assert.is_true(addToBuildX + addToBuildWidth <= applyX,
+ "placement action should not overlap Add to build")
+ local computeX = popup.controls.computeButton:GetPos()
+ local computeWidth = popup.controls.computeButton:GetSize()
+ assert.are.equal(20, popupX + popupWidth - (computeX + computeWidth), "computeButton should keep the header right margin")
+ local closeX = popup.controls.closeButton:GetPos()
+ local closeWidth = popup.controls.closeButton:GetSize()
+ assert.are.equal(10, popupX + popupWidth - (closeX + closeWidth), "closeButton should keep the bottom right margin")
+ local computeTooltipTexts = buttonTooltipTexts(popup.controls.computeButton)
+ assert.is_true(#computeTooltipTexts > 0, "expected Compute tooltip content")
+ assert.is_true(computeTooltipTexts[1]:find("selected stat", 1, true) ~= nil,
+ "expected Compute tooltip to explain stat ranking")
+ assert.is_true(computeTooltipTexts[2]:find("Max points", 1, true) ~= nil,
+ "expected Compute tooltip to name the Max points filter")
+ assert.is_false(popup.controls.findButton:IsShown(), "Find should be hidden for All jewels")
+ local addToBuildTooltipTexts = buttonTooltipTexts(popup.controls.addToBuildButton)
+ assert.is_true(#addToBuildTooltipTexts > 0, "expected Add to build tooltip content")
+ assert.is_true(addToBuildTooltipTexts[1]:find("Select a result", 1, true) ~= nil,
+ "expected Add to build tooltip to explain missing selection")
+ local applyTooltipTexts = buttonTooltipTexts(popup.controls.applyButton)
+ assert.is_true(#applyTooltipTexts > 0, "expected Apply tooltip content")
+ assert.is_true(applyTooltipTexts[1]:find("Select a result", 1, true) ~= nil,
+ "expected Apply tooltip to explain missing selection")
+ assert.is_nil(popup.controls.closeButton.tooltipFunc, "Close is self-explanatory and should not need a tooltip")
+ assert.are.equal("^7Max points:", popup.controls.maxPointsLabel.label)
+ local maxPointsTooltipTexts = buttonTooltipTexts(popup.controls.maxPointsEdit)
+ assert.is_true(#maxPointsTooltipTexts > 0, "expected Max points tooltip content")
+ assert.is_true(maxPointsTooltipTexts[1]:find("Maximum Points per result.", 1, true) ~= nil,
+ "expected Max points tooltip to explain the result limit")
+ assert.is_true(table.concat(maxPointsTooltipTexts, "\n"):find("For Compute, this includes pathing and passives to allocate.", 1, true) ~= nil,
+ "expected Max points tooltip to explain Compute point cost")
+ assert.is_true(table.concat(maxPointsTooltipTexts, "\n"):find("Leave blank for no limit.", 1, true) ~= nil,
+ "expected Max points tooltip to explain the unlimited state")
+ for mode, pointColumnIndex in pairs({ computeSocket = 2, computeSocketAll = 3, find = 2, findThread = 2 }) do
+ local pointColumn = popup.controls.resultsList.columnsByMode[mode][pointColumnIndex]
+ assert.are.equal("Points", pointColumn.label, mode .. " should spell out Points")
+ assert.are.equal(50, pointColumn.width, mode .. " should leave room for the Points label")
+ end
+ local occupiedTooltipTexts = buttonTooltipTexts(popup.controls.occupiedModeSelect, "DROP", 2, popup.controls.occupiedModeSelect.list[2])
+ assert.is_true(#occupiedTooltipTexts > 0, "expected Sockets tooltip content")
+ assert.is_true(occupiedTooltipTexts[2]:find("socket%-specific") ~= nil,
+ "expected Safe occupied tooltip to explain socket-specific behavior")
+ assert.is_true(popup.controls.computeMethodSelect.shown, "expected Method selector for All jewels")
+ assert.are.same({ "Fast", "Simulated" }, listLabels(popup.controls.computeMethodSelect.list))
+ local fastMethodTooltipTexts = buttonTooltipTexts(popup.controls.computeMethodSelect, "DROP", 1, popup.controls.computeMethodSelect.list[1])
+ assert.is_true(fastMethodTooltipTexts[1]:find("Intuitive Leap", 1, true) ~= nil,
+ "expected All jewels Method tooltip to name affected jewel types")
+ assert.is_true(fastMethodTooltipTexts[2]:find("independently", 1, true) ~= nil,
+ "expected Fast method tooltip to explain independent scoring")
+ local simulatedMethodTooltipTexts = buttonTooltipTexts(popup.controls.computeMethodSelect, "DROP", 2, popup.controls.computeMethodSelect.list[2])
+ assert.is_true(simulatedMethodTooltipTexts[2]:find("recalculates", 1, true) ~= nil,
+ "expected Simulated method tooltip to explain recalculation")
+ popup.controls.computeMethodSelect.selFunc(2)
+ assert.are.equal("simulated_greedy", build.radiusJewelFinderState.computeMethodId)
+ popup.controls.computeMethodSelect.selFunc(1)
+ local allResultsViewTooltipTexts = buttonTooltipTexts(popup.controls.allJewelsViewSelect, "DROP", 1, popup.controls.allJewelsViewSelect.list[1])
+ assert.is_true(allResultsViewTooltipTexts[1]:find("every compatible result", 1, true) ~= nil,
+ "expected All results view tooltip to explain unfiltered results")
+ local bestPerSocketTooltipTexts = buttonTooltipTexts(popup.controls.allJewelsViewSelect, "DROP", 2, popup.controls.allJewelsViewSelect.list[2])
+ assert.is_true(bestPerSocketTooltipTexts[1]:find("one best result per socket", 1, true) ~= nil,
+ "expected Best per socket tooltip to explain per-socket filtering")
+ assert.is_true(bestPerSocketTooltipTexts[2]:find("Jewel limits", 1, true) ~= nil,
+ "expected Best per socket tooltip to mention jewel limits")
+ assert.is_true(findIndex(popup.controls.impactStatSelect.list, "Full DPS") ~= nil)
+ assert.is_true(findIndex(popup.controls.impactStatSelect.list, "Hit DPS") ~= nil)
+ assert.is_true(findIndex(popup.controls.impactStatSelect.list, "Block Chance") ~= nil)
+
+ local hasIntuitiveLeap = false
+ local hasThreadOfHope = false
+ local hasTemperedAndTranscendent = false
+ local hasSplitPersonality = false
+ local hasImpossibleEscape = false
+ local hasDreamsAndNightmares = false
+ local jewelTypeLabels = listLabels(popup.controls.jewelTypeSelect.list)
+ for _, label in ipairs(popup.controls.jewelTypeSelect.list) do
+ if label == "Intuitive Leap" then
+ hasIntuitiveLeap = true
+ elseif label == "Thread of Hope" then
+ hasThreadOfHope = true
+ elseif label == "Tempered & Transcendent" then
+ hasTemperedAndTranscendent = true
+ elseif label == "Split Personality" then
+ hasSplitPersonality = true
+ elseif label == "Impossible Escape" then
+ hasImpossibleEscape = true
+ elseif label == "Dreams & Nightmares" then
+ hasDreamsAndNightmares = true
+ end
+ end
+
+ assert.is_true(hasIntuitiveLeap, "expected Intuitive Leap in jewel type list")
+ assert.is_true(hasThreadOfHope, "expected Thread of Hope in jewel type list")
+ assert.is_true(hasTemperedAndTranscendent, "expected Tempered & Transcendent in jewel type list")
+ assert.is_true(hasSplitPersonality, "expected Split Personality in jewel type list")
+ assert.is_true(hasImpossibleEscape, "expected Impossible Escape in jewel type list")
+ assert.is_true(hasDreamsAndNightmares, "expected Dreams & Nightmares in jewel type list")
+ assertAlphabetical(jewelTypeLabels, "expected jewel types to be sorted alphabetically")
+
+ local allJewelsIdx = findIndex(popup.controls.jewelTypeSelect.list, "All jewels")
+ assert.is_not_nil(allJewelsIdx, "expected All jewels in jewel type list")
+ local allJewelsTooltipTexts = tooltipTexts(popup.controls.jewelTypeSelect, allJewelsIdx)
+ assert.is_true(allJewelsTooltipTexts[2]:find("%/Pt.", 1, true) ~= nil,
+ "expected All jewels tooltip to show %/Pt")
+ local doubledPercent = allJewelsTooltipTexts[2]:find("%%/Pt.", 1, true)
+ assert.is_nil(doubledPercent, "All jewels tooltip should not show escaped %%/Pt")
+ popup.controls.jewelTypeSelect.selFunc(allJewelsIdx)
+ local selectedResultPreview = {
+ { height = 16, [1] = "^7Selected Jewel" },
+ { height = 16, [1] = "^8Selected result preview line" },
+ }
+ assert.is_false(popup.controls.findButton:IsShown(), "Find should stay hidden for All jewels")
+ popup.controls.resultsList:SetMode("computeSocketAll", {
+ {
+ jewelName = "Selected Jewel",
+ socketLabel = "Socket #1",
+ socketId = 33631,
+ points = 1,
+ delta = 10,
+ pct = 10,
+ pctPerPoint = 10,
+ sortValue = 10,
+ detailText = "Test detail",
+ itemTooltipLines = selectedResultPreview,
+ action = "equip",
+ },
+ }, "(no compatible sockets)")
+ assert.is_nil(popup.controls.previewList)
+ assert.are.equal("^7Selected Jewel", popup.controls.resultsList.selValue.itemTooltipLines[1][1])
+ local allJewelsDetailHover = popup.controls.resultsList:GetHoverInfo(7, popup.controls.resultsList.selValue)
+ assert.is_true(allJewelsDetailHover.showItemTooltip,
+ "All jewels Compute detail column should show jewel preview tooltip")
+ local allJewelsSocketHover = popup.controls.resultsList:GetHoverInfo(2, popup.controls.resultsList.selValue)
+ assert.is_true(allJewelsSocketHover.showViewer,
+ "All jewels Compute socket column should show socket preview")
+ popup.controls.resultsList:SetMode("message", { }, "Click Compute")
+
+ -- Intuitive Leap: tooltip, compute method, occupied mode
+ local intuitiveIdx = findIndex(popup.controls.jewelTypeSelect.list, "Intuitive Leap")
+ assert.is_not_nil(intuitiveIdx, "expected Intuitive Leap in jewel type list")
+ popup.controls.jewelTypeSelect.selFunc(intuitiveIdx)
+ local typeTooltipTexts = tooltipTexts(popup.controls.jewelTypeSelect, intuitiveIdx)
+ assert.is_true(#typeTooltipTexts > 0, "expected jewel type tooltip content")
+ assert.is_true(typeTooltipTexts[1]:find("Intuitive Leap", 1, true) ~= nil,
+ "expected type tooltip to describe Intuitive Leap")
+ assert.is_true(popup.controls.jewelVariantSelect.shown, "expected Foulborn variant selector for Intuitive Leap")
+ assert.are.equal("All variants", popup.controls.jewelVariantSelect.list[1])
+ assert.is_true(popup.controls.findButton:IsShown(),
+ "Find should stay visible while all Intuitive Leap variants are selected")
+ assert.is_false(popup.controls.findButton:IsEnabled(),
+ "Find should require one Intuitive Leap variant")
+ local intuitiveVariantLabels = listLabels(popup.controls.jewelVariantSelect.list)
+ local foulbornIntuitiveIdx
+ for i, label in ipairs(intuitiveVariantLabels) do
+ if label:find("Foulborn:", 1, true) then
+ foulbornIntuitiveIdx = i
+ break
+ end
+ end
+ assert.is_not_nil(foulbornIntuitiveIdx, "expected Foulborn Intuitive Leap variant")
+ popup.controls.jewelVariantSelect.selFunc(foulbornIntuitiveIdx)
+ assert.is_true(popup.controls.findButton:IsShown(), "Find should be shown for the selected Intuitive Leap variant")
+ local findTooltipTexts = buttonTooltipTexts(popup.controls.findButton)
+ assert.is_true(#findTooltipTexts > 0, "expected Find tooltip content")
+ assert.is_true(findTooltipTexts[1]:find("matching passives", 1, true) ~= nil,
+ "expected Find tooltip to explain passive matching")
+ assert.is_true(popup.controls.computeMethodSelect.shown, "expected method selector for Intuitive Leap")
+ assert.are.same({ "Fast", "Simulated" }, listLabels(popup.controls.computeMethodSelect.list))
+ assert.are.same({ "Free only", "Safe occupied", "All occupied" }, listLabels(popup.controls.occupiedModeSelect.list))
+ assert.are.equal("Fast", popup.controls.computeMethodSelect.list[popup.controls.computeMethodSelect.selIndex])
+
+ -- Dreams & Nightmares: variant tooltips
+ local normalDreamsIdx = findIndex(popup.controls.jewelTypeSelect.list, "Dreams & Nightmares")
+ assert.is_not_nil(normalDreamsIdx, "expected Dreams & Nightmares in jewel type list")
+ popup.controls.jewelTypeSelect.selFunc(normalDreamsIdx)
+ assert.are.equal("All variants", popup.controls.jewelVariantSelect.list[1])
+ assert.are.equal(1, popup.controls.jewelVariantSelect.selIndex)
+ assert.is_true(popup.controls.findButton:IsShown(),
+ "Find should stay visible while all variants are selected")
+ assert.is_false(popup.controls.findButton:IsEnabled(),
+ "Find should require one Dreams & Nightmares variant")
+ assert.is_true(popup.controls.jewelVariantLabel.y >= 18,
+ "expected header labels to sit below the popup title")
+ if popup.controls.variantGroupSelect.shown then
+ assert.is_true(popup.controls.variantGroupSelect.x < popup.controls.jewelVariantSelect.x,
+ "expected Jewel to filter Variant from left to right")
+ local redNightmareGroupIdx = findIndex(popup.controls.variantGroupSelect.list, "Red Nightmare")
+ assert.is_not_nil(redNightmareGroupIdx, "expected Red Nightmare in jewel filter")
+ popup.controls.variantGroupSelect.selFunc(redNightmareGroupIdx)
+ local redNightmareGroupLabels = listLabels(popup.controls.jewelVariantSelect.list)
+ assert.are.equal("All variants", redNightmareGroupLabels[1])
+ for i = 2, #redNightmareGroupLabels do
+ assert.is_true(redNightmareGroupLabels[i]:find("Red Nightmare", 1, true) ~= nil,
+ "jewel filter should only show Red Nightmare variants: " .. redNightmareGroupLabels[i])
+ end
+ popup.controls.variantGroupSelect.selFunc(1)
+ end
+ local redNightmareIdx = findIndex(popup.controls.jewelVariantSelect.list, "The Red Nightmare")
+ assert.is_not_nil(redNightmareIdx, "expected The Red Nightmare in variant list")
+ local redNightmareTooltipTexts = tooltipTexts(popup.controls.jewelVariantSelect, redNightmareIdx)
+ assert.is_true(#redNightmareTooltipTexts > 0, "expected Red Nightmare tooltip content")
+ for _, text in ipairs(redNightmareTooltipTexts) do
+ assert.is_nil(text:find("{variant:", 1, true), "variant tooltip should not expose raw variant tags")
+ assert.is_nil(text:find("Selected Variant:", 1, true), "variant tooltip should not expose saved-state metadata")
+ end
+ popup.controls.jewelVariantSelect.selFunc(redNightmareIdx)
+ assert.is_true(popup.controls.findButton:IsShown(),
+ "Find should be shown after selecting a specific variant")
+ local foulbornRedNightmareIdx
+ for i, label in ipairs(listLabels(popup.controls.jewelVariantSelect.list)) do
+ if label:find("The Red Nightmare (Foulborn:", 1, true) then
+ foulbornRedNightmareIdx = i
+ break
+ end
+ end
+ assert.is_not_nil(foulbornRedNightmareIdx, "expected Foulborn Red Nightmare variant")
+ popup.controls.jewelVariantSelect.selFunc(foulbornRedNightmareIdx)
+ assert.is_true(popup.controls.findButton:IsShown(),
+ "Find should stay shown for the selected Foulborn variant")
+ popup.controls.findButton:Click()
+ local hasFoulbornResultLabel = false
+ for _, row in ipairs(popup.controls.resultsList.list) do
+ if row.variantLabel and row.variantLabel:find("Foulborn:", 1, true) then
+ hasFoulbornResultLabel = true
+ break
+ end
+ end
+ assert.is_true(hasFoulbornResultLabel, "expected Find results to name the selected Foulborn variant")
+
+ -- Tempered & Transcendent: type tooltip generic, variant tooltip specific
+ local temperedIdx = findIndex(popup.controls.jewelTypeSelect.list, "Tempered & Transcendent")
+ assert.is_not_nil(temperedIdx, "expected Tempered & Transcendent in jewel type list")
+ local temperedTypeTooltipTexts = tooltipTexts(popup.controls.jewelTypeSelect, temperedIdx)
+ assert.is_true(#temperedTypeTooltipTexts > 0, "expected generic type tooltip content")
+ for _, text in ipairs(temperedTypeTooltipTexts) do
+ assert.is_nil(text:find("Tempered Flesh", 1, true),
+ "type tooltip should not include a specific variant")
+ end
+ popup.controls.jewelTypeSelect.selFunc(temperedIdx)
+ assert.are.equal("All variants", popup.controls.jewelVariantSelect.list[1])
+ local temperedFleshIdx = findIndex(popup.controls.jewelVariantSelect.list, "Tempered Flesh")
+ assert.is_not_nil(temperedFleshIdx, "expected Tempered Flesh in variant list")
+ local variantTooltipTexts = tooltipTexts(popup.controls.jewelVariantSelect, temperedFleshIdx)
+ assert.is_true(#variantTooltipTexts > 0, "expected jewel variant tooltip content")
+ assert.is_true(variantTooltipTexts[1]:find("Tempered Flesh", 1, true) ~= nil,
+ "expected variant tooltip to describe the hovered variant")
+ local temperedLabels = listLabels(popup.controls.jewelVariantSelect.list)
+ assert.is_true(#temperedLabels > 0, "expected Tempered & Transcendent variants")
+ for i, label in ipairs(temperedLabels) do
+ if i == 1 then
+ assert.are.equal("All variants", label)
+ else
+ assert.is_truthy(label:find("Tempered") or label:find("Transcendent"),
+ "variant should be Tempered or Transcendent: " .. label)
+ end
+ end
+
+ -- Split Personality: unique variant labels
+ local splitIdx = findIndex(popup.controls.jewelTypeSelect.list, "Split Personality")
+ assert.is_not_nil(splitIdx, "expected Split Personality in jewel type list")
+ popup.controls.jewelTypeSelect.selFunc(splitIdx)
+ assert.is_true(popup.controls.computeButton.shown, "expected Compute for Split Personality")
+ local splitTypeTooltipTexts = tooltipTexts(popup.controls.jewelTypeSelect, splitIdx)
+ for _, text in ipairs(splitTypeTooltipTexts) do
+ assert.is_nil(text:find("Radius:", 1, true),
+ "Split Personality type tooltip should not show a radius line")
+ end
+ local splitLabels = listLabels(popup.controls.jewelVariantSelect.list)
+ assert.is_true(#splitLabels > 0, "expected Split Personality variants")
+ assert.are.equal("All variants", splitLabels[1])
+ local splitVariantTooltipTexts = tooltipTexts(popup.controls.jewelVariantSelect, 2)
+ for _, text in ipairs(splitVariantTooltipTexts) do
+ assert.is_nil(text:find("Radius:", 1, true),
+ "Split Personality variant tooltip should not show a radius line")
+ end
+ local seenLabels = {}
+ for i, label in ipairs(splitLabels) do
+ assert.is_string(label)
+ assert.is_true(#label > 0, "variant label should not be empty")
+ if i > 1 then
+ assert.is_nil(seenLabels[label], "duplicate Split Personality variant: " .. label)
+ seenLabels[label] = true
+ end
+ end
+
+ -- Impossible Escape: compute method + keystone variants
+ local impossibleIdx = findIndex(popup.controls.jewelTypeSelect.list, "Impossible Escape")
+ assert.is_not_nil(impossibleIdx, "expected Impossible Escape in jewel type list")
+ popup.controls.jewelTypeSelect.selFunc(impossibleIdx)
+ assert.is_true(popup.controls.computeMethodSelect.shown, "expected method selector for Impossible Escape")
+ assert.are.same({ "Fast", "Simulated" }, listLabels(popup.controls.computeMethodSelect.list))
+ assert.is_true(#popup.controls.jewelVariantSelect.list > 0, "expected Impossible Escape keystone variants")
+ assert.are.equal("All variants", popup.controls.jewelVariantSelect.list[1])
+ assert.are.equal(1, popup.controls.jewelVariantSelect.selIndex)
+ assert.is_true(popup.controls.findButton:IsShown(),
+ "Find should stay shown for Impossible Escape all-variant searches")
+ assert.is_true(#popup.controls.jewelVariantSelect.list > 1, "expected at least one selectable keystone variant")
+
+ local capturedVariants
+ finder.compute.computeImpossibleEscapeSocketImpact = function(_, request)
+ capturedVariants = request.variants
+ return { }, 0
+ end
+ popup.controls.computeButton:Click()
+ while main.onFrameFuncs["RadiusJewelFinderCompute"] do
+ runCallback("OnFrame")
+ end
+ assert.is_table(capturedVariants)
+ assert.is_true(#capturedVariants > 1, "All variants should compute every Impossible Escape variant")
+
+ local selectedImpossibleEscapeLabel = listLabels(popup.controls.jewelVariantSelect.list)[2]
+ popup.controls.jewelVariantSelect.selFunc(2)
+ capturedVariants = nil
+ popup.controls.computeButton:Click()
+ while main.onFrameFuncs["RadiusJewelFinderCompute"] do
+ runCallback("OnFrame")
+ end
+ assert.is_table(capturedVariants)
+ assert.are.equal(1, #capturedVariants, "selected Impossible Escape variant should constrain compute")
+ assert.are.equal(selectedImpossibleEscapeLabel, capturedVariants[1].dropdownLabel or capturedVariants[1].name)
+
+ -- Thread of Hope: compute method
+ local threadIdx = findIndex(popup.controls.jewelTypeSelect.list, "Thread of Hope")
+ assert.is_not_nil(threadIdx, "expected Thread of Hope in jewel type list")
+ popup.controls.jewelTypeSelect.selFunc(threadIdx)
+ assert.is_true(popup.controls.computeMethodSelect.shown, "expected method selector for Thread of Hope")
+ assert.are.same({ "Fast", "Simulated" }, listLabels(popup.controls.computeMethodSelect.list))
+
+ while main.popups[1] do
+ main:ClosePopup()
+ end
+ assert.is_nil(main.popups[1])
+ end)
+
+ end)
+
+ describe("replacement item tooltip", function()
+
+ it("attaches the replaced jewel to its detail line", function()
+ while main.popups[1] do
+ main:ClosePopup()
+ end
+ local popup = makeFinder():Open()
+ local socketId = 36634
+ local replacedItem = build.itemsTab.items[build.itemsTab.sockets[socketId].selItemId]
+ popup.controls.resultsList:SetMode("computeSocket", {
+ {
+ socketId = socketId,
+ socketLabel = "Test socket",
+ points = 0,
+ delta = 0,
+ pct = 0,
+ pctPerPoint = 0,
+ sortValue = 0,
+ detailText = "",
+ action = "replace",
+ replacedItemLabel = "Existing jewel",
+ },
+ }, "")
+
+ local replacementLine
+ for _, line in ipairs(popup.controls.resultDetailList.list) do
+ if line[1] and line[1]:find("Will replace", 1, true) then
+ replacementLine = line
+ break
+ end
+ end
+
+ assert.is_not_nil(replacementLine, "expected a replacement detail line")
+ assert.are.equal(replacedItem, replacementLine.item)
+ end)
+
+ end)
+
+end)
diff --git a/src/Classes/ItemsTab.lua b/src/Classes/ItemsTab.lua
index 3ae6e046aa..b07c5a1148 100644
--- a/src/Classes/ItemsTab.lua
+++ b/src/Classes/ItemsTab.lua
@@ -4226,7 +4226,8 @@ function ItemsTabClass:FormatItemSource(text)
:gsub("prophecy{([^}]+)}",colorCodes.PROPHECY.."%1"..colorCodes.SOURCE)
end
-local function itemChangesPassiveTree(item)
+-- Cluster Jewels use the separate comparison path that rebuilds cluster subgraphs.
+function ItemsTabClass:ItemNeedsMainTreeComparisonSpec(item)
return not not (item and item.type == "Jewel" and item.jewelData
and (item.jewelData.conqueredBy or item.jewelRadiusIndex
and (item.jewelData.intuitiveLeapLike or item.jewelData.impossibleEscapeKeystone)))
@@ -4255,7 +4256,7 @@ local sharedSpecKeysForJewelComparison = {
curSecondaryAscendClassName = true,
}
-local function cloneSpecForJewelComparison(spec)
+local function cloneSpecForJewelComparison(spec, includeClusterSubgraphs)
local specCopy = setmetatable({ }, getmetatable(spec))
-- Share only immutable/scalar spec state. Tables that BuildAllDependsAndPaths
-- may mutate must be owned by the comparison spec.
@@ -4304,33 +4305,60 @@ local function cloneSpecForJewelComparison(spec)
specCopy.allocSubgraphNodes = { }
specCopy.allocExtendedNodes = { }
specCopy.subGraphs = { }
+ if includeClusterSubgraphs then
+ for id, subGraph in pairs(spec.subGraphs) do
+ local subGraphCopy = {
+ nodes = { },
+ parentSocket = specCopy.nodes[subGraph.parentSocket.id],
+ entranceNode = specCopy.nodes[subGraph.entranceNode.id],
+ }
+ for _, node in ipairs(subGraph.nodes) do
+ local nodeCopy = specCopy.nodes[node.id]
+ if nodeCopy then
+ t_insert(subGraphCopy.nodes, nodeCopy)
+ end
+ end
+ specCopy.subGraphs[id] = subGraphCopy
+ end
+ end
return specCopy
end
-local function buildSpecForJewelComparison(itemsTab, compareSlot, replacementItem)
+function ItemsTabClass:BuildSpecForJewelComparison(compareSlot, replacementItem, allocateSocket, rebuildClusterJewelGraphs)
local tempItemId
- local spec = cloneSpecForJewelComparison(itemsTab.build.spec)
+ local spec = cloneSpecForJewelComparison(self.build.spec, rebuildClusterJewelGraphs)
if replacementItem then
- if replacementItem.id and itemsTab.items[replacementItem.id] == replacementItem then
+ if replacementItem.id and self.items[replacementItem.id] == replacementItem then
spec.jewels[compareSlot.nodeId] = replacementItem.id
else
tempItemId = -1
- while itemsTab.items[tempItemId] do
+ while self.items[tempItemId] do
tempItemId = tempItemId - 1
end
- itemsTab.items[tempItemId] = replacementItem
+ self.items[tempItemId] = replacementItem
spec.jewels[compareSlot.nodeId] = tempItemId
end
else
spec.jewels[compareSlot.nodeId] = nil
end
+ if allocateSocket then
+ local socketNode = spec.nodes[compareSlot.nodeId]
+ if socketNode then
+ socketNode.alloc = true
+ spec.allocNodes[compareSlot.nodeId] = socketNode
+ end
+ end
local ok, err = xpcall(function()
- spec:BuildAllDependsAndPaths()
+ if rebuildClusterJewelGraphs then
+ spec:BuildClusterJewelGraphs()
+ else
+ spec:BuildAllDependsAndPaths()
+ end
end, debug.traceback)
if tempItemId then
- itemsTab.items[tempItemId] = nil
+ self.items[tempItemId] = nil
end
if not ok then
error(err, 0)
@@ -5061,8 +5089,11 @@ function ItemsTabClass:AddItemTooltip(tooltip, item, slot, dbMode, maxWidth)
local function getReplacedItemAndOutput(compareSlot)
local selItem = self.items[compareSlot.selItemId]
local override = { repSlotName = compareSlot.slotName, repItem = item ~= selItem and item or nil }
- if compareSlot.nodeId and (itemChangesPassiveTree(selItem) or itemChangesPassiveTree(item)) then
- override.spec = buildSpecForJewelComparison(self, compareSlot, override.repItem)
+ if compareSlot.nodeId and (
+ self:ItemNeedsMainTreeComparisonSpec(selItem)
+ or self:ItemNeedsMainTreeComparisonSpec(item)
+ ) then
+ override.spec = self:BuildSpecForJewelComparison(compareSlot, override.repItem)
end
local output = calcFunc(override)
return selItem, output
diff --git a/src/Classes/PassiveSpec.lua b/src/Classes/PassiveSpec.lua
index cd16dd659f..8bbf441ec8 100644
--- a/src/Classes/PassiveSpec.lua
+++ b/src/Classes/PassiveSpec.lua
@@ -1085,7 +1085,8 @@ function PassiveSpecClass:NodesInIntuitiveLeapLikeRadius(node)
if item.jewelData and item.jewelData.impossibleEscapeKeystone then
for keyName, keyNode in pairs(item.jewelData.impossibleEscapeKeystones) do
if self.tree.keystoneMap[keyName] and self.tree.keystoneMap[keyName].nodesInRadius then
- for affectedNodeId in pairs(self.tree.keystoneMap[keyName].nodesInRadius[radiusIndex]) do
+ local nodesInRadius = self.tree.keystoneMap[keyName].nodesInRadius[radiusIndex]
+ for affectedNodeId in pairs(nodesInRadius or { }) do
if self.nodes[affectedNodeId].alloc then
t_insert(result, self.nodes[affectedNodeId])
end
@@ -1151,7 +1152,8 @@ function PassiveSpecClass:BuildAllDependsAndPaths()
local item = self.build.itemsTab.items[itemId]
if item and item.jewelRadiusIndex and self.allocNodes[nodeId] and item.jewelData and not item.jewelData.limitDisabled then
local radiusIndex = item.jewelRadiusIndex
- if self.nodes[nodeId].nodesInRadius and self.nodes[nodeId].nodesInRadius[radiusIndex][node.id] then
+ local nodesInRadius = self.nodes[nodeId].nodesInRadius and self.nodes[nodeId].nodesInRadius[radiusIndex]
+ if nodesInRadius and nodesInRadius[node.id] then
if itemId ~= 0 then
if item.jewelData.intuitiveLeapLike and not (item.jewelData.intuitiveLeapKeystoneOnly and node.type ~= "Keystone") then
-- This node depends on Intuitive Leap-like behaviour
@@ -1172,7 +1174,8 @@ function PassiveSpecClass:BuildAllDependsAndPaths()
if item.jewelData and item.jewelData.impossibleEscapeKeystone then
for keyName, keyNode in pairs(self.tree.keystoneMap) do
if item.jewelData.impossibleEscapeKeystones[keyName] and keyNode.nodesInRadius then
- if keyNode.nodesInRadius[radiusIndex][node.id] then
+ local keyNodesInRadius = keyNode.nodesInRadius[radiusIndex]
+ if keyNodesInRadius and keyNodesInRadius[node.id] then
t_insert(node.intuitiveLeapLikesAffecting, self.nodes[nodeId])
end
end
@@ -1530,6 +1533,7 @@ function PassiveSpecClass:BuildAllDependsAndPaths()
and self.build.itemsTab.items[itemId].jewelData.intuitiveLeapLike
and self.build.itemsTab.items[itemId].jewelRadiusIndex
and self.nodes[nodeId].nodesInRadius
+ and self.nodes[nodeId].nodesInRadius[self.build.itemsTab.items[itemId].jewelRadiusIndex]
and self.nodes[nodeId].nodesInRadius[self.build.itemsTab.items[itemId].jewelRadiusIndex][depNode.id]
) or (
self.build.itemsTab.items[itemId].jewelData
@@ -2401,7 +2405,8 @@ end
function PassiveSpecClass:NodeInKeystoneRadius(keystoneNames, nodeId, radiusIndex)
for _, node in pairs(self.nodes) do
if node.name and node.type == "Keystone" and keystoneNames[node.name:lower()] then
- if (node.nodesInRadius[radiusIndex][nodeId]) then
+ local nodesInRadius = node.nodesInRadius and node.nodesInRadius[radiusIndex]
+ if nodesInRadius and nodesInRadius[nodeId] then
return true
end
end
diff --git a/src/Classes/RadiusJewelCompute.lua b/src/Classes/RadiusJewelCompute.lua
new file mode 100644
index 0000000000..e54958cc42
--- /dev/null
+++ b/src/Classes/RadiusJewelCompute.lua
@@ -0,0 +1,1228 @@
+-- Path of Building
+--
+-- Module: Radius Jewel Compute
+-- Compute methods for the Radius Jewel Finder — calcFunc-based impact evaluation
+-- across all socket/jewel pairs.
+--
+-- Usage:
+-- local RadiusJewelCompute = LoadModule("Classes/RadiusJewelCompute")({
+-- calculateImpactPercent, mustGetUniqueRawText, buildNodeLabelList,
+-- getJewelRadiusIndex,
+-- })
+-- local compute = RadiusJewelCompute.new(finder)
+--
+local ipairs = ipairs
+local pairs = pairs
+local t_insert = table.insert
+local t_sort = table.sort
+local s_format = string.format
+
+return function(helpers)
+
+local RadiusJewelComputeClass = { }
+RadiusJewelComputeClass.__index = RadiusJewelComputeClass
+
+local calculateImpactPercent = helpers.calculateImpactPercent
+local mustGetUniqueRawText = helpers.mustGetUniqueRawText
+local buildNodeLabelList = helpers.buildNodeLabelList
+local getJewelRadiusIndex = helpers.getJewelRadiusIndex
+
+local function extractTooltipStats(output)
+ if not output then return nil end
+ local out = { }
+ for key, value in pairs(output) do
+ local valueType = type(value)
+ if valueType == "number" or valueType == "string" or valueType == "boolean" then
+ out[key] = value
+ end
+ end
+ if output.Minion then
+ out.Minion = extractTooltipStats(output.Minion)
+ end
+ return out
+end
+
+local function normalizeImpactStat(impactStat)
+ if type(impactStat) == "string" then
+ return {
+ field = impactStat,
+ label = impactStat,
+ selection = { stat = impactStat, label = impactStat },
+ }
+ elseif impactStat and impactStat.stat and not impactStat.selection then
+ return {
+ field = impactStat.stat,
+ label = impactStat.label,
+ selection = impactStat,
+ }
+ end
+ return impactStat
+end
+
+function RadiusJewelComputeClass:new(finder)
+ return setmetatable({
+ finder = finder,
+ build = finder.build,
+ }, self)
+end
+
+function RadiusJewelComputeClass:getImpactValue(impactStat, output)
+ impactStat = normalizeImpactStat(impactStat)
+ local selection = impactStat.selection or impactStat
+ if selection.getValue then
+ return selection.getValue(output, self.build)
+ end
+ local statOutput = output
+ if statOutput and statOutput.Minion and selection.stat ~= "FullDPS" then
+ statOutput = statOutput.Minion
+ end
+ local value = statOutput and (statOutput[selection.stat] or 0) or 0
+ if selection.transform then
+ value = selection.transform(value)
+ end
+ return value
+end
+
+function RadiusJewelComputeClass:calculateImpactDelta(impactStat, baselineOutput, compareOutput)
+ impactStat = normalizeImpactStat(impactStat)
+ local selection = impactStat.selection or impactStat
+ return self.build.calcsTab:CalculatePowerStat(selection, compareOutput, baselineOutput)
+end
+
+function RadiusJewelComputeClass:getSocketOccupancyInfo(...)
+ return self.finder:getSocketOccupancyInfo(...)
+end
+
+function RadiusJewelComputeClass:socketMatchesOccupiedMode(...)
+ return self.finder:socketMatchesOccupiedMode(...)
+end
+
+function RadiusJewelComputeClass:getSocketBasePoints(...)
+ return self.finder:getSocketBasePoints(...)
+end
+
+-- ─────────────────────────────────────────────────────────────────────────────
+-- Local helpers
+-- ─────────────────────────────────────────────────────────────────────────────
+
+local function progressTick(progress, done, total, label)
+ if progress and progress.tick then
+ progress:tick(done, total, label)
+ end
+end
+
+local function progressChild(progress, startFraction, spanFraction)
+ if progress and progress.child then
+ return progress:child(startFraction, spanFraction)
+ end
+ return progress
+end
+
+local function copyRequest(request)
+ local copied = { }
+ for key, value in pairs(request) do
+ copied[key] = value
+ end
+ return copied
+end
+
+local function calculateWithSocketDistance(calcFunc, override, socketNode, distance)
+ local previousDistance = socketNode.distanceToClassStart
+ socketNode.distanceToClassStart = distance
+ local ok, output = xpcall(function()
+ return calcFunc(override)
+ end, debug.traceback)
+ socketNode.distanceToClassStart = previousDistance
+ if not ok then
+ error(output, 0)
+ end
+ return output
+end
+
+local function isDisconnectedPassiveCandidateNode(node, keystoneOnly, notableOrKeystoneOnly)
+ if not node then
+ return false
+ end
+ if node.ascendancyName then
+ return false
+ end
+ if node.type == "Socket" or node.type == "ClassStart" or node.type == "AscendClassStart" or node.type == "Mastery" then
+ return false
+ end
+ if keystoneOnly then
+ return node.type == "Keystone"
+ end
+ if notableOrKeystoneOnly then
+ return node.type == "Keystone" or node.type == "Notable"
+ end
+ return true
+end
+
+local function getPassiveNodeLabel(node)
+ return node.dn or node.name or tostring(node.id or "?")
+end
+
+local function buildChosenNodesSummary(nodes, variantLabel)
+ local labels = { }
+ for _, node in ipairs(nodes) do
+ t_insert(labels, getPassiveNodeLabel(node))
+ end
+ t_sort(labels)
+ local prefix = #labels == 1 and "1 node" or s_format("%d nodes", #labels)
+ if #labels == 0 then
+ return variantLabel and (variantLabel .. " | jewel only") or "jewel only"
+ end
+ local summary = labels[1]
+ if #labels >= 2 then
+ summary = summary .. ", " .. labels[2]
+ end
+ if #labels > 2 then
+ summary = summary .. s_format(", +%d more", #labels - 2)
+ end
+ if variantLabel and variantLabel ~= "" then
+ return s_format("%s | %s: %s", variantLabel, prefix, summary)
+ end
+ return s_format("%s: %s", prefix, summary)
+end
+
+local function copyNodeList(nodes)
+ local out = { }
+ for i, node in ipairs(nodes) do
+ out[i] = node
+ end
+ return out
+end
+
+local function buildNodeEntries(nodes)
+ local entries = { }
+ for _, node in ipairs(nodes or { }) do
+ if type(node) == "table" then
+ t_insert(entries, {
+ label = getPassiveNodeLabel(node),
+ nodeId = node.id,
+ })
+ else
+ t_insert(entries, {
+ label = tostring(node),
+ })
+ end
+ end
+ t_sort(entries, function(a, b)
+ return (a.label or "") < (b.label or "")
+ end)
+ return entries
+end
+
+local function buildReplacementItem(slot)
+ local item = new("Item"):Item("Rarity: Normal\nCobalt Jewel")
+ item:BuildModList()
+ if slot and slot.selItemId == 0 then
+ item.jewelSocketSource = "empty"
+ end
+ return item
+end
+
+local function itemNeedsRadiusComparisonSpec(itemsTab, item)
+ return itemsTab:ItemNeedsMainTreeComparisonSpec(item)
+ or not not (item and item.type == "Jewel" and item.clusterJewel)
+end
+
+local function buildDisconnectedPassivePlanStep(baseOutput, baseValue, value, compareOutput, chosenNodes, variantLabel)
+ local snapshotNodes = copyNodeList(chosenNodes)
+ return {
+ value = value,
+ delta = value - baseValue,
+ baseOutput = extractTooltipStats(baseOutput),
+ compareOutput = extractTooltipStats(compareOutput),
+ chosenNodes = snapshotNodes,
+ resultNodes = buildNodeEntries(snapshotNodes),
+ resultNodeLabels = buildNodeLabelList(snapshotNodes),
+ addedNodeCount = #snapshotNodes,
+ detailText = buildChosenNodesSummary(snapshotNodes, variantLabel),
+ }
+end
+
+-- Exported for the UI to build compute result rows
+local function buildDisplayedDisconnectedPassivePlans(result, socketBasePoints, baseline)
+ if not result.planSteps or #result.planSteps == 0 then
+ return { result }
+ end
+ local displayedPlans = { }
+ local bestPctPerPoint = -math.huge
+ for _, step in ipairs(result.planSteps) do
+ local totalPoints = socketBasePoints + (step.addedNodeCount or 0)
+ local pct = calculateImpactPercent(step.delta, baseline)
+ local pctPerPoint = totalPoints > 0 and (pct / totalPoints) or pct
+ if pctPerPoint > bestPctPerPoint + 1e-9 then
+ t_insert(displayedPlans, step)
+ bestPctPerPoint = pctPerPoint
+ end
+ end
+ local finalPlan = result
+ local lastDisplayed = displayedPlans[#displayedPlans]
+ if not lastDisplayed or (lastDisplayed.addedNodeCount or 0) ~= (finalPlan.addedNodeCount or 0) then
+ t_insert(displayedPlans, finalPlan)
+ end
+ return displayedPlans
+end
+
+-- ─────────────────────────────────────────────────────────────────────────────
+-- Class methods
+-- ─────────────────────────────────────────────────────────────────────────────
+
+function RadiusJewelComputeClass:buildSocketReplacementContext(calcFunc, socketId)
+ local socketNode = self.build.spec.nodes[socketId] or self.build.spec.tree.nodes[socketId]
+ if not socketNode then
+ return nil
+ end
+ local occupancy = self:getSocketOccupancyInfo(socketId)
+ local slotName = "Jewel " .. tostring(socketId)
+ local baselineItem = occupancy.isOccupied and occupancy.item or buildReplacementItem(occupancy.slot)
+ local baselineOutput = calcFunc({
+ addNodes = { [socketNode] = true },
+ repSlotName = slotName,
+ repItem = baselineItem,
+ })
+ return {
+ socketNode = socketNode,
+ slotName = slotName,
+ occupancy = occupancy,
+ baselineItem = baselineItem,
+ baselineOutput = baselineOutput,
+ replacedItemLabel = occupancy.replacedItemLabel,
+ storedUnallocatedItemLabel = occupancy.storedUnallocatedItemLabel,
+ }
+end
+
+function RadiusJewelComputeClass:socketReplacementChangesPassiveTree(replacementContext, item)
+ local replacedItem = replacementContext.occupancy and replacementContext.occupancy.isOccupied and replacementContext.occupancy.item
+ return itemNeedsRadiusComparisonSpec(self.build.itemsTab, replacedItem)
+ or itemNeedsRadiusComparisonSpec(self.build.itemsTab, item)
+end
+
+function RadiusJewelComputeClass:getImpossibleEscapePlanCacheKey(statField, variantName, replacementContext)
+ local cacheKey = s_format("IE|%s|%s", statField, variantName)
+ local occupancy = replacementContext.occupancy
+ if occupancy and occupancy.isOccupied and itemNeedsRadiusComparisonSpec(self.build.itemsTab, occupancy.item) then
+ -- Removing a structural jewel changes the comparison spec for this socket.
+ return s_format("%s|%s", cacheKey, replacementContext.socketNode.id)
+ end
+ return cacheKey
+end
+
+function RadiusJewelComputeClass:buildSocketReplacementOverride(replacementContext, item, addNodes)
+ local override = {
+ addNodes = addNodes,
+ repSlotName = replacementContext.slotName,
+ repItem = item,
+ }
+ if self:socketReplacementChangesPassiveTree(replacementContext, item) then
+ -- repItem changes only the evaluated item. Structural jewels can also
+ -- change node ownership and dependencies, so rebuild a comparison spec first.
+ local socketNode = replacementContext.socketNode
+ replacementContext.comparisonSpecs = replacementContext.comparisonSpecs or { }
+ local spec = replacementContext.comparisonSpecs[item]
+ if not spec then
+ local replacedItem = replacementContext.occupancy and replacementContext.occupancy.item
+ local rebuildClusterJewelGraphs = (replacedItem and replacedItem.clusterJewel) or item.clusterJewel
+ spec = self.build.itemsTab:BuildSpecForJewelComparison({ nodeId = socketNode.id }, item, not socketNode.alloc, rebuildClusterJewelGraphs)
+ replacementContext.comparisonSpecs[item] = spec
+ end
+ override.spec = spec
+ if addNodes then
+ local comparisonNodes = { }
+ for node in pairs(addNodes) do
+ comparisonNodes[spec.nodes[node.id] or node] = true
+ end
+ override.addNodes = comparisonNodes
+ end
+ end
+ return override
+end
+
+function RadiusJewelComputeClass:getSocketDistanceToClassStart(socketId)
+ local spec = self.build.spec
+ local socketNode = spec.nodes[socketId]
+ if not socketNode then
+ return 0
+ end
+ if socketNode.alloc and socketNode.connectedToStart then
+ return socketNode.distanceToClassStart or 0
+ end
+
+ local targetNodeId = spec.curClass.startNodeId
+ local nodeDistanceToRoot = { [socketNode.id] = 0 }
+ local queue = { socketNode }
+ local outIndex, inIndex = 1, 2
+ while outIndex < inIndex do
+ local node = queue[outIndex]
+ outIndex = outIndex + 1
+ local curDist = nodeDistanceToRoot[node.id] + 1
+ for _, other in ipairs(node.linked) do
+ if other.id == targetNodeId then
+ return curDist - 1
+ end
+ if node.type ~= "Mastery"
+ and other.type ~= "ClassStart"
+ and other.type ~= "AscendClassStart"
+ and not nodeDistanceToRoot[other.id]
+ and (node.ascendancyName == other.ascendancyName or (nodeDistanceToRoot[node.id] == 0 and not other.ascendancyName)) then
+ nodeDistanceToRoot[other.id] = curDist
+ queue[inIndex] = other
+ inIndex = inIndex + 1
+ end
+ end
+ end
+
+ return 0
+end
+
+-- Candidates are unallocated passives a disconnected-passive jewel may add before scoring.
+function RadiusJewelComputeClass:collectDisconnectedPassiveCandidates(socketNode, options)
+ local allocNodes = self.build.spec.allocNodes
+ local candidates = { }
+ local seen = { }
+ local sourceNodes
+ if options.collectNodes then
+ sourceNodes = options.collectNodes(socketNode)
+ else
+ sourceNodes = socketNode and socketNode.nodesInRadius and options.radiusIndex and socketNode.nodesInRadius[options.radiusIndex]
+ end
+ if not sourceNodes then
+ return candidates
+ end
+ for nodeId, node in pairs(sourceNodes) do
+ if not seen[nodeId] and not allocNodes[nodeId] and isDisconnectedPassiveCandidateNode(node, options.keystoneOnly, options.notableOrKeystoneOnly) then
+ t_insert(candidates, node)
+ seen[nodeId] = true
+ end
+ end
+ t_sort(candidates, function(a, b)
+ if a.type ~= b.type then
+ if a.type == "Keystone" then
+ return true
+ end
+ if b.type == "Keystone" then
+ return false
+ end
+ if a.type == "Notable" then
+ return true
+ end
+ if b.type == "Notable" then
+ return false
+ end
+ end
+ return getPassiveNodeLabel(a) < getPassiveNodeLabel(b)
+ end)
+ return candidates
+end
+
+function RadiusJewelComputeClass:computeDisconnectedPassiveSimulatedPlan(request)
+ local calcFunc = request.calcFunc
+ local replacementContext = request.replacementContext
+ local baseOutput = request.baseOutput
+ local baseValue = request.baseValue
+ local socketNode = request.socketNode
+ local item = request.item
+ local impactStat = request.impactStat
+ local candidates = request.candidates
+ local variantLabel = request.variantLabel
+ local progressLabel = request.progressLabel
+ local progress = request.progress
+ local maxAdditionalNodes = request.maxAdditionalNodes
+ impactStat = normalizeImpactStat(impactStat)
+ local addNodes = { [socketNode] = true }
+ local function calculate(extraNode)
+ local nextNodes = copyTable(addNodes, true)
+ if extraNode then
+ nextNodes[extraNode] = true
+ end
+ local output = calcFunc(self:buildSocketReplacementOverride(replacementContext, item, nextNodes))
+ return output, self:getImpactValue(impactStat, output)
+ end
+
+ local currentOutput, currentValue = calculate()
+ local chosenNodes = { }
+ local chosenNodeIds = { }
+ if maxAdditionalNodes and maxAdditionalNodes <= 0 then
+ return buildDisconnectedPassivePlanStep(baseOutput, baseValue, currentValue, currentOutput, chosenNodes, variantLabel)
+ end
+ local planSteps = { }
+
+ while true do
+ if maxAdditionalNodes and #chosenNodes >= maxAdditionalNodes then
+ break
+ end
+ local bestCandidate
+ for candidateIndex, node in ipairs(candidates) do
+ progressTick(progress, candidateIndex, #candidates, progressLabel)
+ if not chosenNodeIds[node.id] then
+ local output, value = calculate(node)
+ -- Marginal delta is this node's extra gain over the current greedy plan.
+ local marginalDelta = value - currentValue
+ if not bestCandidate
+ or marginalDelta > bestCandidate.marginalDelta
+ or (marginalDelta == bestCandidate.marginalDelta and getPassiveNodeLabel(node) < getPassiveNodeLabel(bestCandidate.node)) then
+ bestCandidate = {
+ node = node,
+ output = output,
+ value = value,
+ marginalDelta = marginalDelta,
+ }
+ end
+ end
+ end
+ if not bestCandidate or bestCandidate.marginalDelta <= 0 then
+ break
+ end
+ chosenNodeIds[bestCandidate.node.id] = true
+ addNodes[bestCandidate.node] = true
+ t_insert(chosenNodes, bestCandidate.node)
+ currentOutput = bestCandidate.output
+ currentValue = bestCandidate.value
+ t_insert(planSteps, buildDisconnectedPassivePlanStep(baseOutput, baseValue, currentValue, currentOutput, chosenNodes, variantLabel))
+ end
+
+ local result = buildDisconnectedPassivePlanStep(baseOutput, baseValue, currentValue, currentOutput, chosenNodes, variantLabel)
+ result.planSteps = planSteps
+ return result
+end
+
+function RadiusJewelComputeClass:computeDisconnectedPassiveFastPlan(request)
+ local calcFunc = request.calcFunc
+ local replacementContext = request.replacementContext
+ local baseOutput = request.baseOutput
+ local baseValue = request.baseValue
+ local socketNode = request.socketNode
+ local item = request.item
+ local impactStat = request.impactStat
+ local candidates = request.candidates
+ local variantLabel = request.variantLabel
+ local deltaCache = request.deltaCache
+ local progressLabel = request.progressLabel
+ local progress = request.progress
+ local maxAdditionalNodes = request.maxAdditionalNodes
+ local skipPlanSteps = request.skipPlanSteps
+ impactStat = normalizeImpactStat(impactStat)
+ local jewelOnlyOutput, jewelOnlyValue
+ local function ensureJewelOnly()
+ if not jewelOnlyOutput then
+ jewelOnlyOutput = calcFunc(self:buildSocketReplacementOverride(replacementContext, item, {
+ [socketNode] = true,
+ }))
+ jewelOnlyValue = self:getImpactValue(impactStat, jewelOnlyOutput)
+ end
+ end
+ if maxAdditionalNodes and maxAdditionalNodes <= 0 then
+ ensureJewelOnly()
+ local chosenNodes = { }
+ return buildDisconnectedPassivePlanStep(baseOutput, baseValue, jewelOnlyValue, jewelOnlyOutput, chosenNodes, variantLabel)
+ end
+ local scoredCandidates = { }
+ for candidateIndex, node in ipairs(candidates) do
+ progressTick(progress, candidateIndex, #candidates, progressLabel)
+ local delta = deltaCache[node.id]
+ if delta == nil then
+ ensureJewelOnly()
+ local output = calcFunc(self:buildSocketReplacementOverride(replacementContext, item, {
+ [socketNode] = true,
+ [node] = true,
+ }))
+ delta = self:getImpactValue(impactStat, output) - jewelOnlyValue
+ deltaCache[node.id] = delta
+ end
+ if delta > 0 then
+ t_insert(scoredCandidates, {
+ node = node,
+ delta = delta,
+ })
+ end
+ end
+ t_sort(scoredCandidates, function(a, b)
+ if a.delta ~= b.delta then
+ return a.delta > b.delta
+ end
+ return getPassiveNodeLabel(a.node) < getPassiveNodeLabel(b.node)
+ end)
+
+ local chosenNodes = { }
+ for i, entry in ipairs(scoredCandidates) do
+ if maxAdditionalNodes and i > maxAdditionalNodes then
+ break
+ end
+ t_insert(chosenNodes, entry.node)
+ end
+
+ local addNodes = { [socketNode] = true }
+ for _, node in ipairs(chosenNodes) do
+ addNodes[node] = true
+ end
+
+ if skipPlanSteps then
+ local finalOutput = calcFunc(self:buildSocketReplacementOverride(replacementContext, item, addNodes))
+ local finalValue = self:getImpactValue(impactStat, finalOutput)
+ return buildDisconnectedPassivePlanStep(baseOutput, baseValue, finalValue, finalOutput, chosenNodes, variantLabel)
+ end
+
+ local planSteps = { }
+ local prefixNodes = { }
+ local prefixAddNodes = { [socketNode] = true }
+ local lastOutput, lastValue
+ for _, node in ipairs(chosenNodes) do
+ t_insert(prefixNodes, node)
+ prefixAddNodes[node] = true
+ lastOutput = calcFunc(self:buildSocketReplacementOverride(replacementContext, item, prefixAddNodes))
+ lastValue = self:getImpactValue(impactStat, lastOutput)
+ t_insert(planSteps, buildDisconnectedPassivePlanStep(baseOutput, baseValue, lastValue, lastOutput, prefixNodes, variantLabel))
+ end
+ if not lastOutput then
+ ensureJewelOnly()
+ lastOutput = jewelOnlyOutput
+ lastValue = jewelOnlyValue
+ end
+
+ local result = buildDisconnectedPassivePlanStep(baseOutput, baseValue, lastValue, lastOutput, chosenNodes, variantLabel)
+ result.planSteps = planSteps
+ return result
+end
+
+function RadiusJewelComputeClass:computeDisconnectedPassivePlan(request)
+ if request.methodId == "fast" then
+ return self:computeDisconnectedPassiveFastPlan(request)
+ end
+ return self:computeDisconnectedPassiveSimulatedPlan(request)
+end
+
+function RadiusJewelComputeClass:computeSocketImpact(request)
+ local sockets = request.sockets
+ local rawText = request.rawText
+ local impactStat = request.impactStat
+ local progress = request.progress
+ local maxTotalPoints = request.maxTotalPoints
+ local occupiedMode = request.occupiedMode
+ impactStat = normalizeImpactStat(impactStat)
+ local calcFunc, baseOutput = self.build.calcsTab:GetMiscCalculator()
+ local realBaseline = self:getImpactValue(impactStat, baseOutput)
+
+ local results = { }
+ for socketIndex, socket in ipairs(sockets) do
+ progressTick(progress, socketIndex - 1, #sockets, socket.label)
+ local socketAllowed, occupancy = self:socketMatchesOccupiedMode(socket.id, occupiedMode)
+ local socketBasePoints = self:getSocketBasePoints(socket, occupancy)
+ if socketAllowed and (not maxTotalPoints or socketBasePoints <= maxTotalPoints) then
+ local replacementContext = self:buildSocketReplacementContext(calcFunc, socket.id)
+ local item = new("Item"):Item("Rarity: Unique\n" .. rawText)
+ item:BuildModList()
+ local output = calcFunc(self:buildSocketReplacementOverride(replacementContext, item, {
+ [replacementContext.socketNode] = true,
+ }))
+ local value = self:getImpactValue(impactStat, output)
+ local delta = self:calculateImpactDelta(impactStat, replacementContext.baselineOutput, output)
+ t_insert(results, {
+ socket = socket,
+ value = value,
+ delta = delta,
+ replacedItemLabel = occupancy and occupancy.replacedItemLabel or nil,
+ storedUnallocatedItemLabel = occupancy and occupancy.storedUnallocatedItemLabel or nil,
+ baseOutput = extractTooltipStats(replacementContext.baselineOutput),
+ compareOutput = extractTooltipStats(output),
+ })
+ end
+ end
+
+ t_sort(results, function(a, b) return a.delta > b.delta end)
+ return results, realBaseline
+end
+
+function RadiusJewelComputeClass:computeBestVariantSocketImpact(request)
+ local sockets = request.sockets
+ local variants = request.variants
+ local impactStat = request.impactStat
+ local progress = request.progress
+ local maxTotalPoints = request.maxTotalPoints
+ local occupiedMode = request.occupiedMode
+ impactStat = normalizeImpactStat(impactStat)
+ local calcFunc, baseOutput = self.build.calcsTab:GetMiscCalculator()
+ local realBaseline = self:getImpactValue(impactStat, baseOutput)
+
+ local results = { }
+ for socketIndex, socket in ipairs(sockets) do
+ progressTick(progress, socketIndex - 1, #sockets, socket.label)
+ local socketProgress = progressChild(progress, (socketIndex - 1) / #sockets, 1 / #sockets)
+ local socketAllowed, occupancy = self:socketMatchesOccupiedMode(socket.id, occupiedMode)
+ local socketBasePoints = self:getSocketBasePoints(socket, occupancy)
+ if socketAllowed and (not maxTotalPoints or socketBasePoints <= maxTotalPoints) then
+ local replacementContext = self:buildSocketReplacementContext(calcFunc, socket.id)
+ local socketNode = replacementContext.socketNode
+ local bestResult
+ for variantIndex, variant in ipairs(variants) do
+ progressTick(socketProgress, variantIndex, #variants, socket.label .. " | " .. variant.name)
+ local item = new("Item"):Item("Rarity: Unique\n" .. variant.rawText)
+ item:BuildModList()
+ local output = calcFunc(self:buildSocketReplacementOverride(replacementContext, item, {
+ [socketNode] = true,
+ }))
+ local value = self:getImpactValue(impactStat, output)
+ local delta = self:calculateImpactDelta(impactStat, replacementContext.baselineOutput, output)
+ if not bestResult or delta > bestResult.delta then
+ bestResult = {
+ socket = socket,
+ variant = variant,
+ variantIdx = variantIndex,
+ value = value,
+ delta = delta,
+ replacedItemLabel = occupancy and occupancy.replacedItemLabel or nil,
+ storedUnallocatedItemLabel = occupancy and occupancy.storedUnallocatedItemLabel or nil,
+ baseOutput = extractTooltipStats(replacementContext.baselineOutput),
+ compareOutput = extractTooltipStats(output),
+ }
+ end
+ end
+ if bestResult then
+ t_insert(results, bestResult)
+ end
+ progressTick(socketProgress, 1, 1, socket.label)
+ end
+ end
+
+ t_sort(results, function(a, b) return a.delta > b.delta end)
+ return results, realBaseline
+end
+
+function RadiusJewelComputeClass:computeIntuitiveLeapSocketImpact(request)
+ local sockets = request.sockets
+ local impactStat = request.impactStat
+ local variant = request.variant
+ local methodId = request.methodId
+ local planCache = request.planCache
+ local progress = request.progress
+ local maxTotalPoints = request.maxTotalPoints
+ local occupiedMode = request.occupiedMode
+ local skipPlanSteps = request.skipPlanSteps
+ impactStat = normalizeImpactStat(impactStat)
+ local calcFunc, baseOutput = self.build.calcsTab:GetMiscCalculator()
+ local realBaseline = self:getImpactValue(impactStat, baseOutput)
+ local statField = impactStat.field
+
+ local keystoneOnly = variant and variant.keystoneOnly or false
+ local rawText = (variant and variant.rawText) or mustGetUniqueRawText("Intuitive Leap")
+ local candidateOptions = {
+ radiusIndex = variant and variant.radiusIndex or getJewelRadiusIndex("Small"),
+ keystoneOnly = keystoneOnly,
+ }
+
+ local variantKey = variant and variant.name or "normal"
+ local results = { }
+ for socketIndex, socket in ipairs(sockets) do
+ progressTick(progress, socketIndex - 1, #sockets, socket.label)
+ local socketProgress = progressChild(progress, (socketIndex - 1) / #sockets, 1 / #sockets)
+ local socketAllowed, occupancy = self:socketMatchesOccupiedMode(socket.id, occupiedMode)
+ local socketBasePoints = self:getSocketBasePoints(socket, occupancy)
+ if socketAllowed and (not maxTotalPoints or socketBasePoints <= maxTotalPoints) then
+ local replacementContext = self:buildSocketReplacementContext(calcFunc, socket.id)
+ local socketNode = replacementContext.socketNode
+ local item = new("Item"):Item("Rarity: Unique\n" .. rawText)
+ item:BuildModList()
+ local candidates = self:collectDisconnectedPassiveCandidates(socketNode, candidateOptions)
+ if #candidates > 0 then
+ local maxAdditionalNodes = maxTotalPoints and math.max(maxTotalPoints - socketBasePoints, 0) or nil
+ local socketBaseline = self:getImpactValue(impactStat, replacementContext.baselineOutput)
+ local deltaCache
+ if methodId == "fast" then
+ local cacheKey = s_format("IL|%s|%s|%s", statField, variantKey, socket.id)
+ planCache[cacheKey] = planCache[cacheKey] or { }
+ deltaCache = planCache[cacheKey]
+ end
+ local result = self:computeDisconnectedPassivePlan({
+ methodId = methodId,
+ calcFunc = calcFunc,
+ replacementContext = replacementContext,
+ baseOutput = replacementContext.baselineOutput,
+ baseValue = socketBaseline,
+ socketNode = socketNode,
+ item = item,
+ impactStat = impactStat,
+ candidates = candidates,
+ deltaCache = deltaCache,
+ progressLabel = socket.label,
+ progress = socketProgress,
+ maxAdditionalNodes = maxAdditionalNodes,
+ skipPlanSteps = skipPlanSteps,
+ })
+ result.socket = socket
+ result.variant = variant
+ result.replacedItemLabel = occupancy and occupancy.replacedItemLabel or nil
+ result.storedUnallocatedItemLabel = occupancy and occupancy.storedUnallocatedItemLabel or nil
+ t_insert(results, result)
+ end
+ progressTick(socketProgress, 1, 1, socket.label)
+ end
+ end
+
+ t_sort(results, function(a, b) return a.delta > b.delta end)
+ return results, realBaseline
+end
+
+function RadiusJewelComputeClass:computeBestIntuitiveLeapSocketImpact(request)
+ local variants = request.variants
+ if not variants or #variants == 0 then
+ return self:computeIntuitiveLeapSocketImpact(request)
+ end
+ local bestBySocket = { }
+ local realBaseline
+ local variantCount = #variants
+ for variantIndex, variant in ipairs(variants) do
+ local variantProgress = progressChild(request.progress, (variantIndex - 1) / variantCount, 1 / variantCount)
+ local variantRequest = copyRequest(request)
+ variantRequest.variant = variant
+ variantRequest.progress = variantProgress
+ local results, baseline = self:computeIntuitiveLeapSocketImpact(variantRequest)
+ realBaseline = realBaseline or baseline
+ for _, result in ipairs(results) do
+ result.variant = variant
+ local previous = bestBySocket[result.socket.id]
+ if not previous
+ or result.delta > previous.delta
+ or (result.delta == previous.delta and result.addedNodeCount < previous.addedNodeCount)
+ or (result.delta == previous.delta and result.addedNodeCount == previous.addedNodeCount and variant.name < previous.variant.name) then
+ bestBySocket[result.socket.id] = result
+ end
+ end
+ end
+ local results = { }
+ for _, result in pairs(bestBySocket) do
+ t_insert(results, result)
+ end
+ t_sort(results, function(a, b) return a.delta > b.delta end)
+ return results, realBaseline
+end
+
+function RadiusJewelComputeClass:computeThreadOfHopeSocketImpact(request)
+ local sockets = request.sockets
+ local impactStat = request.impactStat
+ local threadVariants = request.variants
+ local methodId = request.methodId
+ local planCache = request.planCache
+ local progress = request.progress
+ local maxTotalPoints = request.maxTotalPoints
+ local occupiedMode = request.occupiedMode
+ local skipPlanSteps = request.skipPlanSteps
+ impactStat = normalizeImpactStat(impactStat)
+ local calcFunc, baseOutput = self.build.calcsTab:GetMiscCalculator()
+ local realBaseline = self:getImpactValue(impactStat, baseOutput)
+ local statField = impactStat.field
+ local results = { }
+
+ -- Pre-build items per ring variant (avoid re-creating inside the socket loop)
+ local threadItems = { }
+ for variantIndex, threadVariant in ipairs(threadVariants) do
+ local item = new("Item"):Item("Rarity: Unique\n" .. threadVariant.rawText)
+ item:BuildModList()
+ threadItems[variantIndex] = item
+ end
+
+ for socketIndex, socket in ipairs(sockets) do
+ progressTick(progress, socketIndex - 1, #sockets, socket.label)
+ local socketProgress = progressChild(progress, (socketIndex - 1) / #sockets, 1 / #sockets)
+ local socketAllowed, occupancy = self:socketMatchesOccupiedMode(socket.id, occupiedMode)
+ local socketBasePoints = self:getSocketBasePoints(socket, occupancy)
+ if socketAllowed and (not maxTotalPoints or socketBasePoints <= maxTotalPoints) then
+ local replacementContext = self:buildSocketReplacementContext(calcFunc, socket.id)
+ local socketNode = replacementContext.socketNode
+ local socketBaseline = self:getImpactValue(impactStat, replacementContext.baselineOutput)
+ local bestResult
+ for variantIndex, threadVariant in ipairs(threadVariants) do
+ local variantProgress = progressChild(socketProgress, (variantIndex - 1) / #threadVariants, 1 / #threadVariants)
+ local item = threadItems[variantIndex]
+ local ringLabel = threadVariant.ringLabel or (threadVariant.name .. " Ring")
+ local candidates = self:collectDisconnectedPassiveCandidates(socketNode, {
+ radiusIndex = threadVariant.radiusIndex,
+ notableOrKeystoneOnly = skipPlanSteps or methodId == "fast",
+ })
+ if #candidates > 0 then
+ local maxAdditionalNodes = maxTotalPoints and math.max(maxTotalPoints - socketBasePoints, 0) or nil
+ local deltaCache
+ if methodId == "fast" then
+ local cacheKey = s_format("ThreadOfHope|%s|%s", statField, socket.id)
+ planCache[cacheKey] = planCache[cacheKey] or { }
+ deltaCache = planCache[cacheKey]
+ end
+ local result = self:computeDisconnectedPassivePlan({
+ methodId = methodId,
+ calcFunc = calcFunc,
+ replacementContext = replacementContext,
+ baseOutput = replacementContext.baselineOutput,
+ baseValue = socketBaseline,
+ socketNode = socketNode,
+ item = item,
+ impactStat = impactStat,
+ candidates = candidates,
+ variantLabel = ringLabel,
+ deltaCache = deltaCache,
+ progressLabel = socket.label .. " | " .. ringLabel,
+ progress = variantProgress,
+ maxAdditionalNodes = maxAdditionalNodes,
+ skipPlanSteps = skipPlanSteps,
+ })
+ result.variant = threadVariant
+ if not bestResult
+ or result.delta > bestResult.delta
+ or (result.delta == bestResult.delta and result.addedNodeCount < bestResult.addedNodeCount)
+ or (result.delta == bestResult.delta and result.addedNodeCount == bestResult.addedNodeCount and threadVariant.radiusIndex < bestResult.variant.radiusIndex) then
+ bestResult = result
+ end
+ end
+ end
+ if bestResult then
+ bestResult.socket = socket
+ bestResult.replacedItemLabel = occupancy and occupancy.replacedItemLabel or nil
+ bestResult.storedUnallocatedItemLabel = occupancy and occupancy.storedUnallocatedItemLabel or nil
+ t_insert(results, bestResult)
+ end
+ progressTick(socketProgress, 1, 1, socket.label)
+ end
+ end
+
+ t_sort(results, function(a, b)
+ if a.delta ~= b.delta then
+ return a.delta > b.delta
+ end
+ return a.variant.radiusIndex < b.variant.radiusIndex
+ end)
+
+ return results, realBaseline
+end
+
+function RadiusJewelComputeClass:computeSplitPersonalitySocketImpact(request)
+ local sockets = request.sockets
+ local impactStat = request.impactStat
+ local variants = request.variants
+ local progress = request.progress
+ local maxTotalPoints = request.maxTotalPoints
+ local occupiedMode = request.occupiedMode
+ impactStat = normalizeImpactStat(impactStat)
+ local calcFunc, baseOutput = self.build.calcsTab:GetMiscCalculator()
+ local realBaseline = self:getImpactValue(impactStat, baseOutput)
+ local results = { }
+
+ for socketIndex, socket in ipairs(sockets) do
+ progressTick(progress, socketIndex - 1, #sockets, socket.label)
+ local socketProgress = progressChild(progress, (socketIndex - 1) / #sockets, 1 / #sockets)
+ local socketAllowed, occupancy = self:socketMatchesOccupiedMode(socket.id, occupiedMode)
+ local socketBasePoints = self:getSocketBasePoints(socket, occupancy)
+ if socketAllowed and (not maxTotalPoints or socketBasePoints <= maxTotalPoints) then
+ local replacementContext = self:buildSocketReplacementContext(calcFunc, socket.id)
+ local socketNode = replacementContext.socketNode
+ local slotName = replacementContext.slotName
+ local splitDistance = socket.classStartDist or self:getSocketDistanceToClassStart(socket.id)
+ local baselineOutput = calculateWithSocketDistance(calcFunc, {
+ addNodes = { [socketNode] = true },
+ repSlotName = slotName,
+ repItem = replacementContext.baselineItem,
+ }, socketNode, splitDistance)
+
+ local bestResult
+ for variantIdx, variant in ipairs(variants) do
+ progressTick(socketProgress, variantIdx, #variants, socket.label .. " | " .. variant.name)
+ local item = new("Item"):Item("Rarity: Unique\n" .. variant.rawText)
+ item:BuildModList()
+ local override = self:buildSocketReplacementOverride(replacementContext, item, {
+ [socketNode] = true,
+ })
+ if override.spec then
+ override.spec.nodes[socketNode.id].distanceToClassStart = splitDistance
+ end
+ local output = override.spec and calcFunc(override)
+ or calculateWithSocketDistance(calcFunc, override, socketNode, splitDistance)
+ local value = self:getImpactValue(impactStat, output)
+ local delta = self:calculateImpactDelta(impactStat, baselineOutput, output)
+ if not bestResult or delta > bestResult.delta then
+ bestResult = {
+ socket = socket,
+ variant = variant,
+ variantIdx = variantIdx,
+ value = value,
+ delta = delta,
+ replacedItemLabel = occupancy and occupancy.replacedItemLabel or nil,
+ storedUnallocatedItemLabel = occupancy and occupancy.storedUnallocatedItemLabel or nil,
+ baseOutput = extractTooltipStats(baselineOutput),
+ compareOutput = extractTooltipStats(output),
+ detailText = s_format("Dist %d | %s", splitDistance, variant.name),
+ }
+ end
+ end
+
+ if bestResult then
+ bestResult.splitDistance = splitDistance
+ t_insert(results, bestResult)
+ end
+ progressTick(socketProgress, 1, 1, socket.label)
+ end
+ end
+
+ t_sort(results, function(a, b)
+ if a.delta ~= b.delta then
+ return a.delta > b.delta
+ end
+ return (a.splitDistance or 0) > (b.splitDistance or 0)
+ end)
+ return results, realBaseline
+end
+
+local function getSmallRadiusIndex()
+ return getJewelRadiusIndex("Small")
+end
+
+local function prepareImpossibleEscapeVariants(self, variants, smallRadiusIndex, notableOrKeystoneOnly)
+ local variantDataByName = { }
+ for _, variant in ipairs(variants) do
+ local keystoneNode = self.build.spec.tree.keystoneMap[variant.keystoneName]
+ if keystoneNode and keystoneNode.nodesInRadius and keystoneNode.nodesInRadius[smallRadiusIndex] then
+ local candidates = self:collectDisconnectedPassiveCandidates(nil, {
+ collectNodes = function()
+ return keystoneNode.nodesInRadius[smallRadiusIndex]
+ end,
+ notableOrKeystoneOnly = notableOrKeystoneOnly,
+ })
+ if #candidates > 0 then
+ local item = new("Item"):Item("Rarity: Unique\n" .. variant.rawText)
+ item:BuildModList()
+ variantDataByName[variant.name] = {
+ variant = variant,
+ item = item,
+ keystoneNode = keystoneNode,
+ candidates = candidates,
+ }
+ end
+ end
+ end
+ return variantDataByName
+end
+
+-- Free sockets with the same remaining points share one representative.
+-- Occupied sockets stay separate because each replacement state can differ.
+local function groupImpossibleEscapeSockets(self, sockets, maxTotalPoints, occupiedMode)
+ local groupedEntries = { }
+ local groupedOrder = { }
+ for _, socket in ipairs(sockets) do
+ local socketAllowed, occupancy = self:socketMatchesOccupiedMode(socket.id, occupiedMode)
+ local socketBasePoints = self:getSocketBasePoints(socket, occupancy)
+ if socketAllowed and (not maxTotalPoints or socketBasePoints <= maxTotalPoints) then
+ local remainingPoints = maxTotalPoints and math.max(maxTotalPoints - socketBasePoints, 0) or -1
+ local groupKey = occupancy and occupancy.isOccupied and ("occupied:" .. socket.id) or ("free:" .. tostring(remainingPoints))
+ if not groupedEntries[groupKey] then
+ groupedEntries[groupKey] = {
+ groupKey = groupKey,
+ remainingPoints = remainingPoints,
+ sockets = { },
+ representativeSocket = socket,
+ occupancy = occupancy,
+ }
+ t_insert(groupedOrder, groupedEntries[groupKey])
+ end
+ t_insert(groupedEntries[groupKey].sockets, socket)
+ end
+ end
+ t_sort(groupedOrder, function(a, b)
+ if a.remainingPoints ~= b.remainingPoints then
+ return a.remainingPoints > b.remainingPoints
+ end
+ return a.representativeSocket.id < b.representativeSocket.id
+ end)
+ return groupedOrder
+end
+
+local function computeImpossibleEscapeRepresentativeResults(self, request)
+ local groupedOrder = request.groupedOrder
+ local variants = request.variants
+ local variantDataByName = request.variantDataByName
+ local methodId = request.methodId
+ local impactStat = request.impactStat
+ local statField = request.statField
+ local calcFunc = request.calcFunc
+ local planCache = request.planCache
+ local progress = request.progress
+ local bestResultByGroupKey = { }
+ local totalPlanCount = #groupedOrder * #variants
+ local currentPlanIndex = 0
+ for _, groupEntry in ipairs(groupedOrder) do
+ local representativeSocket = groupEntry.representativeSocket
+ local replacementContext = self:buildSocketReplacementContext(calcFunc, representativeSocket.id)
+ local representativeSocketNode = replacementContext.socketNode
+ local socketBaseline = self:getImpactValue(impactStat, replacementContext.baselineOutput)
+ local bestResult
+ for _, variant in ipairs(variants) do
+ currentPlanIndex = currentPlanIndex + 1
+ local planProgress = progressChild(progress, (currentPlanIndex - 1) / totalPlanCount, 1 / totalPlanCount)
+ local variantData = variantDataByName[variant.name]
+ if variantData then
+ local maxAdditionalNodes = groupEntry.remainingPoints >= 0 and groupEntry.remainingPoints or nil
+ local deltaCache
+ if methodId == "fast" then
+ local cacheKey = self:getImpossibleEscapePlanCacheKey(statField, variant.name, replacementContext)
+ planCache[cacheKey] = planCache[cacheKey] or { }
+ deltaCache = planCache[cacheKey]
+ end
+ local result = self:computeDisconnectedPassivePlan({
+ methodId = methodId,
+ calcFunc = calcFunc,
+ replacementContext = replacementContext,
+ baseOutput = replacementContext.baselineOutput,
+ baseValue = socketBaseline,
+ socketNode = representativeSocketNode,
+ item = variantData.item,
+ impactStat = impactStat,
+ candidates = variantData.candidates,
+ variantLabel = variant.name,
+ deltaCache = deltaCache,
+ progressLabel = variant.name,
+ progress = planProgress,
+ maxAdditionalNodes = maxAdditionalNodes,
+ skipPlanSteps = true,
+ })
+ result.variant = variant
+ if not bestResult
+ or result.delta > bestResult.delta
+ or (result.delta == bestResult.delta and result.addedNodeCount < bestResult.addedNodeCount)
+ or (result.delta == bestResult.delta and result.addedNodeCount == bestResult.addedNodeCount and variant.name < bestResult.variant.name) then
+ bestResult = result
+ end
+ end
+ progressTick(planProgress, 1, 1, variant.name)
+ end
+ if bestResult then
+ bestResult.impossibleEscapeGroupKey = groupEntry.groupKey
+ end
+ bestResultByGroupKey[groupEntry.groupKey] = bestResult
+ end
+ return bestResultByGroupKey
+end
+
+local function fanOutImpossibleEscapeResults(self, groupedOrder, bestResultByGroupKey)
+ local results = { }
+ for _, groupEntry in ipairs(groupedOrder) do
+ local bestResult = bestResultByGroupKey[groupEntry.groupKey]
+ if bestResult then
+ for _, socket in ipairs(groupEntry.sockets) do
+ local socketOccupancy = self:getSocketOccupancyInfo(socket.id)
+ local resultForSocket = copyTableSafe(bestResult, false, true)
+ resultForSocket.impossibleEscapeGroupKey = groupEntry.groupKey
+ resultForSocket.socket = socket
+ resultForSocket.replacedItemLabel = socketOccupancy and socketOccupancy.replacedItemLabel or nil
+ resultForSocket.storedUnallocatedItemLabel = socketOccupancy and socketOccupancy.storedUnallocatedItemLabel or nil
+ t_insert(results, resultForSocket)
+ end
+ end
+ end
+ t_sort(results, function(a, b)
+ if a.delta ~= b.delta then
+ return a.delta > b.delta
+ end
+ return a.variant.name < b.variant.name
+ end)
+ return results
+end
+
+local function addImpossibleEscapePlanDetails(self, request)
+ local results = request.results
+ local groupedOrder = request.groupedOrder
+ local bestResultByGroupKey = request.bestResultByGroupKey
+ local variantDataByName = request.variantDataByName
+ local impactStat = request.impactStat
+ local statField = request.statField
+ local calcFunc = request.calcFunc
+ local planCache = request.planCache
+ for _, groupEntry in ipairs(groupedOrder) do
+ local bestResult = bestResultByGroupKey[groupEntry.groupKey]
+ local variantData = bestResult and variantDataByName[bestResult.variant.name]
+ if variantData then
+ local replacementContext = self:buildSocketReplacementContext(calcFunc, groupEntry.representativeSocket.id)
+ local socketBaseline = self:getImpactValue(impactStat, replacementContext.baselineOutput)
+ local maxAdditionalNodes = groupEntry.remainingPoints >= 0 and groupEntry.remainingPoints or nil
+ local cacheKey = self:getImpossibleEscapePlanCacheKey(statField, bestResult.variant.name, replacementContext)
+ planCache[cacheKey] = planCache[cacheKey] or { }
+ local fullResult = self:computeDisconnectedPassiveFastPlan({
+ calcFunc = calcFunc,
+ replacementContext = replacementContext,
+ baseOutput = replacementContext.baselineOutput,
+ baseValue = socketBaseline,
+ socketNode = replacementContext.socketNode,
+ item = variantData.item,
+ impactStat = impactStat,
+ candidates = variantData.candidates,
+ variantLabel = bestResult.variant.name,
+ deltaCache = planCache[cacheKey],
+ maxAdditionalNodes = maxAdditionalNodes,
+ skipPlanSteps = false,
+ })
+ fullResult.variant = bestResult.variant
+ fullResult.impossibleEscapeGroupKey = groupEntry.groupKey
+ for i, result in ipairs(results) do
+ if result.impossibleEscapeGroupKey == groupEntry.groupKey then
+ local updated = copyTableSafe(fullResult, false, true)
+ updated.socket = result.socket
+ updated.replacedItemLabel = result.replacedItemLabel
+ updated.storedUnallocatedItemLabel = result.storedUnallocatedItemLabel
+ results[i] = updated
+ end
+ end
+ end
+ end
+end
+
+function RadiusJewelComputeClass:computeImpossibleEscapeSocketImpact(request)
+ local sockets = request.sockets
+ local impactStat = request.impactStat
+ local variants = request.variants
+ local methodId = request.methodId
+ local planCache = request.planCache
+ local progress = request.progress
+ local maxTotalPoints = request.maxTotalPoints
+ local occupiedMode = request.occupiedMode
+ local skipPlanSteps = request.skipPlanSteps
+ impactStat = normalizeImpactStat(impactStat)
+ local calcFunc, baseOutput = self.build.calcsTab:GetMiscCalculator()
+ local realBaseline = self:getImpactValue(impactStat, baseOutput)
+ local statField = impactStat.field
+ local notableOrKeystoneOnly = skipPlanSteps or methodId == "fast"
+ local variantDataByName = prepareImpossibleEscapeVariants(self, variants, getSmallRadiusIndex(), notableOrKeystoneOnly)
+ local groupedOrder = groupImpossibleEscapeSockets(self, sockets, maxTotalPoints, occupiedMode)
+ if #groupedOrder == 0 then
+ return { }, realBaseline
+ end
+ local bestResultByGroupKey = computeImpossibleEscapeRepresentativeResults(self, {
+ groupedOrder = groupedOrder,
+ variants = variants,
+ variantDataByName = variantDataByName,
+ methodId = methodId,
+ impactStat = impactStat,
+ statField = statField,
+ calcFunc = calcFunc,
+ planCache = planCache,
+ progress = progress,
+ })
+ local results = fanOutImpossibleEscapeResults(self, groupedOrder, bestResultByGroupKey)
+ if not skipPlanSteps and methodId == "fast" and #results > 0 then
+ addImpossibleEscapePlanDetails(self, {
+ results = results,
+ groupedOrder = groupedOrder,
+ bestResultByGroupKey = bestResultByGroupKey,
+ variantDataByName = variantDataByName,
+ impactStat = impactStat,
+ statField = statField,
+ calcFunc = calcFunc,
+ planCache = planCache,
+ })
+ end
+
+ return results, realBaseline
+end
+
+return {
+ new = function(finder)
+ return RadiusJewelComputeClass:new(finder)
+ end,
+ buildDisplayedDisconnectedPassivePlans = buildDisplayedDisconnectedPassivePlans,
+}
+
+end -- return function(helpers)
diff --git a/src/Classes/RadiusJewelData.lua b/src/Classes/RadiusJewelData.lua
new file mode 100644
index 0000000000..0a5e2c5456
--- /dev/null
+++ b/src/Classes/RadiusJewelData.lua
@@ -0,0 +1,894 @@
+-- Path of Building
+--
+-- Module: Radius Jewel Data
+-- Jewel type definitions, variants, scoring functions, and preview helpers
+-- for the Radius Jewel Finder.
+--
+local ipairs = ipairs
+local pairs = pairs
+local t_insert = table.insert
+local t_sort = table.sort
+local s_format = string.format
+
+local M = { }
+
+-- ─────────────────────────────────────────────────────────────────────────────
+-- Color constants
+-- ─────────────────────────────────────────────────────────────────────────────
+
+local COL_UNIQUE = "^xAF6025"
+local COL_MOD = "^7"
+local COL_META = "^8"
+local COL_NEG = "^1"
+M.COL_META = COL_META
+
+M.JEWEL_STRATEGY = {
+ RADIUS = "radius",
+ INTUITIVE_LEAP = "intuitiveLeap",
+ THREAD_OF_HOPE = "threadOfHope",
+ IMPOSSIBLE_ESCAPE = "impossibleEscape",
+ SPLIT_PERSONALITY = "splitPersonality",
+ ALL_JEWELS = "allJewels",
+}
+local JEWEL_STRATEGY = M.JEWEL_STRATEGY
+
+-- ─────────────────────────────────────────────────────────────────────────────
+-- Unique raw text lookup
+-- ─────────────────────────────────────────────────────────────────────────────
+
+local uniqueRawTextByName
+local uniqueRawTextByNameAndBase
+local uniqueVariantRawTextCache = { }
+
+local function buildUniqueRawTextIndex()
+ local rawByName = { }
+ local rawByNameAndBase = { }
+ for _, uniqueList in pairs(data.uniques or { }) do
+ if type(uniqueList) == "table" then
+ for _, rawText in ipairs(uniqueList) do
+ if type(rawText) == "string" then
+ local name, baseName = rawText:match("^([^\n]+)\n([^\n]+)")
+ if name and not rawByName[name] then
+ rawByName[name] = rawText
+ end
+ if name and baseName then
+ rawByNameAndBase[name] = rawByNameAndBase[name] or { }
+ if not rawByNameAndBase[name][baseName] then
+ rawByNameAndBase[name][baseName] = rawText
+ end
+ end
+ end
+ end
+ end
+ end
+ return rawByName, rawByNameAndBase
+end
+
+local function getUniqueRawText(name, fallbackRawText, baseName)
+ if not uniqueRawTextByName then
+ uniqueRawTextByName, uniqueRawTextByNameAndBase = buildUniqueRawTextIndex()
+ end
+ if baseName and uniqueRawTextByNameAndBase[name] and uniqueRawTextByNameAndBase[name][baseName] then
+ return uniqueRawTextByNameAndBase[name][baseName]
+ end
+ return uniqueRawTextByName[name] or fallbackRawText
+end
+
+local function getUniqueVariantRawText(name, variantSelector, fallbackRawText, baseName)
+ if not variantSelector then
+ return getUniqueRawText(name, fallbackRawText, baseName)
+ end
+ local cacheKey = s_format("%s|%s|%s", name, baseName or "", tostring(variantSelector))
+ if uniqueVariantRawTextCache[cacheKey] then
+ return uniqueVariantRawTextCache[cacheKey]
+ end
+ local rawText = getUniqueRawText(name, fallbackRawText, baseName)
+ if not rawText then
+ return nil
+ end
+ local item = new("Item"):Item("Rarity: Unique\n" .. rawText)
+ local selectedVariant
+ if type(variantSelector) == "number" then
+ selectedVariant = variantSelector
+ elseif item.variantList then
+ for idx, variantName in ipairs(item.variantList) do
+ if variantName == variantSelector then
+ selectedVariant = idx
+ break
+ end
+ end
+ end
+ if not selectedVariant then
+ return fallbackRawText or rawText
+ end
+ item.variant = selectedVariant
+ local builtRaw = item:BuildRaw():gsub("^Rarity: %w+\n", "")
+ uniqueVariantRawTextCache[cacheKey] = builtRaw
+ return builtRaw
+end
+
+local function mustGetUniqueRawText(name, baseName)
+ local rawText = getUniqueRawText(name, nil, baseName)
+ assert(rawText, "Missing unique raw text: " .. name .. (baseName and (" [" .. baseName .. "]") or ""))
+ return rawText
+end
+
+local function mustGetUniqueVariantRawText(name, variantSelector, baseName)
+ local rawText = getUniqueVariantRawText(name, variantSelector, nil, baseName)
+ assert(rawText, "Missing unique variant raw text: " .. name .. " [" .. tostring(variantSelector) .. "]" .. (baseName and (" [" .. baseName .. "]") or ""))
+ return rawText
+end
+
+local function mustGetCurrentUniqueRawText(name, baseName)
+ return mustGetUniqueVariantRawText(name, "Current", baseName)
+end
+
+local function getRadiusIndexFromRawText(rawText)
+ if not rawText then
+ return nil
+ end
+ local item = new("Item"):Item("Rarity: Unique\n" .. rawText)
+ return item.jewelRadiusIndex
+end
+
+local function getJewelRadiusIndex(label)
+ for index, radius in ipairs(data.jewelRadius) do
+ if radius.inner == 0 and radius.label == label then
+ return index
+ end
+ end
+ return nil
+end
+
+M.getJewelRadiusIndex = getJewelRadiusIndex
+
+local function makeVariantIdentity(family, rawText, variantGroup, radiusIndex)
+ local item = new("Item"):Item("Rarity: Unique\n" .. rawText)
+ local uniqueName = (item.title or rawText:match("^([^\n]+)")):gsub("^[Ff]oulborn ", "")
+ return {
+ family = family,
+ uniqueName = uniqueName,
+ rawText = rawText,
+ variantGroup = variantGroup or uniqueName,
+ radiusIndex = radiusIndex or item.jewelRadiusIndex,
+ limitKey = uniqueName,
+ limit = item.limit,
+ }
+end
+
+local function assignVariantIdentity(candidate, family, variantGroup)
+ if not candidate.rawText then
+ return candidate
+ end
+ candidate.variantIdentity = makeVariantIdentity(family, candidate.rawText, variantGroup, candidate.radiusIndex)
+ candidate.radiusIndex = candidate.variantIdentity.radiusIndex
+ return candidate
+end
+
+local function makeUniqueVariant(name, uniqueName, baseName)
+ local rawText = mustGetCurrentUniqueRawText(uniqueName or name, baseName)
+ return {
+ name = name,
+ rawText = rawText,
+ radiusIndex = getRadiusIndexFromRawText(rawText),
+ }
+end
+
+-- Expose for compute module and tests
+M.mustGetUniqueRawText = mustGetUniqueRawText
+
+-- ─────────────────────────────────────────────────────────────────────────────
+-- Variant helpers
+-- ─────────────────────────────────────────────────────────────────────────────
+
+local function buildVariantsFromUniqueItem(uniqueName, baseName)
+ local variants = { }
+ local baseRawText = mustGetUniqueRawText(uniqueName, baseName)
+ local item = new("Item"):Item("Rarity: Unique\n" .. baseRawText)
+ if item.variantList then
+ for idx, variantName in ipairs(item.variantList) do
+ local rawText = getUniqueVariantRawText(uniqueName, idx, nil, baseName)
+ if rawText then
+ t_insert(variants, {
+ name = variantName,
+ rawText = rawText,
+ radiusIndex = getRadiusIndexFromRawText(rawText),
+ })
+ end
+ end
+ end
+ return variants
+end
+
+M.buildVariantsFromUniqueItem = buildVariantsFromUniqueItem
+
+local THREAD_OF_HOPE_VARIANTS
+local THREAD_OF_HOPE_RADIUS_DATA
+function M.getThreadOfHopeVariants()
+ if not THREAD_OF_HOPE_VARIANTS or THREAD_OF_HOPE_RADIUS_DATA ~= data.jewelRadius then
+ THREAD_OF_HOPE_VARIANTS = { }
+ THREAD_OF_HOPE_RADIUS_DATA = data.jewelRadius
+ local rawText = mustGetUniqueRawText("Thread of Hope")
+ local item = new("Item"):Item("Rarity: Unique\n" .. rawText)
+ for variantIndex, variantName in ipairs(item.variantList or { }) do
+ local variantRawText = mustGetUniqueVariantRawText("Thread of Hope", variantIndex)
+ local variant = {
+ name = variantName:gsub(" Ring$", ""),
+ ringLabel = variantName,
+ rawText = variantRawText,
+ radiusIndex = getRadiusIndexFromRawText(variantRawText),
+ }
+ t_insert(THREAD_OF_HOPE_VARIANTS, assignVariantIdentity(variant, "Thread of Hope", variant.name))
+ end
+ end
+ return THREAD_OF_HOPE_VARIANTS
+end
+
+-- ─────────────────────────────────────────────────────────────────────────────
+-- Scoring functions
+-- ─────────────────────────────────────────────────────────────────────────────
+
+local function scoreGainLoss(nodes, allocNodes, gainType, lossType)
+ local gained, lost = 0, 0
+ for nodeId, node in pairs(nodes) do
+ if not node.ascendancyName and gainType and node.type == gainType and not allocNodes[nodeId] then
+ gained = gained + 1
+ end
+ if not node.ascendancyName and lossType and node.type == lossType and allocNodes[nodeId] then
+ lost = lost + 1
+ end
+ end
+ return gained - lost
+end
+
+local function scoreAllocPassives(nodes, allocNodes)
+ local s = 0
+ for nodeId, node in pairs(nodes) do
+ if not node.ascendancyName and allocNodes[nodeId] and node.type ~= "Socket" and node.type ~= "ClassStart"
+ and node.type ~= "AscendClassStart" and node.type ~= "Mastery" then
+ s = s + 1
+ end
+ end
+ return s
+end
+
+local function scoreUnallocPassives(nodes, allocNodes)
+ local s = 0
+ for nodeId, node in pairs(nodes) do
+ if not node.ascendancyName and not allocNodes[nodeId] and node.type ~= "Socket" and node.type ~= "ClassStart"
+ and node.type ~= "AscendClassStart" and node.type ~= "Mastery" then
+ s = s + 1
+ end
+ end
+ return s
+end
+
+local function scoreUnallocNotablesAndKeystones(nodes, allocNodes)
+ local s = 0
+ for nodeId, node in pairs(nodes) do
+ if not allocNodes[nodeId] and (node.type == "Notable" or node.type == "Keystone") then
+ s = s + 1
+ end
+ end
+ return s
+end
+
+local function getRadiusPassiveAttributeTotals(nodes, allocNodes, attribute)
+ local allocated = 0
+ local unallocated = 0
+ for nodeId, node in pairs(nodes) do
+ if not node.ascendancyName and node.type ~= "Socket" and node.type ~= "ClassStart" and node.type ~= "AscendClassStart" then
+ local amount = node.modList and node.modList:Sum("BASE", nil, attribute) or 0
+ if amount ~= 0 then
+ if allocNodes[nodeId] then
+ allocated = allocated + amount
+ else
+ unallocated = unallocated + amount
+ end
+ end
+ end
+ end
+ return allocated, unallocated
+end
+
+local function scoreRadiusAttributes(nodes, allocNodes, attribute, includeAllocated, includeUnallocated)
+ local allocated, unallocated = getRadiusPassiveAttributeTotals(nodes, allocNodes, attribute)
+ local score = 0
+ if includeAllocated then
+ score = score + allocated
+ end
+ if includeUnallocated then
+ score = score + unallocated
+ end
+ return score
+end
+
+local function makeRadiusAttributeDetail(attributeLabel, includeAllocated, includeUnallocated)
+ return function(nodes, allocNodes)
+ local allocated, unallocated = getRadiusPassiveAttributeTotals(nodes, allocNodes, attributeLabel)
+ if includeAllocated and includeUnallocated then
+ return s_format("%s alloc %d | %s unalloc %d", attributeLabel, allocated, attributeLabel, unallocated)
+ elseif includeAllocated then
+ return s_format("%s alloc %d", attributeLabel, allocated)
+ end
+ return s_format("%s unalloc %d", attributeLabel, unallocated)
+ end
+end
+
+-- ─────────────────────────────────────────────────────────────────────────────
+-- Foulborn finder variants
+-- ─────────────────────────────────────────────────────────────────────────────
+-- PoB now models Foulborn by toggling individual modifier lines. The finder
+-- supports only radius-jewel families whose radius remains represented by Item.
+local FOULBORN_EXCLUDED_UNIQUES = {
+ ["Might of the Meek"] = true,
+}
+
+local FOULBORN_UNNATURAL_GAIN_NOTABLE = "MutatedUniqueJewel125GrantsAllBonusesOfUnallocatedNotablesInRadius"
+local FOULBORN_UNNATURAL_LOSE_NOTABLE = "MutatedUniqueJewel125AllocatedNotablePassiveSkillsInRadiusDoNothing"
+local FOULBORN_INSPIRED_SMALL_PASSIVES = "MutatedUniqueJewel3GainRandomRareMonsterModOnKillWhileXSmallPassivesAllocatedInRadius"
+local FOULBORN_INTUITIVE_KEYSTONES = "MutatedUniqueJewel6KeystoneCanBeAllocatedInMassiveRadiusWithoutBeingConnected"
+
+local function hasFoulbornMutation(variant, modId)
+ for _, newModId in ipairs(variant.newModIds or { }) do
+ if newModId == modId then
+ return true
+ end
+ end
+ return false
+end
+
+local function addUnnaturalInstinctFoulbornFields(variant)
+ local gainType = hasFoulbornMutation(variant, FOULBORN_UNNATURAL_GAIN_NOTABLE) and "Notable" or "Normal"
+ local loseType = hasFoulbornMutation(variant, FOULBORN_UNNATURAL_LOSE_NOTABLE) and "Notable" or "Normal"
+ local gainShort = gainType == "Notable" and "notable" or "small"
+ local loseShort = loseType == "Notable" and "notable" or "small"
+ variant.scoreLabel = "unalloc " .. gainShort .. " - alloc " .. loseShort
+ variant.score = function(nodes, allocNodes)
+ return scoreGainLoss(nodes, allocNodes, gainType, loseType)
+ end
+end
+
+local function addInspiredLearningFoulbornFields(variant)
+ if not hasFoulbornMutation(variant, FOULBORN_INSPIRED_SMALL_PASSIVES) then
+ return
+ end
+ variant.scoreLabel = "alloc small passives"
+ variant.score = function(nodes, allocNodes)
+ local s = 0
+ for nodeId, node in pairs(nodes) do
+ if allocNodes[nodeId] and node.type == "Normal" then
+ s = s + 1
+ end
+ end
+ return s
+ end
+end
+
+local function addIntuitiveLeapFoulbornFields(variant)
+ if not hasFoulbornMutation(variant, FOULBORN_INTUITIVE_KEYSTONES) then
+ return
+ end
+ -- Massive radius is part of the Foulborn effect, not a parsed item mod line.
+ variant.isMassiveRadius = true
+ variant.radiusIndex = getJewelRadiusIndex("Massive")
+ variant.keystoneOnly = true
+ variant.previewMeta = { "Massive Radius", "Keystone Passive Skills only" }
+ variant.scoreLabel = "unalloc keystones"
+ variant.score = function(nodes, allocNodes)
+ local s = 0
+ for nodeId, node in pairs(nodes) do
+ if not allocNodes[nodeId] and node.type == "Keystone" then
+ s = s + 1
+ end
+ end
+ return s
+ end
+end
+
+local function addFoulbornFields(uniqueName, variant)
+ if uniqueName == "Unnatural Instinct" then
+ addUnnaturalInstinctFoulbornFields(variant)
+ elseif uniqueName == "Inspired Learning" then
+ addInspiredLearningFoulbornFields(variant)
+ elseif uniqueName == "Intuitive Leap" then
+ addIntuitiveLeapFoulbornFields(variant)
+ end
+end
+
+local function getFoulbornMutationPairs(uniqueName, foulbornMap)
+ local mutationMap = foulbornMap[uniqueName]
+ local mutationPairs = { }
+ if not mutationMap then
+ return mutationPairs
+ end
+ for originalModId, newModId in pairs(mutationMap) do
+ t_insert(mutationPairs, { originalModId = originalModId, newModId = newModId })
+ end
+ t_sort(mutationPairs, function(a, b) return a.newModId < b.newModId end)
+ return mutationPairs
+end
+
+local function getFoulbornVariantLabel(newModIds)
+ local labels = { }
+ for _, newModId in ipairs(newModIds) do
+ local mod = data.itemMods.Foulborn[newModId]
+ t_insert(labels, mod and mod[1] or newModId)
+ end
+ return "Foulborn: " .. table.concat(labels, " + ")
+end
+
+local function buildFoulbornVariants(uniqueName, baseName, foulbornMap)
+ if FOULBORN_EXCLUDED_UNIQUES[uniqueName] then
+ return { }
+ end
+ foulbornMap = foulbornMap or data.foulbornMap or { }
+ local mutationPairs = getFoulbornMutationPairs(uniqueName, foulbornMap)
+ local variants = { }
+ if #mutationPairs == 0 then
+ return variants
+ end
+ local combinationCount = 2 ^ #mutationPairs - 1
+ local baseRawText = mustGetCurrentUniqueRawText(uniqueName, baseName)
+ for combination = 1, combinationCount do
+ local item = new("Item"):Item("Rarity: Unique\n" .. baseRawText)
+ local newModIds = { }
+ for index, mutationPair in ipairs(mutationPairs) do
+ if math.floor(combination / 2 ^ (index - 1)) % 2 == 1 then
+ item:MutateMod(mutationPair.originalModId, mutationPair.newModId, true)
+ t_insert(newModIds, mutationPair.newModId)
+ end
+ end
+ local rawText = item:BuildRaw():gsub("^Rarity: %w+\n", "")
+ local variant = {
+ name = getFoulbornVariantLabel(newModIds),
+ rawText = rawText,
+ radiusIndex = item.jewelRadiusIndex,
+ isFoulborn = true,
+ newModIds = newModIds,
+ }
+ addFoulbornFields(uniqueName, variant)
+ t_insert(variants, variant)
+ end
+ return variants
+end
+
+M.buildFoulbornVariants = buildFoulbornVariants
+
+local function appendFoulbornVariants(jewelType, uniqueName)
+ local foulbornVariants = buildFoulbornVariants(uniqueName)
+ if #foulbornVariants == 0 then return end
+ jewelType.variants = {
+ { name = "Normal", rawText = jewelType.rawText, radiusIndex = jewelType.radiusIndex },
+ }
+ for _, foulbornVariant in ipairs(foulbornVariants) do
+ t_insert(jewelType.variants, foulbornVariant)
+ end
+end
+
+-- ─────────────────────────────────────────────────────────────────────────────
+-- Lazy variant lists
+-- ─────────────────────────────────────────────────────────────────────────────
+
+local LIGHT_OF_MEANING_VARIANTS
+local function getLightOfMeaningVariants()
+ if not LIGHT_OF_MEANING_VARIANTS then
+ LIGHT_OF_MEANING_VARIANTS = buildVariantsFromUniqueItem("The Light of Meaning")
+ end
+ return LIGHT_OF_MEANING_VARIANTS
+end
+
+local function buildImpossibleEscapeVariants()
+ local variants = { }
+ for _, rawText in ipairs(data.uniques.generated or { }) do
+ if type(rawText) == "string" and rawText:match("^Impossible Escape\n") then
+ for line in rawText:gmatch("[^\n]+") do
+ local name = line:match("^Variant: (.+)$")
+ if name and name ~= "Everything (QoL Test Variant)" then
+ local variantRawText = mustGetUniqueVariantRawText("Impossible Escape", name)
+ t_insert(variants, {
+ name = name,
+ dropdownLabel = name,
+ keystoneName = name,
+ rawText = variantRawText,
+ radiusIndex = getRadiusIndexFromRawText(variantRawText),
+ scoreLabel = "unalloc notable/keystone near keystone",
+ })
+ end
+ end
+ break
+ end
+ end
+ return variants
+end
+
+local function makeTemperedVariant(name, rawText, attribute, includeAllocated, includeUnallocated)
+ local detailBuilder = makeRadiusAttributeDetail(attribute, includeAllocated, includeUnallocated)
+ return {
+ name = name,
+ rawText = rawText,
+ radiusIndex = getRadiusIndexFromRawText(rawText),
+ scoreLabel = includeAllocated and includeUnallocated and (attribute:lower() .. " alloc+unalloc")
+ or includeAllocated and (attribute:lower() .. " alloc")
+ or (attribute:lower() .. " unalloc"),
+ score = function(nodes, allocNodes)
+ return scoreRadiusAttributes(nodes, allocNodes, attribute, includeAllocated, includeUnallocated)
+ end,
+ detailBuilder = detailBuilder,
+ }
+end
+
+local TEMPERED_TRANSCENDENT_VARIANTS
+function M.getTemperedTranscendentVariants()
+ if not TEMPERED_TRANSCENDENT_VARIANTS then
+ TEMPERED_TRANSCENDENT_VARIANTS = {
+ makeTemperedVariant("Tempered Flesh", mustGetCurrentUniqueRawText("Tempered Flesh"), "Str", true, false),
+ makeTemperedVariant("Transcendent Flesh", mustGetCurrentUniqueRawText("Transcendent Flesh"), "Str", true, true),
+ makeTemperedVariant("Tempered Mind", mustGetCurrentUniqueRawText("Tempered Mind"), "Int", true, false),
+ makeTemperedVariant("Transcendent Mind", mustGetCurrentUniqueRawText("Transcendent Mind"), "Int", true, true),
+ makeTemperedVariant("Tempered Spirit", mustGetCurrentUniqueRawText("Tempered Spirit"), "Dex", true, false),
+ makeTemperedVariant("Transcendent Spirit", mustGetCurrentUniqueRawText("Transcendent Spirit"), "Dex", true, true),
+ }
+ end
+ return TEMPERED_TRANSCENDENT_VARIANTS
+end
+
+local SPLIT_PERSONALITY_VARIANTS
+function M.getSplitPersonalityVariants()
+ if not SPLIT_PERSONALITY_VARIANTS then
+ SPLIT_PERSONALITY_VARIANTS = buildVariantsFromUniqueItem("Split Personality")
+ end
+ return SPLIT_PERSONALITY_VARIANTS
+end
+
+local IMPOSSIBLE_ESCAPE_VARIANTS
+function M.getImpossibleEscapeVariants()
+ if not IMPOSSIBLE_ESCAPE_VARIANTS then
+ IMPOSSIBLE_ESCAPE_VARIANTS = buildImpossibleEscapeVariants()
+ end
+ return IMPOSSIBLE_ESCAPE_VARIANTS
+end
+
+-- ─────────────────────────────────────────────────────────────────────────────
+-- Dropdown / impact helpers
+-- ─────────────────────────────────────────────────────────────────────────────
+
+function M.makeVariantDropdownEntry(variant)
+ local label = variant.dropdownLabel or variant.name
+ if label == variant.name then
+ return label
+ end
+ return {
+ label = label,
+ searchFilter = variant.name,
+ }
+end
+
+function M.buildImpactStats()
+ local stats = { }
+ for _, stat in ipairs(data.powerStatList or { }) do
+ if stat.stat and not stat.combinedOffDef and not stat.itemField and stat.label ~= "Name" then
+ t_insert(stats, {
+ field = stat.stat,
+ label = stat.label,
+ selection = stat,
+ })
+ end
+ end
+ return stats
+end
+
+M.DISCONNECTED_PASSIVE_COMPUTE_METHODS = {
+ { id = "fast", label = "Fast" },
+ { id = "simulated_greedy", label = "Simulated" },
+}
+
+M.OCCUPIED_SOCKET_OPTIONS = {
+ { id = "free", label = "Free only" },
+ { id = "safe", label = "Safe occupied" },
+ { id = "all", label = "All occupied" },
+}
+
+function M.findDisconnectedPassiveComputeMethod(methodId)
+ for _, method in ipairs(M.DISCONNECTED_PASSIVE_COMPUTE_METHODS) do
+ if method.id == methodId then
+ return method
+ end
+ end
+ return M.DISCONNECTED_PASSIVE_COMPUTE_METHODS[1]
+end
+
+-- ─────────────────────────────────────────────────────────────────────────────
+-- Jewel preview
+-- ─────────────────────────────────────────────────────────────────────────────
+
+local function previewHeader(name, itemType, radius, extra)
+ local lines = {
+ { height = 20, [1] = COL_UNIQUE .. name },
+ { height = 16, [1] = COL_META .. itemType },
+ { height = 6, [1] = "" },
+ }
+ if radius then
+ t_insert(lines, { height = 16, [1] = COL_META .. "Radius: " .. radius })
+ end
+ if extra then
+ for _, e in ipairs(extra) do
+ t_insert(lines, { height = 16, [1] = COL_META .. e })
+ end
+ end
+ t_insert(lines, { height = 6, [1] = "" })
+ return lines
+end
+
+local function previewFromRawText(rawText, displayName, extraPreviewMeta)
+ local item = new("Item"):Item("Rarity: Unique\n" .. rawText)
+ item:BuildModList()
+
+ local itemName = displayName or item.title or "Unknown Jewel"
+ local itemType = item.baseName or "Jewel"
+ local radius = item.jewelRadiusLabel
+ local extra = { }
+ local mods = { }
+
+ if item.limit then
+ t_insert(extra, "Limited to: " .. item.limit)
+ end
+ if item.source then
+ t_insert(extra, "Source: " .. item.source)
+ end
+ if item.league then
+ t_insert(extra, "League: " .. item.league)
+ end
+ for _, upgradePath in ipairs(item.upgradePaths or { }) do
+ t_insert(extra, "Upgrade: " .. upgradePath)
+ end
+ if rawText:match("(^|\n)Corrupted(\n|$)") then
+ t_insert(extra, "Corrupted")
+ end
+
+ local function addActiveModLines(modLineList)
+ for _, modLine in ipairs(modLineList or { }) do
+ if item:CheckModLineVariant(modLine) then
+ for line in modLine.line:gmatch("[^\n]+") do
+ t_insert(mods, line)
+ end
+ end
+ end
+ end
+
+ addActiveModLines(item.implicitModLines)
+ addActiveModLines(item.explicitModLines)
+
+ local lines = previewHeader(itemName, itemType, radius, extra)
+ if extraPreviewMeta then
+ for _, meta in ipairs(extraPreviewMeta) do
+ t_insert(lines, { height = 16, [1] = COL_META .. meta })
+ end
+ t_insert(lines, { height = 6, [1] = "" })
+ end
+ for _, mod in ipairs(mods) do
+ local col = mod:match("^%-") and COL_NEG or COL_MOD
+ t_insert(lines, { height = 16, [1] = col .. mod })
+ end
+ return lines
+end
+
+local function previewUnique(uniqueName, displayName, baseName)
+ return previewFromRawText(mustGetCurrentUniqueRawText(uniqueName, baseName), displayName)
+end
+
+local function previewFinderGroup(name, note)
+ local lines = previewHeader(name, "Finder group", nil)
+ t_insert(lines, { height = 16, [1] = COL_META .. (note or "Select a variant to preview item data.") })
+ return lines
+end
+
+local function previewThreadOfHope(ringName)
+ if not ringName then
+ return previewFinderGroup("Thread of Hope", "Multiple ring sizes available")
+ end
+ local rawText = mustGetUniqueRawText("Thread of Hope")
+ local displayName
+ local item = new("Item"):Item("Rarity: Unique\n" .. rawText)
+ local variantName
+ for _, candidate in ipairs(item.variantList or { }) do
+ if candidate == ringName or candidate:gsub(" Ring$", "") == ringName then
+ variantName = candidate
+ break
+ end
+ end
+ if variantName then
+ rawText = mustGetUniqueVariantRawText("Thread of Hope", variantName)
+ displayName = "Thread of Hope (" .. variantName .. ")"
+ end
+ return previewFromRawText(rawText, displayName)
+end
+
+local function buildJewelPreview(jewelType, variant)
+ if jewelType.strategy == JEWEL_STRATEGY.THREAD_OF_HOPE then
+ return previewThreadOfHope(variant)
+ elseif variant and variant.rawText then
+ local previewOptions = jewelType.previewOptions or { }
+ local displayName = previewOptions.prefixVariantName
+ and (jewelType.name .. " (" .. variant.name .. ")") or variant.name
+ return previewFromRawText(variant.rawText, displayName, variant.previewMeta)
+ elseif jewelType.rawText then
+ return previewUnique(jewelType.name)
+ elseif jewelType.variants then
+ return previewFinderGroup(jewelType.name)
+ end
+ return previewUnique(jewelType.name)
+end
+
+-- ─────────────────────────────────────────────────────────────────────────────
+-- Jewel type definitions
+-- ─────────────────────────────────────────────────────────────────────────────
+
+local function scoreAllocatedNodeType(nodeType)
+ return function(nodes, allocNodes)
+ local score = 0
+ for nodeId, node in pairs(nodes) do
+ if allocNodes[nodeId] and node.type == nodeType then
+ score = score + 1
+ end
+ end
+ return score
+ end
+end
+
+local function scoreUnnaturalInstinct(nodes, allocNodes)
+ local gained, lost = 0, 0
+ for nodeId, node in pairs(nodes) do
+ if node.type == "Normal" then
+ if allocNodes[nodeId] then lost = lost + 1
+ else gained = gained + 1 end
+ end
+ end
+ return gained - lost
+end
+
+local function makeJewelType(name, scoreLabel, score, options)
+ local jewelType = { }
+ for key, value in pairs(options or { }) do
+ jewelType[key] = value
+ end
+ jewelType.name = name
+ jewelType.strategy = jewelType.strategy or JEWEL_STRATEGY.RADIUS
+ jewelType.scoreLabel = scoreLabel
+ jewelType.score = score
+ if not jewelType.rawText and not jewelType.variants then
+ jewelType.rawText = mustGetUniqueRawText(name)
+ end
+ if not jewelType.radiusIndex then
+ jewelType.radiusIndex = jewelType.variants and jewelType.variants[1]
+ and jewelType.variants[1].radiusIndex
+ or jewelType.rawText and getRadiusIndexFromRawText(jewelType.rawText)
+ end
+ jewelType.preview = function(variant)
+ return buildJewelPreview(jewelType, variant)
+ end
+ return jewelType
+end
+
+function M.buildJewelTypes()
+ local scoreAllocatedNormals = scoreAllocatedNodeType("Normal")
+ local scoreAllocatedNotables = scoreAllocatedNodeType("Notable")
+ local mightOfTheMeek = makeJewelType("Might of the Meek", "alloc small passives", scoreAllocatedNormals)
+
+ local inspiredLearning = makeJewelType("Inspired Learning", "alloc notables", scoreAllocatedNotables)
+ appendFoulbornVariants(inspiredLearning, "Inspired Learning")
+
+ local unnaturalInstinct = makeJewelType("Unnatural Instinct", "unalloc small - alloc small", scoreUnnaturalInstinct)
+ appendFoulbornVariants(unnaturalInstinct, "Unnatural Instinct")
+
+ local lioneyesFall = makeJewelType("Lioneye's Fall", "alloc passives", scoreAllocPassives)
+ appendFoulbornVariants(lioneyesFall, "Lioneye's Fall")
+
+ local intuitiveLeap = makeJewelType("Intuitive Leap", "unalloc passives", scoreUnallocPassives, {
+ strategy = JEWEL_STRATEGY.INTUITIVE_LEAP,
+ })
+ appendFoulbornVariants(intuitiveLeap, "Intuitive Leap")
+
+ local dreamsNightmaresJewels = {
+ { name = "The Red Dream" },
+ { name = "The Red Nightmare" },
+ { name = "The Green Dream" },
+ { name = "The Green Nightmare" },
+ { name = "The Blue Dream" },
+ { name = "The Blue Nightmare" },
+ }
+ local dreamsVariants = { }
+ for _, jewelInfo in ipairs(dreamsNightmaresJewels) do
+ local rawText = mustGetCurrentUniqueRawText(jewelInfo.name)
+ t_insert(dreamsVariants, {
+ name = jewelInfo.name,
+ variantGroup = jewelInfo.name,
+ rawText = rawText,
+ radiusIndex = getRadiusIndexFromRawText(rawText),
+ })
+ local foulbornVariants = buildFoulbornVariants(jewelInfo.name)
+ for _, variant in ipairs(foulbornVariants) do
+ variant.variantGroup = jewelInfo.name
+ variant.name = jewelInfo.name .. " (" .. variant.name .. ")"
+ t_insert(dreamsVariants, variant)
+ end
+ end
+
+ local lightOfMeaningVariants = getLightOfMeaningVariants()
+ local temperedTranscendentVariants = M.getTemperedTranscendentVariants()
+ local statConversionVariants = {
+ makeUniqueVariant("Energy From Within"),
+ makeUniqueVariant("Healthy Mind"),
+ makeUniqueVariant("Energised Armour"),
+ }
+ local attributeConversionVariants = {
+ makeUniqueVariant("Brute Force Solution"),
+ makeUniqueVariant("Careful Planning"),
+ makeUniqueVariant("Efficient Training"),
+ makeUniqueVariant("Fertile Mind"),
+ makeUniqueVariant("Fluid Motion"),
+ makeUniqueVariant("Inertia"),
+ }
+ local combatFocusVariants = {
+ makeUniqueVariant("Combat Focus (Crimson)", "Combat Focus", "Crimson Jewel"),
+ makeUniqueVariant("Combat Focus (Cobalt)", "Combat Focus", "Cobalt Jewel"),
+ makeUniqueVariant("Combat Focus (Viridian)", "Combat Focus", "Viridian Jewel"),
+ }
+ local threadOfHopeRawText = mustGetUniqueRawText("Thread of Hope")
+
+ local jewelTypes = { }
+ t_insert(jewelTypes, makeJewelType("The Light of Meaning", "alloc passives", scoreAllocPassives, {
+ previewOptions = { prefixVariantName = true },
+ variants = lightOfMeaningVariants,
+ }))
+ t_insert(jewelTypes, mightOfTheMeek)
+ t_insert(jewelTypes, unnaturalInstinct)
+ t_insert(jewelTypes, inspiredLearning)
+ t_insert(jewelTypes, makeJewelType("Anatomical Knowledge", "alloc passives", scoreAllocPassives, {
+ isLegacy = true,
+ }))
+ t_insert(jewelTypes, makeJewelType("Tempered & Transcendent", "attr in radius", function(nodes, allocNodes)
+ return scoreRadiusAttributes(nodes, allocNodes, "Str", true, false)
+ end, {
+ variants = temperedTranscendentVariants,
+ }))
+ t_insert(jewelTypes, lioneyesFall)
+ t_insert(jewelTypes, intuitiveLeap)
+ t_insert(jewelTypes, makeJewelType("Impossible Escape", "unalloc notable/keystone near keystone",
+ scoreUnallocNotablesAndKeystones, {
+ strategy = JEWEL_STRATEGY.IMPOSSIBLE_ESCAPE,
+ previewOptions = { prefixVariantName = true },
+ variants = M.getImpossibleEscapeVariants(),
+ }))
+ t_insert(jewelTypes, makeJewelType("Split Personality", "dist to start", function() return 0 end, {
+ strategy = JEWEL_STRATEGY.SPLIT_PERSONALITY,
+ previewOptions = { prefixVariantName = true },
+ variants = M.getSplitPersonalityVariants(),
+ }))
+ t_insert(jewelTypes, makeJewelType("Stat Conversion", "alloc passives", scoreAllocPassives, {
+ variants = statConversionVariants,
+ }))
+ t_insert(jewelTypes, makeJewelType("Attribute Conversion", "alloc passives", scoreAllocPassives, {
+ variants = attributeConversionVariants,
+ }))
+ t_insert(jewelTypes, makeJewelType("Combat Focus", "alloc passives", scoreAllocPassives, {
+ variants = combatFocusVariants,
+ }))
+ t_insert(jewelTypes, makeJewelType("Dreams & Nightmares", "alloc passives", scoreAllocPassives, {
+ variants = dreamsVariants,
+ }))
+ t_insert(jewelTypes, makeJewelType("Thread of Hope", "unalloc notable/keystone in ring",
+ scoreUnallocNotablesAndKeystones, {
+ strategy = JEWEL_STRATEGY.THREAD_OF_HOPE,
+ rawText = threadOfHopeRawText,
+ }))
+ for _, jewelType in ipairs(jewelTypes) do
+ assignVariantIdentity(jewelType, jewelType.name, jewelType.name)
+ for _, variant in ipairs(jewelType.variants or { }) do
+ assignVariantIdentity(variant, jewelType.name, variant.variantGroup)
+ end
+ end
+ return jewelTypes
+end
+
+return M
diff --git a/src/Classes/RadiusJewelDetailListControl.lua b/src/Classes/RadiusJewelDetailListControl.lua
new file mode 100644
index 0000000000..bfaf50bdab
--- /dev/null
+++ b/src/Classes/RadiusJewelDetailListControl.lua
@@ -0,0 +1,105 @@
+-- Path of Building
+--
+-- Class: Radius Jewel Detail List Control
+-- Displays result details with passive-node and item previews.
+--
+
+local ipairs = ipairs
+
+local placeTooltip = LoadModule("Classes/RadiusJewelTooltipPlacement").placeTooltip
+
+---@class RadiusJewelDetailListControl: TextListControl
+local RadiusJewelDetailListClass = newClass("RadiusJewelDetailListControl", "TextListControl")
+
+function RadiusJewelDetailListClass:RadiusJewelDetailListControl(anchor, rect, columns, list, build, socketViewer)
+ self:TextListControl(anchor, rect, columns, list)
+ self.build = build
+ self.socketViewer = socketViewer
+ self.nodeTooltip = new("Tooltip"):Tooltip()
+ self.itemTooltip = new("Tooltip"):Tooltip()
+ return self
+end
+
+function RadiusJewelDetailListClass:GetHoverLine()
+ if not self:IsShown() or not self:IsMouseInBounds() then
+ return nil
+ end
+ local cursorX, cursorY = GetCursorPos()
+ local x, y = self:GetPos()
+ local width, height = self:GetSize()
+ if cursorX < x + 2 or cursorX > x + width - 20 or cursorY < y + 2 or cursorY > y + height - 2 then
+ return nil
+ end
+ local lineY = y + 2 - self.controls.scrollBar.offset
+ for _, lineInfo in ipairs(self.list or { }) do
+ if cursorY >= lineY and cursorY < lineY + lineInfo.height then
+ return lineInfo
+ end
+ lineY = lineY + lineInfo.height
+ end
+ return nil
+end
+
+function RadiusJewelDetailListClass:Draw(viewPort)
+ self.TextListControl.Draw(self, viewPort)
+ local hoverLine = self:GetHoverLine()
+ if not hoverLine or main.popups[2] then
+ return
+ end
+
+ local cursorX, cursorY = GetCursorPos()
+ if hoverLine.item then
+ SetDrawLayer(nil, 100)
+ if self.itemTooltip:CheckForUpdate(hoverLine.item, IsKeyDown("SHIFT"), launch.devModeAlt, self.build.outputRevision) then
+ self.build.itemsTab:AddItemTooltip(self.itemTooltip, hoverLine.item)
+ end
+ local ttW, ttH = self.itemTooltip:GetSize()
+ local ttX, ttY = placeTooltip(viewPort, ttW, ttH, cursorX, cursorY)
+ self.itemTooltip:Draw(ttX, ttY, nil, nil, viewPort)
+ SetDrawLayer(nil, 0)
+ return
+ end
+ if not hoverLine.nodeId then
+ return
+ end
+ local node = self.build.spec.nodes[hoverLine.nodeId] or self.build.spec.tree.nodes[hoverLine.nodeId]
+ if not node then
+ return
+ end
+ local viewerRect
+ SetDrawLayer(nil, 15)
+ local viewerX = cursorX + 20
+ local viewerY = cursorY - 150
+ if viewerX + 304 > viewPort.x + viewPort.width then viewerX = cursorX - 324 end
+ if viewerY < viewPort.y then viewerY = viewPort.y elseif viewerY + 304 > viewPort.y + viewPort.height then viewerY = viewPort.y + viewPort.height - 304 end
+ viewerRect = { x = viewerX, y = viewerY, width = 304, height = 304 }
+
+ SetDrawColor(1, 1, 1)
+ DrawImage(nil, viewerX, viewerY, 304, 304)
+ self.socketViewer.zoom = 5
+ local scale = self.build.spec.tree.size / 1500
+ self.socketViewer.zoomX = -node.x / scale
+ self.socketViewer.zoomY = -node.y / scale
+ self.socketViewer.searchStrResults[hoverLine.nodeId] = true
+ SetViewport(viewerX + 2, viewerY + 2, 300, 300)
+ self.socketViewer:Draw(self.build, { x = 0, y = 0, width = 300, height = 300 }, { })
+ self.socketViewer.searchStrResults[hoverLine.nodeId] = nil
+ SetDrawLayer(nil, 30)
+ SetDrawColor(1, 1, 1, 0.2)
+ DrawImage(nil, 149, 0, 2, 300)
+ DrawImage(nil, 0, 149, 300, 2)
+ SetViewport()
+
+ SetDrawLayer(nil, 100)
+ if self.nodeTooltip:CheckForUpdate(node, true, self.socketViewer.tracePath, launch.devModeAlt,
+ self.build.outputRevision, self.build.spec.allocMode) then
+ local prevShowStatDifferences = self.socketViewer.showStatDifferences
+ self.socketViewer.showStatDifferences = true
+ self.socketViewer:AddNodeTooltip(self.nodeTooltip, node, self.build)
+ self.socketViewer.showStatDifferences = prevShowStatDifferences
+ end
+ local ttW, ttH = self.nodeTooltip:GetSize()
+ local ttX, ttY = placeTooltip(viewPort, ttW, ttH, cursorX, cursorY, { viewerRect })
+ self.nodeTooltip:Draw(ttX, ttY, nil, nil, viewPort)
+ SetDrawLayer(nil, 0)
+end
diff --git a/src/Classes/RadiusJewelFinder.lua b/src/Classes/RadiusJewelFinder.lua
new file mode 100644
index 0000000000..547ed90732
--- /dev/null
+++ b/src/Classes/RadiusJewelFinder.lua
@@ -0,0 +1,2650 @@
+-- Path of Building
+--
+-- Class: Radius Jewel Finder
+-- Popup for comparing radius unique jewels across passive tree sockets.
+-- Supported jewel definitions come from RadiusJewelData.buildJewelTypes().
+--
+local ipairs = ipairs
+local pairs = pairs
+local t_insert = table.insert
+local t_sort = table.sort
+local t_concat = table.concat
+local s_format = string.format
+local m_huge = math.huge
+local m_abs = math.abs
+
+local RadiusJewelData = LoadModule("Classes/RadiusJewelData")
+local RadiusJewelItemActions = LoadModule("Classes/RadiusJewelItemActions")
+local COL_META = RadiusJewelData.COL_META
+local getJewelRadiusIndex = RadiusJewelData.getJewelRadiusIndex
+local RadiusJewelCompute
+local getJewelStrategy
+
+-- These sockets have no nearby Keystone. Keep the labels used by the Timeless Jewel finder.
+local SOCKET_ZONE_NAMES = {
+ [26725] = "Marauder",
+ [54127] = "Duelist",
+ [7960] = "Templar/Witch",
+}
+
+---@class RadiusJewelFinder
+local RadiusJewelFinderClass = newClass("RadiusJewelFinder")
+
+function RadiusJewelFinderClass:RadiusJewelFinder(treeTab)
+ self.treeTab = treeTab
+ self.build = treeTab.build
+ self.itemActions = RadiusJewelItemActions.new(self)
+ self.compute = RadiusJewelCompute.new(self)
+ return self
+end
+
+local function calculateImpactPercent(delta, baseline)
+ local baselineMagnitude = m_abs(baseline)
+ return baselineMagnitude > 0 and (delta / baselineMagnitude * 100) or 0
+end
+
+-- Data module imports
+local IMPACT_STATS = RadiusJewelData.buildImpactStats()
+local DISCONNECTED_PASSIVE_COMPUTE_METHODS = RadiusJewelData.DISCONNECTED_PASSIVE_COMPUTE_METHODS
+local OCCUPIED_SOCKET_OPTIONS = RadiusJewelData.OCCUPIED_SOCKET_OPTIONS
+local JEWEL_STRATEGY = RadiusJewelData.JEWEL_STRATEGY
+local buildJewelTypes = RadiusJewelData.buildJewelTypes
+local makeVariantDropdownEntry = RadiusJewelData.makeVariantDropdownEntry
+local findDisconnectedPassiveComputeMethod = RadiusJewelData.findDisconnectedPassiveComputeMethod
+local getSplitPersonalityVariants = RadiusJewelData.getSplitPersonalityVariants
+local getImpossibleEscapeVariants = RadiusJewelData.getImpossibleEscapeVariants
+local mustGetUniqueRawText = RadiusJewelData.mustGetUniqueRawText
+
+-- ─────────────────────────────────────────────────────────────────────────────
+-- Build jewel socket list
+-- ─────────────────────────────────────────────────────────────────────────────
+
+function RadiusJewelFinderClass:buildJewelSockets(largeRadiusIndex)
+ local treeData = self.build.spec.tree
+ local allocNodes = self.build.spec.allocNodes
+ local sockets = { }
+ for socketId, socketData in pairs(self.build.spec.nodes) do
+ if socketData.isJewelSocket and socketData.name ~= "Charm Socket" then
+ local keystone = SOCKET_ZONE_NAMES[socketId] or "Unknown"
+ local minDist = m_huge
+ local socketNode = treeData.nodes[socketId]
+ if not SOCKET_ZONE_NAMES[socketId] and socketNode and socketNode.nodesInRadius and socketNode.nodesInRadius[largeRadiusIndex] then
+ for _, n in pairs(socketNode.nodesInRadius[largeRadiusIndex]) do
+ if n.isKeystone then
+ local dx = n.x - socketData.x
+ local dy = n.y - socketData.y
+ local d = dx * dx + dy * dy
+ if d < minDist then keystone = n.dn or n.name or "Unknown"; minDist = d end
+ end
+ end
+ end
+ local prefix = allocNodes[socketId] and "# " or ""
+ local pd = socketData.pathDist or 0
+ local classStartDist = self.compute:getSocketDistanceToClassStart(socketId)
+ local distStr = (not allocNodes[socketId] and pd < 999) and s_format(" [+%d]", pd) or ""
+ local label = prefix .. keystone .. " (" .. socketId .. ")" .. distStr
+ t_insert(sockets, { label = label, id = socketId, pathDist = pd, classStartDist = classStartDist })
+ end
+ end
+ t_sort(sockets, function(a, b) return a.label < b.label end)
+ return sockets
+end
+
+-- Occupancy is the socket's current item state plus whether a preview replace is safe.
+function RadiusJewelFinderClass:getSocketOccupancyInfo(socketId)
+ local slot = self.build.itemsTab.sockets[socketId]
+ local isSocketAllocated = self.build.spec.allocNodes[socketId] ~= nil
+ if not slot or slot.selItemId == 0 then
+ return {
+ slot = slot,
+ isSocketAllocated = isSocketAllocated,
+ isOccupied = false,
+ isSafeReplace = true,
+ }
+ end
+ local item = self.build.itemsTab.items[slot.selItemId]
+ local itemName = item and (item.title or item.name or item.baseName) or "Unknown item"
+ local itemType = item and item.baseName
+ local itemLabel = itemName
+ if itemType and itemType ~= "" and itemType ~= itemName then
+ itemLabel = itemName .. " (" .. itemType .. ")"
+ end
+ if not isSocketAllocated then
+ return {
+ slot = slot,
+ item = item,
+ itemLabel = itemLabel,
+ isSocketAllocated = false,
+ isOccupied = false,
+ isSafeReplace = true,
+ storedUnallocatedItemLabel = itemLabel,
+ }
+ end
+ local isPositionSensitive = false
+ if item then
+ local jewelData = item.jewelData
+ local impossibleEscapeKeystones = jewelData and jewelData.impossibleEscapeKeystones
+ isPositionSensitive = item.clusterJewel
+ or (jewelData and jewelData.conqueredBy)
+ or item.jewelRadiusIndex ~= nil
+ or (impossibleEscapeKeystones and next(impossibleEscapeKeystones) ~= nil)
+ or (item.title and item.title:match("^Split Personality") ~= nil)
+ end
+ return {
+ slot = slot,
+ item = item,
+ itemLabel = itemLabel,
+ isSocketAllocated = true,
+ isOccupied = true,
+ isSafeReplace = not isPositionSensitive,
+ replacedItemLabel = itemLabel,
+ }
+end
+
+function RadiusJewelFinderClass:socketMatchesOccupiedMode(socketId, occupiedMode)
+ local occupancy = self:getSocketOccupancyInfo(socketId)
+ if not occupancy.isOccupied then
+ return true, occupancy
+ end
+ if not occupiedMode or occupiedMode.id == "free" then
+ return false, occupancy
+ elseif occupiedMode.id == "safe" then
+ return occupancy.isSafeReplace, occupancy
+ end
+ return true, occupancy
+end
+
+function RadiusJewelFinderClass:getSocketBasePoints(socket, occupancy)
+ local socketId = type(socket) == "table" and socket.id or socket
+ occupancy = occupancy or self:getSocketOccupancyInfo(socketId)
+ -- Socket base points are the passive points needed to reach an empty socket; occupied sockets are already paid for.
+ if occupancy and occupancy.isOccupied then
+ return 0
+ end
+ return type(socket) == "table" and (socket.pathDist or 0) or 0
+end
+
+-- Find all sockets where a jewel matching this type is currently equipped.
+-- Returns a list of { socketId, slot, itemId, item } entries with an .atLimit flag.
+-- .atLimit is true when the jewel has a limit and the number of equipped copies >= that limit.
+function RadiusJewelFinderClass:findEquippedJewelSockets(jewelType, variant)
+ local equipped = { }
+ local candidate = variant or jewelType
+ local identity = candidate and candidate.variantIdentity
+ local limitKey = identity and identity.limitKey or candidate.name
+ limitKey = limitKey and limitKey:gsub("^[Ff]oulborn ", "")
+ local limit = identity and identity.limit
+ local allocNodes = self.build.spec.allocNodes
+ for socketId, slot in pairs(self.build.itemsTab.sockets) do
+ if allocNodes[socketId] and slot.selItemId and slot.selItemId ~= 0 then
+ local item = self.build.itemsTab.items[slot.selItemId]
+ local itemName = item and item.title and item.title:gsub("^[Ff]oulborn ", "")
+ if itemName == limitKey then
+ limit = limit or item.limit
+ t_insert(equipped, {
+ socketId = socketId,
+ slot = slot,
+ itemId = slot.selItemId,
+ item = item,
+ })
+ end
+ end
+ end
+ equipped.atLimit = limit ~= nil and #equipped >= limit
+ return equipped
+end
+
+-- Disconnected-passive jewels allocate passives "without being connected to your tree".
+-- Find allocated nodes that depend on Intuitive Leap, Inspired Learning, or Thread of Hope.
+-- Returns a list of nodeIds that should be temporarily unallocated.
+function RadiusJewelFinderClass:findDisconnectedPassiveDependentNodes(socketId, item)
+ local spec = self.build.spec
+ local treeData = spec.tree or self.build.tree
+ local socketNode = treeData.nodes[socketId]
+ if not socketNode then return { } end
+
+ -- Collect all nodes in the jewel's radius
+ local radiusNodes = { }
+ if item.jewelData and item.jewelData.impossibleEscapeKeystones then
+ -- IE: nodes in Small radius around each keystone
+ local smallRI = getJewelRadiusIndex("Small")
+ if smallRI and treeData.keystoneMap then
+ for keystoneName, _ in pairs(item.jewelData.impossibleEscapeKeystones) do
+ local ksNode = treeData.keystoneMap[keystoneName]
+ if ksNode and ksNode.nodesInRadius and ksNode.nodesInRadius[smallRI] then
+ for nodeId, node in pairs(ksNode.nodesInRadius[smallRI]) do
+ radiusNodes[nodeId] = node
+ end
+ end
+ end
+ end
+ elseif item.jewelRadiusIndex and socketNode.nodesInRadius then
+ -- Inspired Learning / Thread of Hope: nodes in the jewel's radius around the socket
+ local nodes = socketNode.nodesInRadius[item.jewelRadiusIndex]
+ if nodes then
+ for nodeId, node in pairs(nodes) do
+ radiusNodes[nodeId] = node
+ end
+ end
+ end
+
+ -- Find allocated nodes in the radius
+ local allocInRadius = { }
+ for nodeId, _ in pairs(radiusNodes) do
+ if spec.allocNodes[nodeId] then
+ allocInRadius[nodeId] = true
+ end
+ end
+ if not next(allocInRadius) then return { } end
+
+ -- Search linked allocated nodes away from the radius edge.
+ -- Note: spec.nodes has `linked`; treeData.nodes does not.
+ local specNodes = spec.nodes
+ local connected = { }
+ local queue = { }
+ for nodeId, _ in pairs(allocInRadius) do
+ local node = specNodes[nodeId]
+ if node and node.linked then
+ for _, other in ipairs(node.linked) do
+ if spec.allocNodes[other.id] and not radiusNodes[other.id] then
+ connected[nodeId] = true
+ t_insert(queue, nodeId)
+ break
+ end
+ end
+ end
+ end
+ -- Continue connectivity within the radius
+ local qi = 1
+ while qi <= #queue do
+ local nodeId = queue[qi]
+ qi = qi + 1
+ local node = specNodes[nodeId]
+ if node and node.linked then
+ for _, other in ipairs(node.linked) do
+ if allocInRadius[other.id] and not connected[other.id] then
+ connected[other.id] = true
+ t_insert(queue, other.id)
+ end
+ end
+ end
+ end
+
+ -- Nodes in radius that are allocated but NOT connected from outside the radius
+ local dependent = { }
+ for nodeId, _ in pairs(allocInRadius) do
+ if not connected[nodeId] then
+ t_insert(dependent, nodeId)
+ end
+ end
+ return dependent
+end
+
+function RadiusJewelFinderClass:removeEquippedJewels(equippedList)
+ local spec = self.build.spec
+ for _, entry in ipairs(equippedList) do
+ -- Find disconnected passive dependent nodes before removing the item
+ entry.savedAllocNodes = { }
+ local dependentNodes = self:findDisconnectedPassiveDependentNodes(entry.socketId, entry.item)
+ for _, nodeId in ipairs(dependentNodes) do
+ entry.savedAllocNodes[nodeId] = spec.allocNodes[nodeId]
+ spec.allocNodes[nodeId] = nil
+ end
+ -- Remove the jewel from the socket
+ entry.savedSelItemId = entry.slot.selItemId
+ entry.savedSpecJewel = spec.jewels[entry.socketId]
+ entry.slot.selItemId = 0
+ spec.jewels[entry.socketId] = 0
+ end
+ return equippedList
+end
+
+function RadiusJewelFinderClass:restoreEquippedJewels(equippedList)
+ local spec = self.build.spec
+ for _, entry in ipairs(equippedList) do
+ if entry.savedSelItemId then
+ entry.slot.selItemId = entry.savedSelItemId
+ spec.jewels[entry.socketId] = entry.savedSpecJewel
+ entry.savedSelItemId = nil
+ entry.savedSpecJewel = nil
+ end
+ if entry.savedAllocNodes then
+ for nodeId, node in pairs(entry.savedAllocNodes) do
+ spec.allocNodes[nodeId] = node
+ end
+ entry.savedAllocNodes = nil
+ end
+ end
+end
+
+local function buildNodeLabelList(nodes)
+ local labels = { }
+ for _, node in ipairs(nodes or { }) do
+ if type(node) == "table" then
+ t_insert(labels, node.label or node.dn or node.name or tostring(node.id or "?"))
+ else
+ t_insert(labels, tostring(node))
+ end
+ end
+ return labels
+end
+
+RadiusJewelCompute = LoadModule("Classes/RadiusJewelCompute")({
+ calculateImpactPercent = calculateImpactPercent,
+ mustGetUniqueRawText = mustGetUniqueRawText,
+ buildNodeLabelList = buildNodeLabelList,
+ getJewelRadiusIndex = getJewelRadiusIndex,
+})
+local buildDisplayedDisconnectedPassivePlans = RadiusJewelCompute.buildDisplayedDisconnectedPassivePlans
+
+-- ─────────────────────────────────────────────────────────────────────────────
+-- Best-per-socket allocation
+-- ─────────────────────────────────────────────────────────────────────────────
+
+--- Filter rows to keep at most one result per socket while applying jewel limits
+--- and use socket-dependent effects before socket-independent ones.
+---
+--- Each row is expected to carry:
+--- socketId (number) – jewel socket id
+--- sortValue (number) – sort key (higher = better)
+--- isEffectSocketIndependent (boolean?) – true when the effect location does not depend on the socket (Impossible Escape)
+--- jewelLimitKey (string?) – key for the "Limited to: X" cap
+--- jewelLimit (number?) – max copies allowed (nil = unlimited)
+--- points (number?) – total points (tie-break for independent)
+function RadiusJewelFinderClass:filterBestPerSocket(rows)
+ local sorted = { }
+ for _, row in ipairs(rows) do
+ t_insert(sorted, row)
+ end
+ t_sort(sorted, function(a, b)
+ return (a.sortValue or 0) > (b.sortValue or 0)
+ end)
+ local usedSockets = { }
+ local limitCounts = { }
+ local filtered = { }
+ -- Pass 1: assign socket-dependent jewels first (they need specific sockets)
+ for _, row in ipairs(sorted) do
+ if not row.isEffectSocketIndependent and not usedSockets[row.socketId] then
+ local limitKey = row.jewelLimitKey
+ local limit = row.jewelLimit
+ if not limit or (limitCounts[limitKey] or 0) < limit then
+ usedSockets[row.socketId] = true
+ if limitKey and limit then
+ limitCounts[limitKey] = (limitCounts[limitKey] or 0) + 1
+ end
+ t_insert(filtered, row)
+ end
+ end
+ end
+ -- Pass 2: assign socket-independent effects (Impossible Escape) to remaining sockets, fewer points first
+ local independentSorted = { }
+ for _, row in ipairs(sorted) do
+ if row.isEffectSocketIndependent then
+ t_insert(independentSorted, row)
+ end
+ end
+ t_sort(independentSorted, function(a, b)
+ local aScore = a.sortValue or 0
+ local bScore = b.sortValue or 0
+ if aScore ~= bScore then
+ return aScore > bScore
+ end
+ return (a.points or 0) < (b.points or 0)
+ end)
+ for _, row in ipairs(independentSorted) do
+ if not usedSockets[row.socketId] then
+ local limitKey = row.jewelLimitKey
+ local limit = row.jewelLimit
+ if not limit or (limitCounts[limitKey] or 0) < limit then
+ usedSockets[row.socketId] = true
+ if limitKey and limit then
+ limitCounts[limitKey] = (limitCounts[limitKey] or 0) + 1
+ end
+ t_insert(filtered, row)
+ end
+ end
+ end
+ t_sort(filtered, function(a, b)
+ return (a.sortValue or 0) > (b.sortValue or 0)
+ end)
+ return filtered
+end
+
+-- ─────────────────────────────────────────────────────────────────────────────
+-- Open popup
+-- ─────────────────────────────────────────────────────────────────────────────
+
+local RadiusJewelResultState = { }
+RadiusJewelResultState.__index = RadiusJewelResultState
+
+function RadiusJewelResultState:new(finder, computeState, controls)
+ return setmetatable({
+ finder = finder,
+ computeState = computeState,
+ controls = controls,
+ }, self)
+end
+
+function RadiusJewelResultState:setResultContext(rows, resultContextKey)
+ for _, row in ipairs(rows or { }) do
+ row.resultContextKey = resultContextKey
+ end
+end
+
+function RadiusJewelResultState:clear(isAllJewels, canFind)
+ self.computeState.lastComputeAllRows = nil
+ self.computeState.lastComputeAllResultContextKey = nil
+ local message = isAllJewels
+ and (COL_META .. "Click Compute to rank all jewels")
+ or not canFind and (COL_META .. "Select a variant for Find, or click Compute")
+ or (COL_META .. "Click Find to search")
+ self.controls.statusLabel.label = message
+ self.controls.resultsList:SetMode("message", { }, "")
+end
+
+function RadiusJewelResultState:showCriteriaChanged(isAllJewels, canFind)
+ local message = (isAllJewels or not canFind)
+ and "^xFFAA33Criteria changed. ^8Run Compute again."
+ or "^xFFAA33Criteria changed. ^8Run Find or Compute again."
+ self.controls.statusLabel.label = message
+ if self.controls.resultsList.mode == "message" or #self.controls.resultsList.list == 0 then
+ self.controls.resultsList:SetMode("message", { }, "")
+ end
+end
+
+function RadiusJewelResultState:isApplicable(row, currentResultContextKey)
+ return row ~= nil and row.actionPlan ~= nil and row.resultContextKey == currentResultContextKey
+ and self.finder.itemActions:isPlanCurrent(row.actionPlan)
+end
+
+local ACTION_LABELS = {
+ equip = "Equip",
+ move = "Move",
+ replace = "Replace",
+ equipped = "Equipped",
+}
+
+local RadiusJewelResultActions = { }
+RadiusJewelResultActions.__index = RadiusJewelResultActions
+
+function RadiusJewelResultActions:new(finder, resultState, resultsList, getResultContextKey)
+ return setmetatable({
+ finder = finder,
+ resultState = resultState,
+ resultsList = resultsList,
+ getResultContextKey = getResultContextKey,
+ }, self)
+end
+
+function RadiusJewelResultActions:getSelectedRow()
+ local index = self.resultsList.selIndex
+ return index and self.resultsList.list[index] or nil
+end
+
+function RadiusJewelResultActions:isApplicable(row)
+ return self.resultState:isApplicable(row, self.getResultContextKey())
+end
+
+function RadiusJewelResultActions:getMatchingBuildItem(row)
+ if not row or not row.actionPlan then
+ return nil
+ end
+ return self.finder.itemActions:findCanonicalVariantMatch(row.actionPlan.targetCanonicalKey)
+end
+
+function RadiusJewelResultActions:execute(row, resultContextKey)
+ if self:isApplicable(row) and row.resultContextKey == resultContextKey then
+ self.finder.itemActions:executePlan(row.actionPlan)
+ end
+end
+
+function RadiusJewelResultActions:applySelected()
+ local row = self:getSelectedRow()
+ local resultContextKey = self.getResultContextKey()
+ if not self:isApplicable(row) then
+ return
+ end
+ local plan = row.actionPlan
+ if not plan.targetSocketAllocated then
+ local actionLabel = ACTION_LABELS[plan.kind] or "Equip"
+ local itemName = plan.targetIdentity.uniqueName or row.jewelName or "jewel"
+ main:OpenConfirmPopup("Unallocated Jewel Socket",
+ "Socket " .. plan.targetSocketLabel .. " is not allocated and is hidden from the Items panel.\n"
+ .. actionLabel .. " will place " .. itemName .. " in that hidden socket.\n"
+ .. "No passive nodes will be allocated.\n\n"
+ .. "Use Add to build instead to keep the jewel in the item list without equipping it.",
+ actionLabel, function()
+ self:execute(row, resultContextKey)
+ end)
+ return
+ end
+ self:execute(row, resultContextKey)
+end
+
+function RadiusJewelResultActions:addSelectedToBuild()
+ local row = self:getSelectedRow()
+ if self:isApplicable(row) then
+ self.finder.itemActions:executeAddToBuildPlan(row.actionPlan)
+ end
+end
+
+function RadiusJewelResultActions:addToBuildLabel()
+ return self:getMatchingBuildItem(self:getSelectedRow()) and "In build" or "Add to build"
+end
+
+function RadiusJewelResultActions:addToBuildEnabled()
+ local row = self:getSelectedRow()
+ return self:isApplicable(row) and not self:getMatchingBuildItem(row)
+end
+
+function RadiusJewelResultActions:addToBuildTooltip(tooltip)
+ local row = self:getSelectedRow()
+ tooltip:Clear(true)
+ if not row or not row.actionPlan then
+ tooltip:AddLine(16, "^7Select a result to add its jewel to the build.")
+ return
+ end
+ local plan = row.actionPlan
+ local itemName = plan.targetIdentity.uniqueName or row.jewelName or "jewel"
+ local existingItem, existingSocket, existingSocketId = self:getMatchingBuildItem(row)
+ if existingItem then
+ local location = existingSocketId and self.finder.itemActions:getSocketLabel(existingSocket, existingSocketId) or "Items"
+ tooltip:AddLine(16, "^8" .. itemName .. " is already in this build in " .. location .. ".")
+ if existingSocketId and self.finder.build.spec.allocNodes[existingSocketId] == nil then
+ tooltip:AddLine(16, "^xFFAA33That socket is unallocated and hidden from the Items panel.")
+ end
+ return
+ end
+ if not self:isApplicable(row) then
+ tooltip:AddLine(16, "^xFFAA33Results are out of date for the current build or criteria.")
+ tooltip:AddLine(16, "^8Run Find or Compute again.")
+ return
+ end
+ tooltip:AddLine(16, "^7Add ^x33FF77" .. itemName .. " ^7to this build without equipping it.")
+ tooltip:AddLine(16, "^7Recommended socket: ^x33FF77" .. plan.targetSocketLabel)
+ tooltip:AddLine(16, "^8The jewel remains in the item list; no sockets or passive allocations change.")
+end
+
+function RadiusJewelResultActions:applyLabel()
+ local row = self:getSelectedRow()
+ local kind = row and row.actionPlan and row.actionPlan.kind
+ return ACTION_LABELS[kind] or "Equip"
+end
+
+function RadiusJewelResultActions:applyEnabled()
+ local row = self:getSelectedRow()
+ return self:isApplicable(row) and row.actionPlan.kind ~= "equipped"
+end
+
+function RadiusJewelResultActions:applyTooltip(tooltip)
+ local row = self:getSelectedRow()
+ if row and row.actionPlan and not self:isApplicable(row) then
+ tooltip:Clear(true)
+ tooltip:AddLine(16, "^xFFAA33Results are out of date for the current build or criteria.")
+ tooltip:AddLine(16, "^8Run Find or Compute again.")
+ return
+ end
+ if not row or not row.actionPlan then
+ tooltip:Clear(true)
+ tooltip:AddLine(16, "^7Select a result to equip.")
+ return
+ end
+ local plan = row.actionPlan
+ tooltip:Clear(true)
+ local itemName = plan.targetIdentity.uniqueName or row.jewelName or "jewel"
+ if plan.kind == "equipped" then
+ tooltip:AddLine(16, "^8" .. itemName .. " is already equipped in " .. plan.targetSocketLabel .. ".")
+ else
+ tooltip:AddLine(16, "^7" .. ACTION_LABELS[plan.kind] .. " ^x33FF77" .. itemName .. " ^7in ^x33FF77" .. plan.targetSocketLabel)
+ if not plan.sourceItemId then
+ tooltip:AddLine(16, "^7Current location: ^8Not in build")
+ elseif not plan.sourceSocketId then
+ tooltip:AddLine(16, "^7Current location: Items")
+ elseif plan.sourceSocketId == plan.targetSocketId then
+ tooltip:AddLine(16, "^7Current location: This socket")
+ else
+ tooltip:AddLine(16, "^7Current location: " .. plan.sourceSocketLabel)
+ end
+ if plan.replacedTargetId then
+ tooltip:AddLine(16, "^xFFAA33Replaces: ^7" .. plan.replacedTargetLabel .. " in " .. plan.targetSocketLabel)
+ end
+ if not plan.targetSocketAllocated then
+ tooltip:AddLine(16, "^xFFAA33This socket is unallocated and hidden from the Items panel.")
+ tooltip:AddLine(16, "^8A confirmation is required; no passive nodes will be allocated.")
+ end
+ end
+ tooltip:AddLine(16, "^8Passive allocations shown in Details are not applied automatically.")
+ if plan.kind ~= "equipped" then
+ tooltip:AddLine(16, "^8Double-click a result to " .. ACTION_LABELS[plan.kind]:lower() .. " it.")
+ end
+end
+
+function RadiusJewelResultActions:bindSelection(onSelect)
+ self.resultsList.OnSelect = function(_, _, row)
+ onSelect(row)
+ end
+ self.resultsList.OnSelClick = function(_, index, value, doubleClick)
+ if doubleClick then
+ self:applySelected()
+ end
+ end
+end
+
+function RadiusJewelResultActions:createControls(anchor, addToBuildRect, applyRect)
+ local addToBuildButton = new("ButtonControl"):ButtonControl(anchor, addToBuildRect,
+ function() return self:addToBuildLabel() end,
+ function() self:addSelectedToBuild() end)
+ addToBuildButton.enabled = function() return self:addToBuildEnabled() end
+ addToBuildButton.tooltipFunc = function(tooltip) self:addToBuildTooltip(tooltip) end
+
+ local applyButton = new("ButtonControl"):ButtonControl(anchor, applyRect,
+ function() return self:applyLabel() end,
+ function() self:applySelected() end)
+ applyButton.enabled = function() return self:applyEnabled() end
+ applyButton.tooltipFunc = function(tooltip) self:applyTooltip(tooltip) end
+ return addToBuildButton, applyButton
+end
+
+local RadiusJewelResultPresentation = { }
+RadiusJewelResultPresentation.__index = RadiusJewelResultPresentation
+
+function RadiusJewelResultPresentation:new(finder, controls, socketViewer, layout)
+ local presentation = setmetatable({
+ finder = finder,
+ controls = controls,
+ layout = layout,
+ resultDetailListData = { },
+ }, self)
+ presentation:createControls(socketViewer)
+ presentation:updateResultDetails(nil)
+ return presentation
+end
+
+function RadiusJewelResultPresentation:buildPreviewLines(request)
+ local jewelType = request.jewelType
+ if not jewelType then
+ return nil
+ end
+ local fn = jewelType.preview
+ if not fn then
+ return nil
+ end
+ local strategy = getJewelStrategy(jewelType)
+ local selectedTypeMatches = request.selectedJewelType
+ and request.selectedJewelType.name == jewelType.name
+ if strategy.usesThreadVariants then
+ local threadVariant = request.previewVariant or request.selectedThreadVariant
+ return fn(threadVariant and threadVariant.name)
+ elseif jewelType.variants then
+ local previewVariant = request.previewVariant
+ if not previewVariant then
+ previewVariant = selectedTypeMatches and request.selectedJewelVariant or nil
+ end
+ if not previewVariant and not selectedTypeMatches then
+ previewVariant = jewelType.variants[1]
+ end
+ return fn(previewVariant)
+ end
+ return fn()
+end
+
+function RadiusJewelResultPresentation:addPreviewLinesToTooltip(tooltip, lines)
+ if type(lines) ~= "table" then
+ return
+ end
+ tooltip:Clear(true)
+ for _, line in ipairs(lines) do
+ tooltip:AddLine(line.height or 16, line[1], line.font)
+ end
+end
+
+function RadiusJewelResultPresentation:buildGenericTypeTooltipLines(request)
+ local jewelType = request.jewelType
+ if not jewelType then
+ return nil
+ end
+ local strategy = getJewelStrategy(jewelType)
+ if not (strategy.usesThreadVariants or jewelType.variants) then
+ local lines = self:buildPreviewLines(request)
+ if type(lines) ~= "table" then
+ return nil
+ end
+ return lines
+ end
+ local fn = jewelType.preview
+ local lines = fn and fn() or nil
+ if type(lines) ~= "table" then
+ return nil
+ end
+ if strategy.usesThreadVariants then
+ return lines
+ end
+
+ local genericLines = { }
+ local blankCount = 0
+ for _, line in ipairs(lines) do
+ t_insert(genericLines, line)
+ if line[1] == "" then
+ blankCount = blankCount + 1
+ if blankCount >= 2 then
+ break
+ end
+ end
+ end
+ t_insert(genericLines, { height = 16, [1] = COL_META .. "Multiple variants available" })
+ return genericLines
+end
+
+function RadiusJewelResultPresentation:resetResultDetailScroll()
+ self.controls.resultDetailList.controls.scrollBar:SetOffset(0)
+end
+
+function RadiusJewelResultPresentation:updateResultDetails(row)
+ wipeTable(self.resultDetailListData)
+ if not row then
+ t_insert(self.resultDetailListData, { height = 16, [1] = COL_META .. "Select a result to view details." })
+ self:resetResultDetailScroll()
+ return
+ end
+ local actionPlan = row.actionPlan
+ local jewelName = actionPlan and actionPlan.targetIdentity.uniqueName or row.jewelName or "jewel"
+ t_insert(self.resultDetailListData, { height = 16, [1] = "^7Jewel: ^x33FF77" .. jewelName })
+ if row.variantLabel and row.variantLabel ~= "" then
+ t_insert(self.resultDetailListData, { height = 16, [1] = "^7Variant: " .. row.variantLabel })
+ end
+ t_insert(self.resultDetailListData, { height = 16, [1] = "^7Socket: " .. (row.socketLabel or "(n/a)") })
+ if actionPlan then
+ local currentLocation
+ if not actionPlan.sourceItemId then
+ currentLocation = "^8Not in build"
+ elseif not actionPlan.sourceSocketId then
+ currentLocation = "^7Items"
+ elseif actionPlan.sourceSocketId == actionPlan.targetSocketId then
+ currentLocation = "^7This socket"
+ else
+ currentLocation = "^7" .. actionPlan.sourceSocketLabel
+ end
+ t_insert(self.resultDetailListData, { height = 16, [1] = "^7Current location: " .. currentLocation })
+ end
+ local action = actionPlan and actionPlan.kind or row.action
+ local replacementItem = actionPlan and actionPlan.replacedTargetId
+ and self.finder.build.itemsTab.items[actionPlan.replacedTargetId]
+ if not replacementItem and (row.replacedItemLabel or row.storedUnallocatedItemLabel) then
+ local occupancy = self.finder:getSocketOccupancyInfo(row.socketId)
+ replacementItem = occupancy and occupancy.item
+ end
+ local replacementLabel = actionPlan and actionPlan.replacedTargetLabel
+ or row.replacedItemLabel or row.storedUnallocatedItemLabel
+ if action == "replace" then
+ replacementLabel = replacementLabel or "?"
+ end
+ if replacementLabel and (action == "move" or action == "replace") then
+ t_insert(self.resultDetailListData, { height = 16, [1] = "^xFFAA33Will replace: ^7" .. replacementLabel, item = replacementItem })
+ end
+ local isRecommendation = row.resultNodes ~= nil
+ local nodeEntries = isRecommendation and row.resultNodes or row.topNodes
+ local detailTextAlreadyShown = row.detailText == row.variantLabel
+ if isRecommendation and #nodeEntries > 0 then
+ local nodeCountLabel = s_format("%d node%s", #nodeEntries, #nodeEntries == 1 and "" or "s")
+ detailTextAlreadyShown = detailTextAlreadyShown or row.detailText == nodeCountLabel
+ or row.variantLabel and row.variantLabel ~= "" and row.detailText == row.variantLabel .. " | " .. nodeCountLabel
+ end
+ if row.detailText and row.detailText ~= "" and not detailTextAlreadyShown then
+ t_insert(self.resultDetailListData, { height = 16, [1] = "^7" .. row.detailText })
+ end
+ if nodeEntries then
+ t_insert(self.resultDetailListData, { height = 6, [1] = "" })
+ if #nodeEntries > 0 then
+ t_insert(self.resultDetailListData, {
+ height = 16,
+ [1] = isRecommendation and s_format("^7Recommended passives (%d):", #nodeEntries)
+ or s_format("^7Notables and keystones in range (%d):", #nodeEntries),
+ })
+ for _, nodeEntry in ipairs(nodeEntries) do
+ t_insert(self.resultDetailListData, {
+ height = 16,
+ [1] = "^xC8C8C8- " .. (nodeEntry.label or tostring(nodeEntry)),
+ nodeId = nodeEntry.nodeId,
+ })
+ end
+ else
+ t_insert(self.resultDetailListData, { height = 16, [1] = isRecommendation
+ and (COL_META .. "No recommended passives")
+ or (COL_META .. "No notables or keystones in range") })
+ end
+ end
+ self:resetResultDetailScroll()
+end
+
+function RadiusJewelResultPresentation:createControls(socketViewer)
+ local controls = self.controls
+ local layout = self.layout
+ controls.resultDetailLabel = new("LabelControl"):LabelControl(layout.anchor,
+ { layout.x, layout.y, 0, 16 }, "^7Details:")
+ controls.resultDetailList = new("RadiusJewelDetailListControl"):RadiusJewelDetailListControl(layout.anchor,
+ { layout.x, layout.y + 18, layout.width, layout.bottomY - layout.y - 18 },
+ { { x = 0, align = "LEFT" } }, self.resultDetailListData, self.finder.build, socketViewer)
+end
+
+local function buildRadiusJewelPopupSetup(self)
+ local treeData = self.build.spec.tree
+ local radiusIndexByLabel = {
+ Small = getJewelRadiusIndex("Small"),
+ Large = getJewelRadiusIndex("Large"),
+ }
+ local threadVariants = RadiusJewelData.getThreadOfHopeVariants()
+
+ local threadVariantLabels = { "Any ring" }
+ for _, variant in ipairs(threadVariants) do
+ t_insert(threadVariantLabels, variant.ringLabel or (variant.name .. " Ring"))
+ end
+ local impactStatLabels = { }
+ for _, stat in ipairs(IMPACT_STATS) do
+ t_insert(impactStatLabels, stat.label)
+ end
+ local occupiedModeLabels = { }
+ for _, option in ipairs(OCCUPIED_SOCKET_OPTIONS) do
+ t_insert(occupiedModeLabels, option.label)
+ end
+
+ local finderState = self.build.radiusJewelFinderState or { }
+ self.build.radiusJewelFinderState = finderState
+
+ local allJewelsViewOptions = {
+ { id = "all", label = "All results" },
+ { id = "bestPerSocket", label = "Best per socket" },
+ }
+ local allJewelsViewLabels = { }
+ for _, option in ipairs(allJewelsViewOptions) do
+ t_insert(allJewelsViewLabels, option.label)
+ end
+
+ local edgePadding = 10
+ local leftPanelWidth = 580
+ local rightPanelWidth = 410
+ local variantDefaultX = 278
+ local variantGroupWidth = 150
+ local layout = {
+ TL = { "TOPLEFT", nil, "TOPLEFT" },
+ BL = { "BOTTOMLEFT", nil, "BOTTOMLEFT" },
+ BR = { "BOTTOMRIGHT", nil, "BOTTOMRIGHT" },
+ edgePadding = edgePadding,
+ buttonHeight = 20,
+ leftPanelWidth = leftPanelWidth,
+ rightPanelWidth = rightPanelWidth,
+ popupWidth = edgePadding * 3 + leftPanelWidth + rightPanelWidth,
+ popupHeight = 474,
+ rightPanelX = edgePadding * 2 + leftPanelWidth,
+ headerLabelY = 18,
+ headerInputY = 34,
+ statusLabelY = 62,
+ contentTopY = 78,
+ resultListBottomY = 430,
+ variantDefaultX = variantDefaultX,
+ variantDefaultWidth = 260,
+ variantGroupX = variantDefaultX,
+ variantGroupWidth = variantGroupWidth,
+ variantFilteredX = variantDefaultX + variantGroupWidth + 8,
+ bottomButtonY = -edgePadding,
+ bottomInputY = -(edgePadding + 2),
+ bottomLabelY = -(edgePadding + 4),
+ }
+ layout.variantFilteredWidth = edgePadding + leftPanelWidth - layout.variantFilteredX
+
+ return {
+ treeData = treeData,
+ radiusIndexByLabel = radiusIndexByLabel,
+ threadVariants = threadVariants,
+ jewelSockets = self:buildJewelSockets(radiusIndexByLabel["Large"]),
+ allVariantGroupsValue = "ALL",
+ allVariantsLabel = "All variants",
+ threadVariantLabels = threadVariantLabels,
+ impactStatLabels = impactStatLabels,
+ occupiedModeLabels = occupiedModeLabels,
+ finderState = finderState,
+ allJewelsViewOptions = allJewelsViewOptions,
+ allJewelsViewLabels = allJewelsViewLabels,
+ socketViewer = new("PassiveTreeView"):PassiveTreeView(),
+ layout = layout,
+ }
+end
+
+local function collectFindTopNodes(nodes)
+ local topNodes = { }
+ for _, node in pairs(nodes) do
+ if not node.ascendancyName and (node.type == "Notable" or node.type == "Keystone") then
+ t_insert(topNodes, {
+ label = node.dn or node.name or "Unknown",
+ nodeId = node.id,
+ })
+ end
+ end
+ t_sort(topNodes, function(a, b) return a.label < b.label end)
+ return topNodes
+end
+
+local function prepareRadiusFind(request)
+ local selectedVariant = request.selectedVariant
+ local radiusIndex = selectedVariant and selectedVariant.radiusIndex or request.jewelType.radiusIndex
+ if not radiusIndex then
+ return
+ end
+ return {
+ radiusIndex = radiusIndex,
+ }
+end
+
+local function findRadiusSocket(_, request, findState)
+ local nodes = request.socketNode.nodesInRadius[findState.radiusIndex]
+ if not nodes then
+ return
+ end
+ local selectedVariant = request.selectedVariant
+ local scoreFn = selectedVariant and selectedVariant.score or request.jewelType.score
+ local detailBuilder = selectedVariant and selectedVariant.detailBuilder or request.jewelType.detailBuilder
+ return {
+ socket = request.socket,
+ score = scoreFn(nodes, request.allocNodes) or 0,
+ topNodes = collectFindTopNodes(nodes),
+ variant = selectedVariant,
+ detailText = detailBuilder and detailBuilder(nodes, request.allocNodes) or nil,
+ replacedItemLabel = request.occupancy and request.occupancy.replacedItemLabel or nil,
+ storedUnallocatedItemLabel = request.occupancy and request.occupancy.storedUnallocatedItemLabel or nil,
+ }
+end
+
+local function findThreadSocket(_, request)
+ local bestResult
+ for _, variant in ipairs(request.threadVariants) do
+ local nodes = request.socketNode.nodesInRadius[variant.radiusIndex]
+ if nodes then
+ local candidate = {
+ socket = request.socket,
+ score = request.jewelType.score(nodes, request.allocNodes) or 0,
+ topNodes = collectFindTopNodes(nodes),
+ variant = variant,
+ replacedItemLabel = request.occupancy and request.occupancy.replacedItemLabel or nil,
+ storedUnallocatedItemLabel = request.occupancy and request.occupancy.storedUnallocatedItemLabel or nil,
+ }
+ if not bestResult
+ or candidate.score > bestResult.score
+ or (candidate.score == bestResult.score and candidate.variant.radiusIndex < bestResult.variant.radiusIndex) then
+ bestResult = candidate
+ end
+ end
+ end
+ return bestResult
+end
+
+local function prepareImpossibleEscapeFind(request)
+ local bestResult
+ local smallRadiusIndex = request.radiusIndexByLabel["Small"]
+ for _, variant in ipairs(request.selectedVariants or request.jewelType.variants or { }) do
+ local keystoneNode = request.treeData.keystoneMap[variant.keystoneName]
+ local nodes = keystoneNode and keystoneNode.nodesInRadius and smallRadiusIndex
+ and keystoneNode.nodesInRadius[smallRadiusIndex]
+ if nodes then
+ local candidate = {
+ score = request.jewelType.score(nodes, request.allocNodes) or 0,
+ topNodes = collectFindTopNodes(nodes),
+ variant = variant,
+ detailText = variant.name,
+ }
+ if not bestResult
+ or candidate.score > bestResult.score
+ or (candidate.score == bestResult.score and candidate.variant.name < bestResult.variant.name) then
+ bestResult = candidate
+ end
+ end
+ end
+ return { bestResult = bestResult }
+end
+
+local function findImpossibleEscapeSocket(_, request, findState)
+ local bestResult = findState.bestResult
+ if not bestResult then
+ return
+ end
+ return {
+ socket = request.socket,
+ score = bestResult.score,
+ topNodes = bestResult.topNodes,
+ variant = bestResult.variant,
+ detailText = bestResult.detailText,
+ replacedItemLabel = request.occupancy and request.occupancy.replacedItemLabel or nil,
+ storedUnallocatedItemLabel = request.occupancy and request.occupancy.storedUnallocatedItemLabel or nil,
+ }
+end
+
+local function findSplitPersonalitySocket(self, request)
+ local score = request.socket.classStartDist or self.compute:getSocketDistanceToClassStart(request.socket.id)
+ return {
+ socket = request.socket,
+ score = score,
+ detailText = s_format("dist to start %d", score),
+ replacedItemLabel = request.occupancy and request.occupancy.replacedItemLabel or nil,
+ storedUnallocatedItemLabel = request.occupancy and request.occupancy.storedUnallocatedItemLabel or nil,
+ }
+end
+
+local function copyComputeRequestWith(request, field, value)
+ local requestCopy = copyTableSafe(request, true)
+ requestCopy[field] = value
+ return requestCopy
+end
+
+local function computeRadiusStrategy(compute, jewelType, request)
+ if request.variants and #request.variants > 0 then
+ return compute:computeBestVariantSocketImpact(request)
+ end
+ return compute:computeSocketImpact(copyComputeRequestWith(request, "rawText", jewelType.rawText))
+end
+
+local function computeIntuitiveLeapStrategy(compute, _, request)
+ return compute:computeBestIntuitiveLeapSocketImpact(request)
+end
+
+local function computeThreadOfHopeStrategy(compute, _, request)
+ return compute:computeThreadOfHopeSocketImpact(copyComputeRequestWith(request, "variants", request.threadVariants))
+end
+
+local function computeImpossibleEscapeStrategy(compute, jewelType, request)
+ local variants = request.variants or jewelType.variants or getImpossibleEscapeVariants()
+ return compute:computeImpossibleEscapeSocketImpact(copyComputeRequestWith(request, "variants", variants))
+end
+
+local function computeSplitPersonalityStrategy(compute, jewelType, request)
+ local variants = request.variants or jewelType.variants or getSplitPersonalityVariants()
+ return compute:computeSplitPersonalitySocketImpact(copyComputeRequestWith(request, "variants", variants))
+end
+
+local JEWEL_STRATEGIES = {
+ [JEWEL_STRATEGY.RADIUS] = {
+ prepareFind = prepareRadiusFind,
+ findSocket = findRadiusSocket,
+ compute = computeRadiusStrategy,
+ usesVariantPartitions = true,
+ },
+ [JEWEL_STRATEGY.INTUITIVE_LEAP] = {
+ prepareFind = prepareRadiusFind,
+ findSocket = findRadiusSocket,
+ compute = computeIntuitiveLeapStrategy,
+ computeMethods = DISCONNECTED_PASSIVE_COMPUTE_METHODS,
+ showsDisconnectedPassivePlans = true,
+ computeTooltipHeader = "^7Socketing this jewel and allocating the best nodes here will give you:",
+ keepBestAllJewelsRowPerSocket = true,
+ },
+ [JEWEL_STRATEGY.THREAD_OF_HOPE] = {
+ findSocket = findThreadSocket,
+ compute = computeThreadOfHopeStrategy,
+ computeMethods = DISCONNECTED_PASSIVE_COMPUTE_METHODS,
+ usesThreadVariants = true,
+ findsAllVariants = true,
+ findAllVariantsTooltip = "^7Find compares every ring and ranks compatible sockets.",
+ showsDisconnectedPassivePlans = true,
+ computeTooltipHeader = "^7Socketing this jewel and allocating the best ring plan here will give you:",
+ resultMode = "findThread",
+ appendMatchCount = true,
+ keepBestAllJewelsRowPerSocket = true,
+ formatVariantLabel = function(variant)
+ return variant.ringLabel or (variant.name .. " Ring")
+ end,
+ formatFindStatus = function(request, resultCount)
+ local variants = request.threadVariants
+ local label = #variants == 1
+ and ("Thread of Hope (" .. (variants[1].ringLabel or (variants[1].name .. " Ring")) .. ")")
+ or "Thread of Hope (Any ring)"
+ return s_format("^7%s | %d | score/pt", label, resultCount)
+ end,
+ formatComputeLabel = function(jewelType, request)
+ local variants = request.threadVariants
+ return #variants == 1
+ and (jewelType.name .. " (" .. (variants[1].ringLabel or (variants[1].name .. " Ring")) .. ")")
+ or (jewelType.name .. " (Any ring)")
+ end,
+ },
+ [JEWEL_STRATEGY.IMPOSSIBLE_ESCAPE] = {
+ prepareFind = prepareImpossibleEscapeFind,
+ findSocket = findImpossibleEscapeSocket,
+ compute = computeImpossibleEscapeStrategy,
+ computeMethods = DISCONNECTED_PASSIVE_COMPUTE_METHODS,
+ findsAllVariants = true,
+ findAllVariantsTooltip = "^7Find compares every displayed Keystone variant and ranks compatible sockets.",
+ showsDisconnectedPassivePlans = true,
+ isEffectSocketIndependent = true,
+ computeTooltipHeader = "^7Socketing this jewel and allocating the best keystone plan here will give you:",
+ appendMatchCount = true,
+ keepBestAllJewelsRowPerSocket = true,
+ getDetailNodeId = function(treeData, variant)
+ local keystoneNode = variant and treeData.keystoneMap[variant.keystoneName]
+ return keystoneNode and keystoneNode.id or nil
+ end,
+ formatFindStatus = function(_, resultCount)
+ return s_format("^7Impossible Escape | %d | score/pt", resultCount)
+ end,
+ },
+ [JEWEL_STRATEGY.SPLIT_PERSONALITY] = {
+ findSocket = findSplitPersonalitySocket,
+ compute = computeSplitPersonalityStrategy,
+ allowsSocketWithoutRadius = true,
+ formatFindStatus = function(_, resultCount)
+ return s_format("^7Split Personality | %d | score/pt", resultCount)
+ end,
+ },
+ [JEWEL_STRATEGY.ALL_JEWELS] = {
+ isAllJewels = true,
+ computeMethods = DISCONNECTED_PASSIVE_COMPUTE_METHODS,
+ },
+}
+
+getJewelStrategy = function(jewelType)
+ local strategy = jewelType and JEWEL_STRATEGIES[jewelType.strategy]
+ assert(strategy, "Missing radius jewel strategy: " .. tostring(jewelType and jewelType.name))
+ return strategy
+end
+
+local function computeJewelType(self, jewelType, request)
+ local strategy = getJewelStrategy(jewelType)
+ assert(strategy.compute, "Radius jewel strategy cannot compute: " .. jewelType.name)
+ return strategy.compute(self.compute, jewelType, request)
+end
+
+local function runRadiusJewelFind(self, context)
+ local controls = context.controls
+ local treeData = context.treeData
+ local radiusIndexByLabel = context.radiusIndexByLabel
+ local threadVariants = context.threadVariants
+ local jewelSockets = context.jewelSockets
+ local selectedJewelType = context.selectedJewelType
+ local selectedJewelVariant = context.selectedJewelVariant
+ local selectedMaxPoints = context.selectedMaxPoints
+ local selectedOccupiedMode = context.selectedOccupiedMode
+ local resultContextKey = context.resultContextKey
+ local getSelectedVariants = context.getSelectedVariants
+ local formatElapsed = context.formatElapsed
+ local setResultContext = context.setResultContext
+ local showAllJewelsComputePrompt = context.showAllJewelsComputePrompt
+
+ local searchStartTime = GetTime()
+ local selectedStrategy = selectedJewelType and getJewelStrategy(selectedJewelType)
+ if selectedStrategy and selectedStrategy.isAllJewels then
+ showAllJewelsComputePrompt()
+ return
+ end
+ controls.statusLabel.label = "^7Searching..."
+ local ok, err = pcall(function()
+ local allocNodes = self.build.spec.allocNodes
+ local strategy = selectedStrategy
+ assert(strategy.findSocket, "Radius jewel strategy cannot find: " .. selectedJewelType.name)
+ local findRequest = {
+ jewelType = selectedJewelType,
+ selectedVariant = selectedJewelVariant,
+ selectedVariants = getSelectedVariants(),
+ threadVariants = threadVariants,
+ treeData = treeData,
+ radiusIndexByLabel = radiusIndexByLabel,
+ allocNodes = allocNodes,
+ }
+ local findState = { }
+ if strategy.prepareFind then
+ findState = strategy.prepareFind(findRequest)
+ if not findState then
+ return
+ end
+ end
+ local results = { }
+ for _, socket in ipairs(jewelSockets) do
+ local socketAllowed, occupancy = self:socketMatchesOccupiedMode(socket.id, selectedOccupiedMode)
+ local socketNode = treeData.nodes[socket.id]
+ local socketPoints = self:getSocketBasePoints(socket, occupancy)
+ if socketAllowed and (not selectedMaxPoints or socketPoints <= selectedMaxPoints)
+ and socketNode and (socketNode.nodesInRadius or strategy.allowsSocketWithoutRadius) then
+ findRequest.socket = socket
+ findRequest.socketNode = socketNode
+ findRequest.occupancy = occupancy
+ local result = strategy.findSocket(self, findRequest, findState)
+ if result then
+ t_insert(results, result)
+ end
+ end
+ end
+
+ t_sort(results, function(a, b) return (a.score or 0) > (b.score or 0) end)
+
+ local equippedVariant = selectedJewelVariant
+ local equippedList = self:findEquippedJewelSockets(selectedJewelType, equippedVariant)
+ local equippedSocketIds = { }
+ for _, entry in ipairs(equippedList) do
+ equippedSocketIds[entry.socketId] = true
+ end
+ local rows = { }
+ for _, r in ipairs(results) do
+ local topNodes = r.topNodes or { }
+ local topLabels = buildNodeLabelList(topNodes)
+ local topStr = t_concat(topLabels, ", ")
+ if #topStr > 50 then
+ topStr = topStr:sub(1, 47) .. "..."
+ end
+
+ local scoreLabel = (selectedJewelType.variants and selectedJewelVariant and selectedJewelVariant.scoreLabel)
+ or selectedJewelType.scoreLabel
+ local isEquippedSocket = equippedSocketIds[r.socket.id]
+ local points = isEquippedSocket and 0
+ or self:getSocketBasePoints(r.socket, { isOccupied = r.replacedItemLabel ~= nil })
+ local scorePerPoint = points > 0 and (r.score / points) or r.score
+ local sortValue = points > 0 and scorePerPoint or r.score
+ local detailText = r.detailText
+ if not detailText or detailText == "" then
+ detailText = #topNodes > 0 and s_format("%d match%s", #topNodes, #topNodes == 1 and "" or "es") or scoreLabel
+ elseif #topStr > 0 and strategy.appendMatchCount then
+ detailText = detailText .. s_format(" | %d match%s", #topNodes, #topNodes == 1 and "" or "es")
+ end
+ local detailNodeId = strategy.getDetailNodeId and strategy.getDetailNodeId(treeData, r.variant) or nil
+ local targetIdentity = r.variant and r.variant.variantIdentity
+ or selectedJewelVariant and selectedJewelVariant.variantIdentity
+ or selectedJewelType.variantIdentity
+ local targetRawText = targetIdentity and targetIdentity.rawText
+ or r.variant and r.variant.rawText
+ or selectedJewelVariant and selectedJewelVariant.rawText
+ or selectedJewelType.rawText
+ local actionPlan = self.itemActions:buildPlan({
+ socketId = r.socket.id,
+ socketLabel = r.socket.label,
+ targetIdentity = targetIdentity,
+ targetRawText = targetRawText,
+ })
+ t_insert(rows, {
+ socketLabel = r.socket.label,
+ socketId = r.socket.id,
+ points = points,
+ score = r.score or 0,
+ scorePerPoint = scorePerPoint,
+ sortValue = sortValue,
+ variantLabel = r.variant and (strategy.formatVariantLabel and strategy.formatVariantLabel(r.variant)
+ or r.variant.dropdownLabel or r.variant.name) or "",
+ detailText = detailText,
+ detailNodeId = detailNodeId,
+ topNodes = r.topNodes and copyTableSafe(r.topNodes, false, true),
+ replacedItemLabel = r.replacedItemLabel,
+ storedUnallocatedItemLabel = r.storedUnallocatedItemLabel,
+ action = actionPlan and actionPlan.kind or nil,
+ actionPlan = actionPlan,
+ })
+ end
+ setResultContext(rows, resultContextKey)
+ local resultMode = strategy.resultMode or "find"
+ controls.resultsList:SetMode(resultMode, rows, COL_META .. "(no results)")
+ local elapsed = formatElapsed(searchStartTime)
+ controls.statusLabel.label = (strategy.formatFindStatus
+ and strategy.formatFindStatus(findRequest, #results)
+ or s_format("^7%d results | score/pt", #results)) .. elapsed
+ end)
+ if not ok then
+ controls.statusLabel.label = "^1Search failed"
+ controls.resultsList:SetMode("message", {
+ { text = "^1" .. tostring(err) },
+ }, "")
+ end
+end
+
+local function runRadiusJewelCompute(self, context)
+ local controls = context.controls
+ local computeState = context.computeState
+ local cancelCompute = context.cancelCompute
+ local setComputeProgress = context.setComputeProgress
+ local makeComputeProgressTracker = context.makeComputeProgressTracker
+ local selectedImpactStat = context.selectedImpactStat
+ local selectedComputeMethod = context.selectedComputeMethod
+ local selectedJewelType = context.selectedJewelType
+ local selectedStrategy = getJewelStrategy(selectedJewelType)
+ local activeJewelTypes = context.activeJewelTypes
+ local jewelSockets = context.jewelSockets
+ local threadVariants = context.threadVariants
+ local selectedMaxPoints = context.selectedMaxPoints
+ local selectedOccupiedMode = context.selectedOccupiedMode
+ local buildComputeRows = context.buildComputeRows
+ local getSelectedAllJewelsView = context.getSelectedAllJewelsView
+ local formatComputeStatus = context.formatComputeStatus
+ local formatElapsed = context.formatElapsed
+ local setResultContext = context.setResultContext
+ local getSelectedVariants = context.getSelectedVariants
+ local hasVariantGroups = context.hasVariantGroups
+ local selectedVariantGroup = context.selectedVariantGroup
+ local ALL_VARIANT_GROUPS_VALUE = context.allVariantGroupsValue
+ local resultContextKey = context.resultContextKey
+
+ if computeState.computeContext then
+ cancelCompute("^8Compute stopped")
+ context.clearResultsForContext()
+ return
+ end
+
+ controls.computeButton.label = "Cancel"
+ local searchStartTime = GetTime()
+ local planCache = { }
+ computeState.lastComputeAllRows = nil
+ computeState.lastComputeAllResultContextKey = nil
+ setComputeProgress("^7Computing...")
+ local progress = makeComputeProgressTracker()
+ computeState.computeContext = {
+ resultContextKey = resultContextKey,
+ co = coroutine.create(function()
+ local ok, err = pcall(function()
+ local statLabel = selectedImpactStat.label
+ local computeMethod = selectedComputeMethod or findDisconnectedPassiveComputeMethod(nil)
+ local computeMethodLabel = selectedStrategy.computeMethods and computeMethod.label or nil
+ local function makeComputeRequest(variants, computeProgress, skipPlanSteps)
+ return {
+ sockets = jewelSockets,
+ variants = variants,
+ threadVariants = threadVariants,
+ impactStat = selectedImpactStat,
+ methodId = computeMethod.id,
+ planCache = planCache,
+ progress = computeProgress,
+ maxTotalPoints = selectedMaxPoints,
+ occupiedMode = selectedOccupiedMode,
+ skipPlanSteps = skipPlanSteps,
+ }
+ end
+ local function computeVariantPartitionRows(jewelType, variants, computeProgress)
+ local partitions = { }
+ local partitionByLimitKey = { }
+ for _, variant in ipairs(variants) do
+ local identity = variant.variantIdentity
+ local limitKey = identity and identity.limitKey or variant.name
+ local partition = partitionByLimitKey[limitKey]
+ if not partition then
+ partition = { representative = variant, variants = { } }
+ partitionByLimitKey[limitKey] = partition
+ t_insert(partitions, partition)
+ end
+ t_insert(partition.variants, variant)
+ end
+
+ local bestRowBySocket = { }
+ local baseline
+ for partitionIndex, partition in ipairs(partitions) do
+ local partitionProgress = computeProgress:child(
+ (partitionIndex - 1) / #partitions,
+ 1 / #partitions)
+ local equippedList = self:findEquippedJewelSockets(jewelType, partition.representative)
+ local removedJewels = equippedList.atLimit and self:removeEquippedJewels(equippedList) or { }
+ computeState.computeContext.removedJewels = removedJewels
+ local socketResults, partitionBaseline = computeJewelType(self, jewelType,
+ makeComputeRequest(partition.variants, partitionProgress))
+ baseline = baseline or partitionBaseline
+ self:restoreEquippedJewels(removedJewels)
+ computeState.computeContext.removedJewels = nil
+
+ for _, row in ipairs(buildComputeRows(jewelType, socketResults, partitionBaseline, equippedList)) do
+ local bestRow = bestRowBySocket[row.socketId]
+ if not bestRow or row.delta > bestRow.delta then
+ bestRowBySocket[row.socketId] = row
+ end
+ end
+ end
+
+ local rows = { }
+ for _, row in pairs(bestRowBySocket) do
+ t_insert(rows, row)
+ end
+ t_sort(rows, function(a, b)
+ if a.delta ~= b.delta then
+ return a.delta > b.delta
+ end
+ return a.socketLabel < b.socketLabel
+ end)
+ return rows, baseline or 0
+ end
+
+ if selectedStrategy.isAllJewels then
+ local allRows = { }
+ local globalBaseline
+
+ local computeJewelTypes = { }
+ for _, jt in ipairs(activeJewelTypes) do
+ if not getJewelStrategy(jt).isAllJewels then
+ t_insert(computeJewelTypes, jt)
+ end
+ end
+
+ for typeIndex, jt in ipairs(computeJewelTypes) do
+ local rawChild = progress:child(
+ (typeIndex - 1) / #computeJewelTypes,
+ 1 / #computeJewelTypes)
+ local jtName = jt.name
+ local function wrapProgress(base)
+ return {
+ tick = function(self, done, total, label)
+ base:tick(done, total, label and (jtName .. " | " .. label) or jtName)
+ end,
+ child = function(self, startFraction, spanFraction)
+ return wrapProgress(base:child(startFraction, spanFraction))
+ end,
+ }
+ end
+ local typeProgress = wrapProgress(rawChild)
+ local socketResults, baseline
+ local typeRows
+ local strategy = getJewelStrategy(jt)
+ local useVariantPartitions = strategy.usesVariantPartitions
+ and jt.variants and #jt.variants > 0
+
+ if useVariantPartitions then
+ typeRows, baseline = computeVariantPartitionRows(jt, jt.variants, typeProgress)
+ else
+ local equippedList = self:findEquippedJewelSockets(jt)
+ local removedJewels = equippedList.atLimit and self:removeEquippedJewels(equippedList) or { }
+ computeState.computeContext.removedJewels = removedJewels
+ socketResults, baseline = computeJewelType(self, jt,
+ makeComputeRequest(jt.variants, typeProgress, true))
+ self:restoreEquippedJewels(removedJewels)
+ computeState.computeContext.removedJewels = nil
+ typeRows = buildComputeRows(jt, socketResults, baseline, equippedList)
+ end
+
+ globalBaseline = globalBaseline or baseline
+
+ if strategy.keepBestAllJewelsRowPerSocket then
+ local bestBySocket = { }
+ for _, row in ipairs(typeRows) do
+ local ex = bestBySocket[row.socketId]
+ if not ex or row.sortValue > ex.sortValue then
+ bestBySocket[row.socketId] = row
+ end
+ end
+ typeRows = { }
+ for _, row in pairs(bestBySocket) do
+ t_insert(typeRows, row)
+ end
+ end
+
+ for _, row in ipairs(typeRows) do
+ t_insert(allRows, row)
+ end
+ end
+
+ globalBaseline = globalBaseline or 0
+ setResultContext(allRows, resultContextKey)
+ computeState.lastComputeAllRows = allRows
+ computeState.lastComputeAllResultContextKey = resultContextKey
+ local displayRows = getSelectedAllJewelsView().id == "bestPerSocket"
+ and self:filterBestPerSocket(allRows) or allRows
+ controls.resultsList:SetMode("computeSocketAll", displayRows, COL_META .. "(no compatible sockets)")
+ controls.statusLabel.label = formatComputeStatus("All jewels", statLabel, globalBaseline, computeMethodLabel) .. formatElapsed(searchStartTime)
+ else
+ local displayedVariants = getSelectedVariants()
+ local strategy = selectedStrategy
+ local computeRequest = makeComputeRequest(displayedVariants, progress)
+ local itemLabel = strategy.formatComputeLabel
+ and strategy.formatComputeLabel(selectedJewelType, computeRequest)
+ or selectedJewelType.name
+ local socketResults, baseline
+ local rows
+ local useVariantPartitions = strategy.usesVariantPartitions
+ and displayedVariants and #displayedVariants > 1
+ if useVariantPartitions then
+ if hasVariantGroups() and selectedVariantGroup and selectedVariantGroup.value ~= ALL_VARIANT_GROUPS_VALUE then
+ itemLabel = selectedVariantGroup.name
+ end
+ rows, baseline = computeVariantPartitionRows(selectedJewelType, displayedVariants, progress)
+ else
+ local equippedVariant = displayedVariants and #displayedVariants == 1 and displayedVariants[1] or nil
+ local equippedList = self:findEquippedJewelSockets(selectedJewelType, equippedVariant)
+ local removedJewels = equippedList.atLimit and self:removeEquippedJewels(equippedList) or { }
+ computeState.computeContext.removedJewels = removedJewels
+ if strategy.usesVariantPartitions and displayedVariants and #displayedVariants > 0
+ and hasVariantGroups() and selectedVariantGroup
+ and selectedVariantGroup.value ~= ALL_VARIANT_GROUPS_VALUE then
+ itemLabel = selectedVariantGroup.name
+ end
+ socketResults, baseline = computeJewelType(self, selectedJewelType, computeRequest)
+ self:restoreEquippedJewels(removedJewels)
+ computeState.computeContext.removedJewels = nil
+ rows = buildComputeRows(selectedJewelType, socketResults, baseline, equippedList)
+ end
+ setResultContext(rows, resultContextKey)
+ controls.resultsList:SetMode("computeSocket", rows, COL_META .. "(no compatible sockets)")
+ controls.statusLabel.label = formatComputeStatus(itemLabel, statLabel, baseline, computeMethodLabel) .. formatElapsed(searchStartTime)
+ end
+ end)
+ if not ok then
+ error(err)
+ end
+ end),
+ }
+ main.onFrameFuncs["RadiusJewelFinderCompute"] = function()
+ if not computeState.computeContext then
+ main.onFrameFuncs["RadiusJewelFinderCompute"] = nil
+ return
+ end
+ if not context.isResultContextCurrent(resultContextKey) then
+ cancelCompute()
+ context.clearResultsForContext()
+ return
+ end
+ local res, errMsg = coroutine.resume(computeState.computeContext.co)
+ if not res then
+ cancelCompute()
+ controls.statusLabel.label = "^1Compute failed"
+ controls.resultsList:SetMode("message", {
+ { text = "^1" .. tostring(errMsg) },
+ }, "")
+ return
+ end
+ if coroutine.status(computeState.computeContext.co) == "dead" then
+ cancelCompute()
+ end
+ end
+end
+
+local function buildRadiusJewelPopupContext(self)
+ local setup = buildRadiusJewelPopupSetup(self)
+ local layout = setup.layout
+ local treeData = setup.treeData
+ local radiusIndexByLabel = setup.radiusIndexByLabel
+ local threadVariants = setup.threadVariants
+ local jewelSockets = setup.jewelSockets
+ local ALL_VARIANT_GROUPS_VALUE = setup.allVariantGroupsValue
+ local ALL_VARIANTS_LABEL = setup.allVariantsLabel
+ local ALL_JEWELS_VIEW_OPTIONS = setup.allJewelsViewOptions
+
+ local TL = layout.TL
+ local BL = layout.BL
+ local BR = layout.BR
+ local edgePadding = layout.edgePadding
+ local buttonHeight = layout.buttonHeight
+ local leftPanelWidth = layout.leftPanelWidth
+ local rightPanelWidth = layout.rightPanelWidth
+ local popupWidth = layout.popupWidth
+ local popupHeight = layout.popupHeight
+ local rightPanelX = layout.rightPanelX
+ local headerLabelY = layout.headerLabelY
+ local headerInputY = layout.headerInputY
+ local statusLabelY = layout.statusLabelY
+ local contentTopY = layout.contentTopY
+ local resultListBottomY = layout.resultListBottomY
+ local variantDefaultX = layout.variantDefaultX
+ local variantDefaultWidth = layout.variantDefaultWidth
+ local variantGroupX = layout.variantGroupX
+ local variantGroupWidth = layout.variantGroupWidth
+ local variantFilteredX = layout.variantFilteredX
+ local variantFilteredWidth = layout.variantFilteredWidth
+ local bottomButtonY = layout.bottomButtonY
+ local bottomInputY = layout.bottomInputY
+ local bottomLabelY = layout.bottomLabelY
+
+ local jewelTypes
+ local showLegacy = false
+ local activeJewelTypes = { }
+ local selectedJewelType
+ local selectedThreadVariant
+ local selectedJewelVariant
+ local selectedComputeMethod = DISCONNECTED_PASSIVE_COMPUTE_METHODS[1]
+ local selectedMaxPoints = 20
+ local selectedOccupiedMode = OCCUPIED_SOCKET_OPTIONS[1]
+ local variantGroupOptions = { { name = "All", value = ALL_VARIANT_GROUPS_VALUE } }
+ local selectedVariantGroup = variantGroupOptions[1]
+ local controls = { }
+ local jtLabels = { }
+ local tvLabels = setup.threadVariantLabels
+ local socketViewer = setup.socketViewer
+ local impactStatLabels = setup.impactStatLabels
+ local occupiedModeLabels = setup.occupiedModeLabels
+ local selectedImpactStat = IMPACT_STATS[1]
+ local finderState = setup.finderState
+ local allJewelsViewLabels = setup.allJewelsViewLabels
+ local selectedAllJewelsView = ALL_JEWELS_VIEW_OPTIONS[1]
+ local computeState = { }
+ local resultState = RadiusJewelResultState:new(self, computeState, controls)
+
+ local suppressFinderStateSave = false
+ local runFind
+ local cancelCompute
+ local function getSelectedJewelStrategy()
+ return selectedJewelType and getJewelStrategy(selectedJewelType) or nil
+ end
+ local function canFindCurrentSelection()
+ local strategy = getSelectedJewelStrategy()
+ if not strategy or strategy.isAllJewels then
+ return false
+ end
+ if strategy.findsAllVariants then
+ return true
+ end
+ return not selectedJewelType.variants or selectedJewelVariant ~= nil
+ end
+
+ local function formatElapsed(startTime)
+ if not startTime then return "" end
+ local ms = GetTime() - startTime
+ if ms < 1000 then
+ return s_format(" ^8(%d ms)", ms)
+ end
+ return s_format(" ^8(%.1fs)", ms / 1000)
+ end
+
+ local function saveFinderState()
+ if suppressFinderStateSave then
+ return
+ end
+ finderState.showLegacy = showLegacy
+ finderState.jewelTypeName = selectedJewelType and selectedJewelType.name or nil
+ finderState.jewelVariantName = selectedJewelVariant and (selectedJewelVariant.dropdownLabel or selectedJewelVariant.name) or nil
+ finderState.threadVariantName = selectedThreadVariant and selectedThreadVariant.name or nil
+ finderState.variantGroupValue = selectedVariantGroup and selectedVariantGroup.value or nil
+ finderState.dreamFamilyValue = nil
+ finderState.impactStatLabel = selectedImpactStat and selectedImpactStat.label or nil
+ finderState.computeMethodId = selectedComputeMethod and selectedComputeMethod.id or nil
+ finderState.maxPoints = selectedMaxPoints
+ finderState.occupiedModeId = selectedOccupiedMode and selectedOccupiedMode.id or nil
+ finderState.allJewelsViewId = selectedAllJewelsView and selectedAllJewelsView.id or nil
+ end
+
+ local function getResultContextKey()
+ local strategy = getSelectedJewelStrategy()
+ local selectedVariantIdentity = selectedJewelVariant and selectedJewelVariant.variantIdentity
+ local selectedVariantKey = selectedVariantIdentity and selectedVariantIdentity.rawText
+ or selectedJewelVariant and (selectedJewelVariant.dropdownLabel or selectedJewelVariant.name)
+ or ""
+ local variantGroupKey = #variantGroupOptions > 1 and selectedVariantGroup and selectedVariantGroup.value or ""
+ local supportsComputeMethods = strategy and strategy.computeMethods and #strategy.computeMethods > 0
+ local computeMethodKey = supportsComputeMethods and selectedComputeMethod and selectedComputeMethod.id or ""
+ local legacyKey = strategy and strategy.isAllJewels and showLegacy and "1" or "0"
+ local threadVariantKey = strategy and strategy.usesThreadVariants
+ and (selectedThreadVariant and selectedThreadVariant.rawText or "ANY") or ""
+ return table.concat({
+ tostring(self.build.outputRevision or 0),
+ selectedJewelType and selectedJewelType.name or "",
+ selectedVariantKey,
+ variantGroupKey,
+ threadVariantKey,
+ selectedImpactStat and selectedImpactStat.field or "",
+ computeMethodKey,
+ selectedMaxPoints and tostring(selectedMaxPoints) or "",
+ selectedOccupiedMode and selectedOccupiedMode.id or "",
+ legacyKey,
+ }, "|")
+ end
+
+ local function setResultContext(rows, resultContextKey)
+ resultState:setResultContext(rows, resultContextKey)
+ end
+
+ local function clearResultsForContext()
+ local strategy = getSelectedJewelStrategy()
+ resultState:clear(strategy and strategy.isAllJewels, canFindCurrentSelection())
+ end
+ local function showCriteriaChangedForContext()
+ local strategy = getSelectedJewelStrategy()
+ resultState:showCriteriaChanged(strategy and strategy.isAllJewels, canFindCurrentSelection())
+ end
+ local function isResultContextCurrent(resultContextKey)
+ return resultContextKey == getResultContextKey()
+ end
+ local function onCriteriaChanged(updateCriteria)
+ cancelCompute()
+ updateCriteria()
+ saveFinderState()
+ showCriteriaChangedForContext()
+ end
+ local function formatComputeStatus(itemLabel, statLabel, baseline, methodLabel)
+ if methodLabel and methodLabel ~= "" then
+ return s_format("^7%s | %s %.1f | %s | %%/pt", itemLabel, statLabel, baseline, methodLabel)
+ end
+ return s_format("^7%s | %s %.1f | %%/pt", itemLabel, statLabel, baseline)
+ end
+ local function formatReplacementLabel(replacedItemLabel)
+ return replacedItemLabel and ("Replace " .. replacedItemLabel) or "Free socket"
+ end
+ local function setComputeProgress(message)
+ controls.statusLabel.label = message
+ controls.resultsList:SetMode("message", { }, "")
+ end
+ cancelCompute = function(statusMessage)
+ if not computeState.computeContext then
+ return
+ end
+ if computeState.computeContext.removedJewels and #computeState.computeContext.removedJewels > 0 then
+ self:restoreEquippedJewels(computeState.computeContext.removedJewels)
+ end
+ main.onFrameFuncs["RadiusJewelFinderCompute"] = nil
+ computeState.computeContext = nil
+ if controls.computeButton then
+ controls.computeButton.label = "Compute"
+ end
+ if statusMessage then
+ controls.statusLabel.label = statusMessage
+ end
+ end
+ local function getSelectedComputeMethods()
+ local strategy = getSelectedJewelStrategy()
+ return strategy and strategy.computeMethods or nil
+ end
+ local function selectedJewelSupportsComputeMethods()
+ local methods = getSelectedComputeMethods()
+ return methods and #methods > 0
+ end
+
+ local function makeVariantGroupLabel(group)
+ return group:gsub("^The%s+", "")
+ end
+
+ local function buildVariantGroupOptions(variants)
+ local options = { { name = "All", value = ALL_VARIANT_GROUPS_VALUE } }
+ local counts = { }
+ for _, variant in ipairs(variants or { }) do
+ if variant.variantGroup then
+ counts[variant.variantGroup] = (counts[variant.variantGroup] or 0) + 1
+ end
+ end
+ for _, variant in ipairs(variants or { }) do
+ local group = variant.variantGroup
+ if group and counts[group] and counts[group] > 1 then
+ counts[group] = nil
+ t_insert(options, { name = makeVariantGroupLabel(group), value = group })
+ end
+ end
+ return options
+ end
+
+ local function syncVariantGroupSelect()
+ variantGroupOptions = buildVariantGroupOptions(selectedJewelType and selectedJewelType.variants)
+ local labels = { }
+ local selectedIndex = 1
+ for i, option in ipairs(variantGroupOptions) do
+ t_insert(labels, option.name)
+ if selectedVariantGroup and option.value == selectedVariantGroup.value then
+ selectedIndex = i
+ end
+ end
+ selectedVariantGroup = variantGroupOptions[selectedIndex]
+ if controls.variantGroupSelect then
+ controls.variantGroupSelect:SetList(labels)
+ controls.variantGroupSelect.selIndex = selectedIndex
+ end
+ return #variantGroupOptions > 1
+ end
+
+ local function hasVariantGroups()
+ return #variantGroupOptions > 1
+ end
+
+ local function getDisplayedVariants()
+ if not selectedJewelType or not selectedJewelType.variants then
+ return nil
+ end
+ if hasVariantGroups() and selectedVariantGroup and selectedVariantGroup.value ~= ALL_VARIANT_GROUPS_VALUE then
+ local variants = { }
+ for _, variant in ipairs(selectedJewelType.variants) do
+ if variant.variantGroup == selectedVariantGroup.value then
+ t_insert(variants, variant)
+ end
+ end
+ return variants
+ end
+ return selectedJewelType.variants
+ end
+
+ local function getSelectedVariants()
+ local variants = getDisplayedVariants()
+ if not variants then
+ return nil
+ end
+ if selectedJewelVariant then
+ return { selectedJewelVariant }
+ end
+ return variants
+ end
+
+ local function getSelectedThreadVariants()
+ return selectedThreadVariant and { selectedThreadVariant } or threadVariants
+ end
+
+ local function isAnyFinderDropdownDropped()
+ return (controls.jewelTypeSelect and controls.jewelTypeSelect.dropped)
+ or (controls.jewelVariantSelect and controls.jewelVariantSelect.dropped)
+ or (controls.threadVariantSelect and controls.threadVariantSelect.dropped)
+ or (controls.variantGroupSelect and controls.variantGroupSelect.dropped)
+ or (controls.allJewelsViewSelect and controls.allJewelsViewSelect.dropped)
+ or (controls.impactStatSelect and controls.impactStatSelect.dropped)
+ or (controls.occupiedModeSelect and controls.occupiedModeSelect.dropped)
+ end
+
+ local function syncDisplayedVariants()
+ local variants = getDisplayedVariants()
+ if not variants then
+ controls.jewelVariantSelect:SetList({ })
+ controls.jewelVariantSelect.selIndex = nil
+ selectedJewelVariant = nil
+ saveFinderState()
+ return
+ end
+ if #variants == 0 then
+ controls.jewelVariantSelect:SetList({ })
+ controls.jewelVariantSelect.selIndex = nil
+ selectedJewelVariant = nil
+ saveFinderState()
+ return
+ end
+ local variantNames = { }
+ t_insert(variantNames, ALL_VARIANTS_LABEL)
+ for _, v in ipairs(variants) do
+ t_insert(variantNames, makeVariantDropdownEntry(v))
+ end
+ controls.jewelVariantSelect:SetList(variantNames)
+ local varIdx = 1
+ local matchedVariant
+ if selectedJewelVariant then
+ for i, variant in ipairs(variants) do
+ if variant == selectedJewelVariant then
+ varIdx = i + 1
+ matchedVariant = variant
+ break
+ end
+ end
+ else
+ varIdx = 1
+ end
+ if not matchedVariant then
+ selectedJewelVariant = nil
+ end
+ if varIdx > #variantNames then
+ varIdx = 1
+ selectedJewelVariant = nil
+ end
+ controls.jewelVariantSelect.selIndex = varIdx
+ saveFinderState()
+ end
+
+ local resultPresentation = RadiusJewelResultPresentation:new(self, controls, socketViewer, {
+ anchor = TL,
+ x = rightPanelX,
+ y = contentTopY,
+ width = rightPanelWidth,
+ bottomY = resultListBottomY,
+ })
+ local function buildPreviewRequest(jewelType, previewVariant)
+ return {
+ jewelType = jewelType,
+ previewVariant = previewVariant,
+ selectedJewelType = selectedJewelType,
+ selectedJewelVariant = selectedJewelVariant,
+ selectedThreadVariant = selectedThreadVariant,
+ }
+ end
+ local function buildPreviewLinesForJewelType(jewelType, previewVariant)
+ return resultPresentation:buildPreviewLines(buildPreviewRequest(jewelType, previewVariant))
+ end
+ local function buildGenericTypeTooltipLinesForJewelType(jewelType)
+ return resultPresentation:buildGenericTypeTooltipLines(buildPreviewRequest(jewelType))
+ end
+ local function addPreviewLinesToTooltip(tooltip, lines)
+ resultPresentation:addPreviewLinesToTooltip(tooltip, lines)
+ end
+ controls.resultsList = new("RadiusJewelResultsListControl"):RadiusJewelResultsListControl(TL, { edgePadding, contentTopY, leftPanelWidth, resultListBottomY - contentTopY }, self.build, socketViewer)
+ controls.resultsList.suppressTooltipFunc = isAnyFinderDropdownDropped
+ local resultActions = RadiusJewelResultActions:new(self, resultState, controls.resultsList, getResultContextKey)
+ resultActions:bindSelection(function(row)
+ resultPresentation:updateResultDetails(row)
+ end)
+ controls.resultsList:SetMode("message", { }, "")
+
+ local function rebuildJewelTypeDropdown()
+ jewelTypes = buildJewelTypes()
+ activeJewelTypes = { }
+ jtLabels = { }
+ for _, jt in ipairs(jewelTypes) do
+ if showLegacy or not jt.isLegacy then
+ t_insert(activeJewelTypes, jt)
+ end
+ end
+ t_sort(activeJewelTypes, function(a, b)
+ if a.name ~= b.name then
+ return a.name < b.name
+ end
+ if a.isLegacy ~= b.isLegacy then
+ return a.isLegacy == false
+ end
+ return false
+ end)
+ t_insert(activeJewelTypes, 1, {
+ name = "All jewels",
+ strategy = JEWEL_STRATEGY.ALL_JEWELS,
+ })
+ for _, jt in ipairs(activeJewelTypes) do
+ t_insert(jtLabels, jt.name)
+ end
+ if controls.jewelTypeSelect then
+ controls.jewelTypeSelect:SetList(jtLabels)
+ -- Keep the current selection if it remains visible; otherwise reset to the first entry.
+ local selIdx = 1
+ for i, jt in ipairs(activeJewelTypes) do
+ if selectedJewelType and jt.name == selectedJewelType.name then
+ selIdx = i
+ break
+ end
+ end
+ controls.jewelTypeSelect.selIndex = selIdx
+ selectedJewelType = activeJewelTypes[selIdx]
+
+ local hasVariants = selectedJewelType.variants ~= nil
+ controls.jewelVariantLabel.shown = hasVariants
+ controls.jewelVariantSelect.shown = hasVariants
+ if hasVariants then
+ syncDisplayedVariants()
+ else
+ selectedJewelVariant = nil
+ end
+ saveFinderState()
+ else
+ -- Select the initial entry before controls exist.
+ selectedJewelType = activeJewelTypes[1]
+ end
+ end
+ rebuildJewelTypeDropdown()
+
+ controls.jewelTypeLabel = new("LabelControl"):LabelControl(TL, { edgePadding, headerLabelY, 0, 16 }, "^7Type:")
+
+ controls.computeMethodLabel = new("LabelControl"):LabelControl(TL, { rightPanelX, headerLabelY, 0, 16 }, "^7Method:")
+ controls.computeMethodSelect = new("DropDownControl"):DropDownControl(TL, { rightPanelX, headerInputY, 160, buttonHeight }, { }, function(idx)
+ onCriteriaChanged(function()
+ local methods = getSelectedComputeMethods()
+ if methods then
+ selectedComputeMethod = methods[idx]
+ end
+ end)
+ end)
+ local function addComputeMethodTooltip(tooltip, mode, index)
+ local methods = getSelectedComputeMethods()
+ local method = (index and methods and methods[index]) or selectedComputeMethod
+ tooltip:Clear(true)
+ local strategy = getSelectedJewelStrategy()
+ if strategy and strategy.isAllJewels then
+ tooltip:AddLine(16, "^7Used for Intuitive Leap, Thread of Hope, and Impossible Escape.")
+ else
+ tooltip:AddLine(16, "^7Controls how passives are selected for this jewel.")
+ end
+ if method and method.id == "simulated_greedy" then
+ tooltip:AddLine(16, "^8Simulated recalculates after each chosen passive.")
+ else
+ tooltip:AddLine(16, "^8Fast scores candidate passives independently.")
+ end
+ end
+ controls.computeMethodLabel.tooltipFunc = addComputeMethodTooltip
+ controls.computeMethodSelect.tooltipFunc = addComputeMethodTooltip
+ controls.computeMethodLabel.shown = false
+ controls.computeMethodSelect.shown = false
+
+ -- Impact stat selector
+ controls.impactStatLabel = new("LabelControl"):LabelControl(TL, { rightPanelX + 180, headerLabelY, 0, 16 }, "^7Stat:")
+ controls.impactStatSelect = new("DropDownControl"):DropDownControl(TL, { rightPanelX + 180, headerInputY, 140, buttonHeight }, impactStatLabels, function(idx)
+ onCriteriaChanged(function()
+ selectedImpactStat = IMPACT_STATS[idx]
+ end)
+ end)
+ controls.impactStatLabel.shown = true
+ controls.impactStatSelect.shown = true
+
+ controls.maxPointsLabel = new("LabelControl"):LabelControl(BL, { edgePadding + 110, bottomLabelY, 0, 16 }, "^7Max points:")
+ controls.maxPointsEdit = new("EditControl"):EditControl(BL, { edgePadding + 190, bottomInputY, 56, buttonHeight }, tostring(selectedMaxPoints), nil, "%D", 3, function(buf)
+ onCriteriaChanged(function()
+ selectedMaxPoints = buf ~= "" and tonumber(buf) or nil
+ end)
+ end)
+ local function addMaxPointsTooltip(tooltip)
+ tooltip:Clear(true)
+ tooltip:AddLine(16, "^7Maximum Points per result.")
+ tooltip:AddLine(16, "^8For Compute, this includes pathing and passives to allocate.")
+ tooltip:AddLine(16, "^8Leave blank for no limit.")
+ end
+ controls.maxPointsLabel.tooltipFunc = addMaxPointsTooltip
+ controls.maxPointsEdit.tooltipFunc = addMaxPointsTooltip
+ controls.maxPointsLabel.shown = true
+ controls.maxPointsEdit.shown = true
+
+ controls.occupiedModeLabel = new("LabelControl"):LabelControl(BL, { edgePadding + 256, bottomLabelY, 0, 16 }, "^7Sockets:")
+ controls.occupiedModeSelect = new("DropDownControl"):DropDownControl(BL, { edgePadding + 314, bottomInputY, 150, buttonHeight }, occupiedModeLabels, function(idx)
+ onCriteriaChanged(function()
+ selectedOccupiedMode = OCCUPIED_SOCKET_OPTIONS[idx]
+ end)
+ end)
+ local function addOccupiedModeTooltip(tooltip, mode, index)
+ local option = (index and OCCUPIED_SOCKET_OPTIONS[index]) or selectedOccupiedMode
+ tooltip:Clear(true)
+ if not option or option.id == "free" then
+ tooltip:AddLine(16, "^7Only try empty jewel sockets.")
+ elseif option.id == "safe" then
+ tooltip:AddLine(16, "^7Try empty sockets and safe occupied sockets.")
+ tooltip:AddLine(16, "^8Safe means the current jewel has no socket-specific behavior.")
+ else
+ tooltip:AddLine(16, "^7Try empty and occupied jewel sockets.")
+ tooltip:AddLine(16, "^8May suggest replacing socket-specific jewels.")
+ end
+ end
+ controls.occupiedModeLabel.tooltipFunc = addOccupiedModeTooltip
+ controls.occupiedModeSelect.tooltipFunc = addOccupiedModeTooltip
+ controls.occupiedModeLabel.shown = true
+ controls.occupiedModeSelect.shown = true
+
+ -- All-jewels view mode selector
+ controls.allJewelsViewLabel = new("LabelControl"):LabelControl(TL, { variantDefaultX, headerLabelY, 0, 16 }, "^7View:")
+ controls.allJewelsViewSelect = new("DropDownControl"):DropDownControl(TL, { variantDefaultX, headerInputY, 160, 20 }, allJewelsViewLabels, function(idx)
+ selectedAllJewelsView = ALL_JEWELS_VIEW_OPTIONS[idx]
+ if computeState.lastComputeAllRows
+ and isResultContextCurrent(computeState.lastComputeAllResultContextKey) then
+ local displayRows = selectedAllJewelsView.id == "bestPerSocket"
+ and self:filterBestPerSocket(computeState.lastComputeAllRows) or computeState.lastComputeAllRows
+ controls.resultsList:SetMode("computeSocketAll", displayRows, COL_META .. "(no compatible sockets)")
+ end
+ saveFinderState()
+ end)
+ local function addAllJewelsViewTooltip(tooltip, mode, index)
+ local option = (index and ALL_JEWELS_VIEW_OPTIONS[index]) or selectedAllJewelsView
+ tooltip:Clear(true)
+ if option and option.id == "bestPerSocket" then
+ tooltip:AddLine(16, "^7Keep one best result per socket.")
+ tooltip:AddLine(16, "^8Jewel limits still apply.")
+ else
+ tooltip:AddLine(16, "^7Show every compatible result.")
+ end
+ end
+ controls.allJewelsViewLabel.tooltipFunc = addAllJewelsViewTooltip
+ controls.allJewelsViewSelect.tooltipFunc = addAllJewelsViewTooltip
+ controls.allJewelsViewLabel.shown = false
+ controls.allJewelsViewSelect.shown = false
+
+ -- Thread ring selector (shown when Thread of Hope selected)
+ controls.threadVariantLabel = new("LabelControl"):LabelControl(TL, { variantDefaultX, headerLabelY, 0, 16 }, "^7Ring:")
+ controls.threadVariantSelect = new("DropDownControl"):DropDownControl(TL, { variantDefaultX, headerInputY, 200, 20 }, tvLabels, function(idx)
+ onCriteriaChanged(function()
+ selectedThreadVariant = idx == 1 and nil or threadVariants[idx - 1]
+ end)
+ end)
+ controls.threadVariantLabel.shown = false
+ controls.threadVariantSelect.shown = false
+
+ controls.variantGroupLabel = new("LabelControl"):LabelControl(TL, { variantGroupX, headerLabelY, 0, 16 }, "^7Jewel:")
+ controls.variantGroupSelect = new("DropDownControl"):DropDownControl(TL, { variantGroupX, headerInputY, variantGroupWidth, 20 }, { "All" }, function(idx)
+ onCriteriaChanged(function()
+ selectedVariantGroup = variantGroupOptions[idx] or variantGroupOptions[1]
+ controls.jewelVariantSelect.selIndex = 1
+ selectedJewelVariant = nil
+ syncDisplayedVariants()
+ end)
+ end)
+ controls.variantGroupLabel.shown = false
+ controls.variantGroupSelect.shown = false
+
+ -- Jewel variant selector (shown when jewel type has built-in variants)
+ controls.jewelVariantLabel = new("LabelControl"):LabelControl(TL, { variantDefaultX, headerLabelY, 0, 16 }, "^7Variant:")
+ controls.jewelVariantSelect = new("DropDownControl"):DropDownControl(TL, { variantDefaultX, headerInputY, variantDefaultWidth, 20 }, {}, function(idx)
+ onCriteriaChanged(function()
+ local variants = getDisplayedVariants()
+ if variants then
+ selectedJewelVariant = idx == 1 and nil or variants[idx - 1]
+ end
+ end)
+ end)
+ controls.jewelVariantSelect.enableDroppedWidth = true
+ controls.jewelVariantSelect.maxDroppedWidth = 520
+ controls.jewelVariantLabel.shown = false
+ controls.jewelVariantSelect.shown = false
+
+ local function syncVariantControlLayout(hasVariantGroupFilter)
+ if hasVariantGroupFilter then
+ controls.jewelVariantLabel.x = variantFilteredX
+ controls.jewelVariantSelect.x = variantFilteredX
+ controls.jewelVariantSelect.width = variantFilteredWidth
+ else
+ controls.jewelVariantLabel.x = variantDefaultX
+ controls.jewelVariantSelect.x = variantDefaultX
+ controls.jewelVariantSelect.width = variantDefaultWidth
+ end
+ end
+
+ local function syncComputeMethodSelect(methods)
+ methods = methods or getSelectedComputeMethods()
+ if not methods or #methods == 0 then
+ controls.computeMethodSelect:SetList({ })
+ controls.computeMethodSelect.selIndex = nil
+ return
+ end
+ local methodLabels = { }
+ for _, method in ipairs(methods) do
+ t_insert(methodLabels, method.label)
+ end
+ local selectedIndex = 1
+ for i, method in ipairs(methods) do
+ if selectedComputeMethod and method.id == selectedComputeMethod.id then
+ selectedIndex = i
+ break
+ end
+ end
+ selectedComputeMethod = methods[selectedIndex]
+ controls.computeMethodSelect:SetList(methodLabels)
+ controls.computeMethodSelect.selIndex = selectedIndex
+ end
+
+ local function syncSelectedJewelTypeControls()
+ local strategy = getSelectedJewelStrategy()
+ if strategy.isAllJewels then
+ controls.allJewelsViewLabel.shown = true
+ controls.allJewelsViewSelect.shown = true
+ controls.threadVariantLabel.shown = false
+ controls.threadVariantSelect.shown = false
+ controls.variantGroupLabel.shown = false
+ controls.variantGroupSelect.shown = false
+ controls.jewelVariantLabel.shown = false
+ controls.jewelVariantSelect.shown = false
+ controls.computeMethodLabel.shown = true
+ controls.computeMethodSelect.shown = true
+ controls.impactStatLabel.shown = true
+ controls.impactStatSelect.shown = true
+ syncComputeMethodSelect(strategy.computeMethods)
+ if controls.computeButton then
+ controls.computeButton.shown = true
+ end
+ if controls.findButton then
+ controls.findButton.shown = false
+ end
+ selectedJewelVariant = nil
+ return
+ end
+ controls.allJewelsViewLabel.shown = false
+ controls.allJewelsViewSelect.shown = false
+ local usesThreadVariants = strategy.usesThreadVariants == true
+ local hasVariants = selectedJewelType.variants ~= nil
+ local hasVariantGroupFilter = syncVariantGroupSelect()
+ local hasComputeMethods = selectedJewelSupportsComputeMethods()
+ syncVariantControlLayout(hasVariantGroupFilter)
+
+ controls.threadVariantLabel.shown = usesThreadVariants
+ controls.threadVariantSelect.shown = usesThreadVariants
+ controls.variantGroupLabel.shown = hasVariantGroupFilter
+ controls.variantGroupSelect.shown = hasVariantGroupFilter
+ controls.jewelVariantLabel.shown = hasVariants
+ controls.jewelVariantSelect.shown = hasVariants
+ controls.computeMethodLabel.shown = hasComputeMethods
+ controls.computeMethodSelect.shown = hasComputeMethods
+ controls.impactStatLabel.shown = true
+ controls.impactStatSelect.shown = true
+ if controls.findButton then
+ controls.findButton.shown = true
+ end
+ if controls.computeButton then
+ controls.computeButton.shown = true
+ end
+
+ if hasVariants then
+ if not hasVariantGroupFilter then
+ selectedVariantGroup = variantGroupOptions[1]
+ controls.variantGroupSelect.selIndex = 1
+ end
+ syncDisplayedVariants()
+ else
+ selectedJewelVariant = nil
+ end
+ if hasComputeMethods then
+ syncComputeMethodSelect(strategy.computeMethods)
+ end
+ end
+
+ -- Jewel type dropdown (defined after variant controls so :Click() is safe)
+ controls.jewelTypeSelect = new("DropDownControl"):DropDownControl(TL, { 10, headerInputY, 260, 20 }, jtLabels, function(idx)
+ onCriteriaChanged(function()
+ selectedJewelType = activeJewelTypes[idx]
+ controls.jewelVariantSelect.selIndex = 1
+ syncSelectedJewelTypeControls()
+ end)
+ end)
+ controls.jewelTypeSelect.tooltipFunc = function(tooltip, mode, index)
+ local jewelType = activeJewelTypes[index]
+ local strategy = jewelType and getJewelStrategy(jewelType)
+ if strategy and strategy.isAllJewels then
+ tooltip:Clear(true)
+ tooltip:AddLine(16, "^7Evaluate every jewel type at once.")
+ tooltip:AddLine(16, "^7Results sorted globally by %/Pt.")
+ return
+ end
+ addPreviewLinesToTooltip(tooltip, buildGenericTypeTooltipLinesForJewelType(jewelType))
+ end
+ controls.jewelVariantSelect.tooltipFunc = function(tooltip, mode, index)
+ local variants = getDisplayedVariants()
+ if not selectedJewelType or not variants then
+ return
+ end
+ if not index then
+ addPreviewLinesToTooltip(tooltip, buildPreviewLinesForJewelType(selectedJewelType))
+ return
+ end
+ if index == 1 then
+ addPreviewLinesToTooltip(tooltip, buildGenericTypeTooltipLinesForJewelType(selectedJewelType))
+ if getSelectedJewelStrategy().findsAllVariants then
+ tooltip:AddLine(16, "^8Find and Compute compare every displayed Keystone variant.")
+ else
+ tooltip:AddLine(16, "^7Find ranks sockets for one exact variant.")
+ tooltip:AddLine(16, "^8Choose a variant, or use Compute to compare the displayed variants by the selected stat.")
+ end
+ return
+ end
+ local variant = variants[index - 1]
+ if variant then
+ addPreviewLinesToTooltip(tooltip, buildPreviewLinesForJewelType(selectedJewelType, variant))
+ end
+ end
+ controls.threadVariantSelect.tooltipFunc = function(tooltip, mode, index)
+ if not selectedJewelType then
+ return
+ end
+ if index == 1 then
+ addPreviewLinesToTooltip(tooltip, buildGenericTypeTooltipLinesForJewelType(selectedJewelType))
+ tooltip:AddLine(16, "^8Find and Compute compare every ring.")
+ return
+ end
+ local variant = threadVariants[index - 1]
+ if not variant then return end
+ addPreviewLinesToTooltip(tooltip, buildPreviewLinesForJewelType(selectedJewelType, variant))
+ tooltip:AddLine(16, "^8Find and Compute use only this ring.")
+ end
+ syncSelectedJewelTypeControls()
+
+ local function makeComputeProgressTracker()
+ local tracker
+ local function setFraction(self, fraction, label)
+ local nextFraction = math.max(0, math.min(fraction or 0, 1))
+ if nextFraction < self.fraction then
+ nextFraction = self.fraction
+ end
+ self.fraction = nextFraction
+ local pct = math.floor(nextFraction * 100)
+ local text = label and s_format("^7Computing... %d%% | %s", pct, label) or s_format("^7Computing... %d%%", pct)
+ setComputeProgress(text)
+ local now = GetTime()
+ if now - self.lastYield > 50 then
+ self.lastYield = now
+ coroutine.yield()
+ end
+ end
+ local function makeChild(root, startFraction, spanFraction)
+ return {
+ root = root,
+ startFraction = startFraction or 0,
+ spanFraction = spanFraction or 1,
+ tick = function(self, done, total, label)
+ local localFraction = total and total > 0 and (done / total) or 0
+ self.root:setFraction(self.startFraction + localFraction * self.spanFraction, label)
+ end,
+ child = function(self, childStartFraction, childSpanFraction)
+ return makeChild(
+ self.root,
+ self.startFraction + (childStartFraction or 0) * self.spanFraction,
+ (childSpanFraction or 1) * self.spanFraction
+ )
+ end,
+ }
+ end
+ tracker = {
+ lastYield = GetTime(),
+ fraction = 0,
+ setFraction = setFraction,
+ tick = function(self, done, total, label)
+ local fraction = total and total > 0 and (done / total) or 0
+ self:setFraction(fraction, label)
+ end,
+ child = function(self, startFraction, spanFraction)
+ return makeChild(self, startFraction, spanFraction)
+ end,
+ }
+ return tracker
+ end
+ local function buildComputeRows(jewelType, socketResults, baseline, equippedList)
+ local strategy = getJewelStrategy(jewelType)
+ local rows = { }
+ for _, r in ipairs(socketResults) do
+ local rowEquippedList = r.variant and self:findEquippedJewelSockets(jewelType, r.variant) or equippedList or { }
+ local equippedSocketIds = { }
+ local existingSocketId
+ for _, entry in ipairs(rowEquippedList) do
+ equippedSocketIds[entry.socketId] = true
+ if rowEquippedList.atLimit then
+ existingSocketId = existingSocketId or entry.socketId
+ end
+ end
+ -- For limited jewels at capacity, find the keep delta so move rows show the net effect.
+ local keepDelta = 0
+ if existingSocketId then
+ for _, candidateResult in ipairs(socketResults) do
+ if equippedSocketIds[candidateResult.socket.id] then
+ keepDelta = candidateResult.delta or 0
+ break
+ end
+ end
+ end
+ local isEquippedSocket = equippedSocketIds[r.socket.id]
+ local points = isEquippedSocket and 0
+ or self:getSocketBasePoints(r.socket, { isOccupied = r.replacedItemLabel ~= nil })
+ local variantLabel = r.variant and (strategy.formatVariantLabel
+ and strategy.formatVariantLabel(r.variant)
+ or r.variant.dropdownLabel or r.variant.name) or ""
+ local itemTooltipLines = buildPreviewLinesForJewelType(jewelType, r.variant)
+ local variantIdentity = r.variant and r.variant.variantIdentity or jewelType.variantIdentity
+ local targetRawText = variantIdentity and variantIdentity.rawText or r.variant and r.variant.rawText or jewelType.rawText
+ local jewelLimitKey = variantIdentity and variantIdentity.limitKey
+ or targetRawText and targetRawText:match("^([^\n]+)")
+ or jewelType.name
+ jewelLimitKey = jewelLimitKey:gsub("^[Ff]oulborn ", "")
+ local jewelLimit = variantIdentity and variantIdentity.limit
+ or jewelType.limit
+ or (targetRawText and tonumber(targetRawText:match("Limited to: (%d+)")))
+ or nil
+ local displayedPlans = strategy.showsDisconnectedPassivePlans
+ and buildDisplayedDisconnectedPassivePlans(r, points, baseline)
+ or { r }
+ for _, plan in ipairs(displayedPlans) do
+ local displayDelta = plan.delta
+ if existingSocketId and not isEquippedSocket then
+ displayDelta = plan.delta - keepDelta
+ end
+ local pct = calculateImpactPercent(displayDelta, baseline)
+ local totalPoints = points + (plan.addedNodeCount or 0)
+ local summaryParts = { }
+ if variantLabel ~= "" then
+ t_insert(summaryParts, variantLabel)
+ end
+ if plan.resultNodeLabels and #plan.resultNodeLabels > 0 then
+ t_insert(summaryParts, s_format("%d node%s", #plan.resultNodeLabels, #plan.resultNodeLabels == 1 and "" or "s"))
+ elseif (not plan.detailText or plan.detailText == "") and variantLabel == "" then
+ local rIdx = jewelType.radiusIndex
+ local socketNode = plan.socket and treeData.nodes[plan.socket.id]
+ local radiusNodes = rIdx and socketNode and socketNode.nodesInRadius and socketNode.nodesInRadius[rIdx]
+ if radiusNodes then
+ local matchCount = 0
+ for _, n in pairs(radiusNodes) do
+ if not n.ascendancyName and (n.type == "Notable" or n.type == "Keystone") then
+ matchCount = matchCount + 1
+ end
+ end
+ if matchCount > 0 then
+ t_insert(summaryParts, s_format("%d match%s", matchCount, matchCount == 1 and "" or "es"))
+ end
+ end
+ end
+ local detailText = #summaryParts > 0 and t_concat(summaryParts, " | ") or (plan.detailText or "")
+ local detailNodeId = strategy.getDetailNodeId and strategy.getDetailNodeId(treeData, r.variant) or nil
+ local actionPlan = self.itemActions:buildPlan({
+ socketId = r.socket.id,
+ socketLabel = r.socket.label,
+ targetIdentity = variantIdentity,
+ targetRawText = targetRawText,
+ })
+ t_insert(rows, {
+ socketLabel = r.socket.label,
+ socketId = r.socket.id,
+ points = totalPoints,
+ delta = displayDelta,
+ pct = pct,
+ pctPerPoint = totalPoints > 0 and (pct / totalPoints) or pct,
+ sortValue = totalPoints > 0 and (pct / totalPoints) or pct,
+ variantLabel = variantLabel,
+ detailText = detailText,
+ detailNodeId = detailNodeId,
+ resultNodes = plan.resultNodes,
+ resultNodeLabels = plan.resultNodeLabels,
+ replacedItemLabel = r.replacedItemLabel,
+ storedUnallocatedItemLabel = r.storedUnallocatedItemLabel,
+ itemTooltipLines = itemTooltipLines,
+ baseOutput = plan.baseOutput,
+ compareOutput = plan.compareOutput,
+ jewelName = jewelType.name,
+ jewelLimitKey = jewelLimitKey,
+ jewelLimit = jewelLimit,
+ isEffectSocketIndependent = strategy.isEffectSocketIndependent,
+ action = actionPlan and actionPlan.kind or nil,
+ actionPlan = actionPlan,
+ tooltipHeader = strategy.computeTooltipHeader
+ or variantLabel ~= "" and "^7Socketing the best variant here will give you:"
+ or "^7Socketing this jewel will give you:",
+ })
+ end
+ end
+ return rows
+ end
+
+ controls.computeButton = new("ButtonControl"):ButtonControl(TL, { popupWidth - edgePadding * 2 - 72, headerInputY, 72, buttonHeight }, "Compute", function()
+ local resultContextKey = getResultContextKey()
+ local selectedThreadVariants = getSelectedJewelStrategy().usesThreadVariants
+ and getSelectedThreadVariants() or threadVariants
+ runRadiusJewelCompute(self, {
+ controls = controls,
+ computeState = computeState,
+ cancelCompute = cancelCompute,
+ setComputeProgress = setComputeProgress,
+ makeComputeProgressTracker = makeComputeProgressTracker,
+ selectedImpactStat = selectedImpactStat,
+ selectedComputeMethod = selectedComputeMethod,
+ selectedJewelType = selectedJewelType,
+ activeJewelTypes = activeJewelTypes,
+ jewelSockets = jewelSockets,
+ threadVariants = selectedThreadVariants,
+ selectedMaxPoints = selectedMaxPoints,
+ selectedOccupiedMode = selectedOccupiedMode,
+ buildComputeRows = buildComputeRows,
+ getSelectedAllJewelsView = function() return selectedAllJewelsView end,
+ formatComputeStatus = formatComputeStatus,
+ formatElapsed = formatElapsed,
+ setResultContext = setResultContext,
+ getSelectedVariants = getSelectedVariants,
+ hasVariantGroups = hasVariantGroups,
+ selectedVariantGroup = selectedVariantGroup,
+ allVariantGroupsValue = ALL_VARIANT_GROUPS_VALUE,
+ resultContextKey = resultContextKey,
+ isResultContextCurrent = isResultContextCurrent,
+ clearResultsForContext = clearResultsForContext,
+ })
+ end)
+ controls.computeButton.tooltipFunc = function(tooltip)
+ tooltip:Clear(true)
+ if computeState.computeContext then
+ tooltip:AddLine(16, "^7Stop the current compute.")
+ tooltip:AddLine(16, "^8Run Compute again to refresh the results.")
+ return
+ end
+ local strategy = getSelectedJewelStrategy()
+ if strategy and strategy.isAllJewels then
+ tooltip:AddLine(16, "^7Rank every jewel type by the selected stat.")
+ else
+ tooltip:AddLine(16, "^7Rank compatible sockets by the selected stat.")
+ end
+ tooltip:AddLine(16, "^8Uses Stat, Max points, and Sockets filters.")
+ end
+ controls.computeButton.shown = true
+
+ controls.statusLabel = new("LabelControl"):LabelControl(TL, { 10, statusLabelY, 400, 16 }, COL_META .. "Click Find to search")
+ local function showAllJewelsComputePrompt()
+ controls.statusLabel.label = COL_META .. "Click Compute to rank all jewels"
+ controls.resultsList:SetMode("message", { }, "")
+ end
+ controls.showLegacyCheck = new("CheckBoxControl"):CheckBoxControl(TL, { 700, statusLabelY, 18 }, "Show legacy", function(state)
+ onCriteriaChanged(function()
+ showLegacy = state
+ rebuildJewelTypeDropdown()
+ syncSelectedJewelTypeControls()
+ end)
+ end)
+
+ runFind = function()
+ local resultContextKey = getResultContextKey()
+ runRadiusJewelFind(self, {
+ controls = controls,
+ treeData = treeData,
+ radiusIndexByLabel = radiusIndexByLabel,
+ threadVariants = getSelectedThreadVariants(),
+ jewelSockets = jewelSockets,
+ selectedJewelType = selectedJewelType,
+ selectedJewelVariant = selectedJewelVariant,
+ selectedMaxPoints = selectedMaxPoints,
+ selectedOccupiedMode = selectedOccupiedMode,
+ resultContextKey = resultContextKey,
+ getSelectedVariants = getSelectedVariants,
+ formatElapsed = formatElapsed,
+ setResultContext = setResultContext,
+ showAllJewelsComputePrompt = showAllJewelsComputePrompt,
+ })
+ end
+ controls.findButton = new("ButtonControl"):ButtonControl(BL, { edgePadding, bottomButtonY, 100, buttonHeight }, "Find", function()
+ cancelCompute()
+ runFind()
+ end)
+ controls.findButton.shown = not (getSelectedJewelStrategy() and getSelectedJewelStrategy().isAllJewels)
+ controls.findButton.enabled = canFindCurrentSelection
+ controls.findButton.tooltipFunc = function(tooltip)
+ tooltip:Clear(true)
+ local strategy = getSelectedJewelStrategy()
+ local findsAllVariants = strategy and strategy.findsAllVariants
+ and ((strategy.usesThreadVariants and not selectedThreadVariant)
+ or (not strategy.usesThreadVariants and not selectedJewelVariant))
+ if findsAllVariants then
+ tooltip:AddLine(16, strategy.findAllVariantsTooltip)
+ elseif selectedJewelType and selectedJewelType.variants and not selectedJewelVariant then
+ tooltip:AddLine(16, "^7Find ranks sockets for one exact variant.")
+ tooltip:AddLine(16, "^8Choose a variant, or use Compute to compare the displayed variants by the selected stat.")
+ else
+ tooltip:AddLine(16, "^7Find sockets with matching passives for this jewel.")
+ tooltip:AddLine(16, "^8Use Compute to rank by the selected stat.")
+ end
+ end
+
+ controls.addToBuildButton, controls.applyButton = resultActions:createControls(
+ BL,
+ { rightPanelX, bottomButtonY, 100, buttonHeight },
+ { rightPanelX + 110, bottomButtonY, 80, buttonHeight })
+
+ local function restoreFinderState()
+ if not finderState.jewelTypeName then
+ clearResultsForContext()
+ return
+ end
+ suppressFinderStateSave = true
+
+ if finderState.showLegacy ~= nil then
+ showLegacy = finderState.showLegacy
+ controls.showLegacyCheck.state = showLegacy
+ end
+ rebuildJewelTypeDropdown()
+
+ local jewelTypeIndex
+ for i, jt in ipairs(activeJewelTypes) do
+ if jt.name == finderState.jewelTypeName then
+ jewelTypeIndex = i
+ break
+ end
+ end
+ if jewelTypeIndex then
+ controls.jewelTypeSelect.selIndex = jewelTypeIndex
+ selectedJewelType = activeJewelTypes[jewelTypeIndex]
+ end
+
+ if finderState.variantGroupValue or finderState.dreamFamilyValue then
+ selectedVariantGroup = { value = finderState.variantGroupValue or finderState.dreamFamilyValue }
+ end
+
+ syncSelectedJewelTypeControls()
+
+ if finderState.impactStatLabel then
+ for i, stat in ipairs(IMPACT_STATS) do
+ if stat.label == finderState.impactStatLabel then
+ selectedImpactStat = stat
+ controls.impactStatSelect.selIndex = i
+ break
+ end
+ end
+ end
+ if finderState.maxPoints ~= nil then
+ selectedMaxPoints = finderState.maxPoints
+ controls.maxPointsEdit.buf = tostring(finderState.maxPoints)
+ end
+ if finderState.occupiedModeId then
+ for i, option in ipairs(OCCUPIED_SOCKET_OPTIONS) do
+ if option.id == finderState.occupiedModeId then
+ selectedOccupiedMode = option
+ controls.occupiedModeSelect.selIndex = i
+ break
+ end
+ end
+ end
+ if finderState.allJewelsViewId then
+ for i, option in ipairs(ALL_JEWELS_VIEW_OPTIONS) do
+ if option.id == finderState.allJewelsViewId then
+ selectedAllJewelsView = option
+ controls.allJewelsViewSelect.selIndex = i
+ break
+ end
+ end
+ end
+ if finderState.computeMethodId then
+ local methods = getSelectedComputeMethods() or { }
+ for i, method in ipairs(methods) do
+ if method.id == finderState.computeMethodId then
+ selectedComputeMethod = method
+ controls.computeMethodSelect.selIndex = i
+ break
+ end
+ end
+ end
+ local strategy = getSelectedJewelStrategy()
+ if strategy and strategy.usesThreadVariants and finderState.threadVariantName then
+ for i, variant in ipairs(threadVariants) do
+ if variant.name == finderState.threadVariantName then
+ selectedThreadVariant = variant
+ controls.threadVariantSelect.selIndex = i + 1
+ break
+ end
+ end
+ elseif selectedJewelType and selectedJewelType.variants and finderState.jewelVariantName then
+ local variants = getDisplayedVariants() or { }
+ for i, variant in ipairs(variants) do
+ local variantName = variant.dropdownLabel or variant.name
+ if variantName == finderState.jewelVariantName then
+ selectedJewelVariant = variant
+ controls.jewelVariantSelect.selIndex = i + 1
+ break
+ end
+ end
+ end
+
+ suppressFinderStateSave = false
+ saveFinderState()
+ clearResultsForContext()
+ end
+
+ controls.closeButton = new("ButtonControl"):ButtonControl(BR, { -edgePadding, bottomButtonY, 100, buttonHeight }, "Close", function()
+ cancelCompute()
+ main:ClosePopup()
+ end)
+
+ return {
+ controls = controls,
+ popupWidth = popupWidth,
+ popupHeight = popupHeight,
+ restoreFinderState = restoreFinderState,
+ }
+end
+
+function RadiusJewelFinderClass:Open()
+ local context = buildRadiusJewelPopupContext(self)
+ context.restoreFinderState()
+ local popup = main:OpenPopup(context.popupWidth, context.popupHeight, "Find Radius Jewel", context.controls, nil, nil, "closeButton")
+ local baseProcessInput = popup.ProcessInput
+ popup.ProcessInput = function(self, inputEvents, viewPort)
+ for _, event in ipairs(inputEvents) do
+ if event.type == "KeyDown" and event.key == "RETURN" and IsKeyDown("CTRL") then
+ context.controls.computeButton:Click()
+ return
+ end
+ end
+ baseProcessInput(self, inputEvents, viewPort)
+ end
+ return popup
+end
diff --git a/src/Classes/RadiusJewelItemActions.lua b/src/Classes/RadiusJewelItemActions.lua
new file mode 100644
index 0000000000..d38de8634a
--- /dev/null
+++ b/src/Classes/RadiusJewelItemActions.lua
@@ -0,0 +1,372 @@
+-- Path of Building
+--
+-- Module: Radius Jewel Item Actions
+-- Builds and executes guarded item actions for Radius Jewel Finder results.
+--
+local ipairs = ipairs
+local pairs = pairs
+local t_insert = table.insert
+local t_sort = table.sort
+local t_concat = table.concat
+
+local RadiusJewelItemActions = { }
+RadiusJewelItemActions.__index = RadiusJewelItemActions
+
+---@alias RadiusJewelActionKind 'equip'|'move'|'replace'|'equipped'
+
+---@class RadiusJewelActionPlan
+---@field kind RadiusJewelActionKind
+---@field sourceItemId number?
+---@field sourceItemLabel string?
+---@field sourceItemStateKey string?
+---@field sourceSocketId number?
+---@field sourceSocketLabel string?
+---@field sourceMatchesTarget boolean
+---@field targetSocketId number
+---@field targetSocketLabel string
+---@field targetSocketAllocated boolean
+---@field targetIdentity table
+---@field targetCanonicalKey string
+---@field targetRawText string
+---@field targetItemId number
+---@field targetItemStateKey string?
+---@field matchingItemsStateKey string
+---@field replacedTargetId number?
+---@field replacedTargetLabel string?
+
+function RadiusJewelItemActions:new(finder)
+ return setmetatable({
+ finder = finder,
+ build = finder.build,
+ }, self)
+end
+
+local function sortedNumericKeys(tbl)
+ local keys = { }
+ for key in pairs(tbl or { }) do
+ t_insert(keys, key)
+ end
+ t_sort(keys, function(a, b)
+ if type(a) == type(b) then
+ return a < b
+ end
+ return tostring(a) < tostring(b)
+ end)
+ return keys
+end
+
+-- Variant identity deliberately excludes rolls, quality, item level, and unique ID.
+-- It retains every field that selects a canonical unique variant, including Foulborn mods.
+local function buildItemCanonicalVariantKey(item)
+ if not item then
+ return nil
+ end
+ local parts = {
+ item.rarity or "",
+ item.title or item.name or "",
+ item.baseName or "",
+ item.jewelRadiusLabel or "",
+ tostring(item.selectedVersion or ""),
+ tostring(item.variant or ""),
+ tostring(item.variantAlt or ""),
+ tostring(item.variantAlt2 or ""),
+ tostring(item.variantAlt3 or ""),
+ tostring(item.variantAlt4 or ""),
+ tostring(item.variantAlt5 or ""),
+ }
+ for _, groupId in ipairs(sortedNumericKeys(item.variantGroupSelections)) do
+ t_insert(parts, "group:" .. tostring(groupId) .. "=" .. tostring(item.variantGroupSelections[groupId]))
+ end
+ local mutatedModIds = { }
+ for _, modLine in ipairs(item.explicitModLines or { }) do
+ if modLine.mutated then
+ t_insert(mutatedModIds, modLine.modGroup or modLine.modId or modLine.line or "mutated")
+ end
+ end
+ t_sort(mutatedModIds)
+ for _, modId in ipairs(mutatedModIds) do
+ t_insert(parts, "mutated:" .. modId)
+ end
+ return t_concat(parts, "\31")
+end
+
+local function makeTargetItem(targetRawText)
+ local item = new("Item"):Item("Rarity: Unique\n" .. targetRawText)
+ item:BuildModList()
+ return item
+end
+
+local function getItemLabel(item)
+ if not item then
+ return nil
+ end
+ local itemName = item.title or item.name or item.baseName or "Unknown item"
+ local itemType = item.baseName
+ if itemType and itemType ~= "" and itemType ~= itemName then
+ return itemName .. " (" .. itemType .. ")"
+ end
+ return itemName
+end
+
+local function getItemStateKey(item)
+ if not item then
+ return nil
+ end
+ local rawText = item.BuildRaw and item:BuildRaw() or ""
+ return (buildItemCanonicalVariantKey(item) or "") .. "\30" .. rawText
+end
+
+function RadiusJewelItemActions:getSocketLabel(slot, socketId)
+ local label = slot and slot.label
+ if label and label ~= "" then
+ return label .. " (" .. tostring(socketId) .. ")"
+ end
+ return "Jewel socket " .. tostring(socketId)
+end
+
+-- Returns the first matching item and location plus an aggregate key for all matches.
+function RadiusJewelItemActions:findCanonicalVariantMatch(targetCanonicalKey)
+ local itemsTab = self.build.itemsTab
+ local socketByItemId = { }
+ for _, socketId in ipairs(sortedNumericKeys(itemsTab.sockets)) do
+ local itemId = itemsTab.sockets[socketId].selItemId
+ if itemId and itemId ~= 0 and not socketByItemId[itemId] then
+ socketByItemId[itemId] = socketId
+ end
+ end
+
+ local firstItem, firstSocket, firstSocketId
+ local matchingStates = { }
+ for _, itemId in ipairs(itemsTab.itemOrderList) do
+ local item = itemsTab.items[itemId]
+ if buildItemCanonicalVariantKey(item) == targetCanonicalKey then
+ local socketId = socketByItemId[itemId]
+ t_insert(matchingStates, table.concat({
+ tostring(itemId),
+ getItemStateKey(item) or "",
+ tostring(socketId or ""),
+ }, "\29"))
+ if not firstItem then
+ firstItem = item
+ firstSocketId = socketId
+ firstSocket = socketId and itemsTab.sockets[socketId] or nil
+ end
+ end
+ end
+ return firstItem, firstSocket, firstSocketId, t_concat(matchingStates, "\28")
+end
+
+function RadiusJewelItemActions:findExactStoredSource(targetCanonicalKey, targetSocketId)
+ local itemsTab = self.build.itemsTab
+ local allocNodes = self.build.spec.allocNodes
+ local socketedItemIds = { }
+ for _, socketId in ipairs(sortedNumericKeys(itemsTab.sockets)) do
+ local slot = itemsTab.sockets[socketId]
+ local itemId = slot.selItemId
+ if itemId and itemId ~= 0 then
+ socketedItemIds[itemId] = true
+ if socketId ~= targetSocketId and not allocNodes[socketId] then
+ local item = itemsTab.items[itemId]
+ if buildItemCanonicalVariantKey(item) == targetCanonicalKey then
+ return item, slot, socketId
+ end
+ end
+ end
+ end
+ for _, itemId in ipairs(itemsTab.itemOrderList) do
+ if not socketedItemIds[itemId] then
+ local item = itemsTab.items[itemId]
+ if buildItemCanonicalVariantKey(item) == targetCanonicalKey then
+ return item, nil, nil
+ end
+ end
+ end
+ return nil, nil, nil
+end
+
+---@param target table
+---@return RadiusJewelActionPlan?
+function RadiusJewelItemActions:buildPlan(target)
+ local targetSocket = self.build.itemsTab.sockets[target.socketId]
+ local targetIdentity = target.targetIdentity
+ local targetRawText = target.targetRawText
+ if not targetSocket or not targetIdentity or not targetRawText then
+ return nil
+ end
+
+ local targetTemplate = makeTargetItem(targetRawText)
+ local targetCanonicalKey = buildItemCanonicalVariantKey(targetTemplate)
+ local targetItemId = targetSocket.selItemId or 0
+ local targetItem = targetItemId ~= 0 and self.build.itemsTab.items[targetItemId] or nil
+ local targetMatches = buildItemCanonicalVariantKey(targetItem) == targetCanonicalKey
+ local targetSocketLabel = target.socketLabel or self:getSocketLabel(targetSocket, target.socketId)
+ local targetSocketAllocated = self.build.spec.allocNodes[target.socketId] ~= nil
+ local _, _, _, matchingItemsStateKey = self:findCanonicalVariantMatch(targetCanonicalKey)
+ if targetMatches then
+ return {
+ kind = "equipped",
+ sourceItemId = targetItemId,
+ sourceItemLabel = getItemLabel(targetItem),
+ sourceItemStateKey = getItemStateKey(targetItem),
+ sourceSocketId = target.socketId,
+ sourceSocketLabel = targetSocketLabel,
+ sourceMatchesTarget = true,
+ targetSocketId = target.socketId,
+ targetSocketLabel = targetSocketLabel,
+ targetSocketAllocated = targetSocketAllocated,
+ targetIdentity = targetIdentity,
+ targetCanonicalKey = targetCanonicalKey,
+ targetRawText = targetRawText,
+ targetItemId = targetItemId,
+ targetItemStateKey = getItemStateKey(targetItem),
+ matchingItemsStateKey = matchingItemsStateKey,
+ }
+ end
+
+ local sourceItem, sourceSocket, sourceSocketId
+ local equipped = self.finder:findEquippedJewelSockets({
+ name = targetIdentity.family or targetIdentity.uniqueName,
+ variantIdentity = targetIdentity,
+ })
+ if equipped.atLimit then
+ t_sort(equipped, function(a, b)
+ local aIsTarget = a.socketId == target.socketId
+ local bIsTarget = b.socketId == target.socketId
+ if aIsTarget ~= bIsTarget then return aIsTarget end
+ local aMatches = buildItemCanonicalVariantKey(a.item) == targetCanonicalKey
+ local bMatches = buildItemCanonicalVariantKey(b.item) == targetCanonicalKey
+ if aMatches ~= bMatches then return aMatches end
+ return a.socketId < b.socketId
+ end)
+ local source = equipped[1]
+ if source then
+ sourceItem = source.item
+ sourceSocket = source.slot
+ sourceSocketId = source.socketId
+ end
+ else
+ local storedItem, storedSocket, storedSocketId = self:findExactStoredSource(targetCanonicalKey, target.socketId)
+ if storedItem then
+ sourceItem = storedItem
+ sourceSocket = storedSocket
+ sourceSocketId = storedSocketId
+ end
+ end
+
+ local sourceMatchesTarget = buildItemCanonicalVariantKey(sourceItem) == targetCanonicalKey
+ local kind
+ if sourceSocket and sourceSocket ~= targetSocket then
+ kind = "move"
+ elseif targetItem then
+ kind = "replace"
+ else
+ kind = "equip"
+ end
+ return {
+ kind = kind,
+ sourceItemId = sourceItem and sourceItem.id or nil,
+ sourceItemLabel = getItemLabel(sourceItem),
+ sourceItemStateKey = getItemStateKey(sourceItem),
+ sourceSocketId = sourceSocketId,
+ sourceSocketLabel = sourceSocketId and self:getSocketLabel(sourceSocket, sourceSocketId) or nil,
+ sourceMatchesTarget = sourceMatchesTarget,
+ targetSocketId = target.socketId,
+ targetSocketLabel = targetSocketLabel,
+ targetSocketAllocated = targetSocketAllocated,
+ targetIdentity = targetIdentity,
+ targetCanonicalKey = targetCanonicalKey,
+ targetRawText = targetRawText,
+ targetItemId = targetItemId,
+ targetItemStateKey = getItemStateKey(targetItem),
+ matchingItemsStateKey = matchingItemsStateKey,
+ replacedTargetId = targetItemId ~= 0 and targetItemId or nil,
+ replacedTargetLabel = getItemLabel(targetItem),
+ }
+end
+
+function RadiusJewelItemActions:isPlanCurrent(plan)
+ local itemsTab = self.build.itemsTab
+ local targetSocket = plan and itemsTab.sockets[plan.targetSocketId]
+ if not targetSocket or targetSocket.selItemId ~= plan.targetItemId then
+ return false
+ end
+ if (self.build.spec.allocNodes[plan.targetSocketId] ~= nil) ~= plan.targetSocketAllocated then
+ return false
+ end
+ local _, _, _, matchingItemsStateKey = self:findCanonicalVariantMatch(plan.targetCanonicalKey)
+ if matchingItemsStateKey ~= plan.matchingItemsStateKey then
+ return false
+ end
+ if plan.targetItemId ~= 0 and getItemStateKey(itemsTab.items[plan.targetItemId]) ~= plan.targetItemStateKey then
+ return false
+ end
+ local sourceSocket = plan.sourceSocketId and itemsTab.sockets[plan.sourceSocketId]
+ if plan.sourceSocketId and (not sourceSocket or sourceSocket.selItemId ~= plan.sourceItemId) then
+ return false
+ end
+ if plan.sourceItemId and not plan.sourceSocketId then
+ for _, socket in pairs(itemsTab.sockets) do
+ if socket.selItemId == plan.sourceItemId then
+ return false
+ end
+ end
+ end
+ return not plan.sourceItemId or getItemStateKey(itemsTab.items[plan.sourceItemId]) == plan.sourceItemStateKey
+end
+
+---@param plan RadiusJewelActionPlan
+function RadiusJewelItemActions:executePlan(plan)
+ local itemsTab = self.build.itemsTab
+ if not self:isPlanCurrent(plan) or plan.kind == "equipped" then
+ return false
+ end
+
+ local sourceItem = plan.sourceItemId and itemsTab.items[plan.sourceItemId]
+ local sourceSocket = plan.sourceSocketId and itemsTab.sockets[plan.sourceSocketId]
+ local targetSocket = itemsTab.sockets[plan.targetSocketId]
+ local targetItem = plan.sourceMatchesTarget and sourceItem or makeTargetItem(plan.targetRawText)
+ local changesVariantInPlace = sourceItem and not plan.sourceMatchesTarget and sourceSocket == targetSocket
+ if sourceItem and not plan.sourceMatchesTarget and not changesVariantInPlace then
+ targetItem.id = sourceItem.id
+ end
+ if not targetItem.id or targetItem ~= itemsTab.items[targetItem.id] then
+ itemsTab:AddItem(targetItem, true)
+ end
+ if sourceSocket and sourceSocket ~= targetSocket then
+ sourceSocket:SetSelItemId(0)
+ end
+ targetSocket:SetSelItemId(targetItem.id)
+ if changesVariantInPlace then
+ -- Keep the final item count stable, but use a new ID so normal Undo restoration
+ -- changes the socket selection and rebuilds variant-dependent passive graphs.
+ itemsTab:DeleteItem(sourceItem, true)
+ end
+ itemsTab:PopulateSlots()
+ itemsTab:AddUndoState()
+ self.build.buildFlag = true
+ return true
+end
+
+---@param plan RadiusJewelActionPlan
+function RadiusJewelItemActions:executeAddToBuildPlan(plan)
+ local itemsTab = self.build.itemsTab
+ if not self:isPlanCurrent(plan) then
+ return false
+ end
+ local existingItem = self:findCanonicalVariantMatch(plan.targetCanonicalKey)
+ if existingItem then
+ return false
+ end
+
+ itemsTab:AddItem(makeTargetItem(plan.targetRawText), true)
+ itemsTab:PopulateSlots()
+ itemsTab:AddUndoState()
+ self.build.buildFlag = true
+ return true
+end
+
+return {
+ new = function(finder)
+ return RadiusJewelItemActions:new(finder)
+ end,
+}
diff --git a/src/Classes/RadiusJewelResultsListControl.lua b/src/Classes/RadiusJewelResultsListControl.lua
new file mode 100644
index 0000000000..37e70eb5ec
--- /dev/null
+++ b/src/Classes/RadiusJewelResultsListControl.lua
@@ -0,0 +1,269 @@
+-- Path of Building
+--
+-- Class: Radius Jewel Results List Control
+-- Displays and previews ranked Radius Jewel Finder results.
+--
+
+local ipairs = ipairs
+local t_insert = table.insert
+local t_sort = table.sort
+local s_format = string.format
+
+local placeTooltip = LoadModule("Classes/RadiusJewelTooltipPlacement").placeTooltip
+
+local function formatSignedValue(value)
+ local sign = value >= 0 and "+" or ""
+ local col = value > 0 and "^2" or (value < 0 and "^1" or "^8")
+ return s_format("%s%s%.1f", col, sign, value)
+end
+
+local function formatSignedPercent(value)
+ local sign = value >= 0 and "+" or ""
+ local col = value > 0 and "^2" or (value < 0 and "^1" or "^8")
+ return s_format("%s%s%.1f%%", col, sign, value)
+end
+
+local function formatPerPointDisplay(value, points)
+ if points == 0 then
+ return value > 0 and "^2Free" or (value < 0 and "^1Free" or "^8Free")
+ end
+ return formatSignedPercent(value)
+end
+
+local ACTION_COLORS = {
+ equip = "^2",
+ move = "^x33AAFF",
+ replace = "^xFFAA33",
+ equipped = "^8",
+}
+local function colorSocketLabel(row)
+ return (row.action and ACTION_COLORS[row.action] or "") .. row.socketLabel
+end
+
+local function compareField(field, descending)
+ return function(a, b)
+ local aValue = a[field]
+ local bValue = b[field]
+ if aValue == bValue then return false end
+ if aValue == nil then return false end
+ if bValue == nil then return true end
+ return descending and aValue > bValue or not descending and aValue < bValue
+ end
+end
+
+local function column(width, label, getValue, sortField, descending, hoverRole)
+ return {
+ width = width,
+ label = label,
+ sortable = sortField ~= nil,
+ getValue = getValue,
+ compare = sortField and compareField(sortField, descending) or nil,
+ hoverRole = hoverRole,
+ }
+end
+
+local function findPerPoint(row)
+ return row.points == 0 and (row.score > 0 and "^2Free" or "^8Free") or s_format("^7%.2f", row.scorePerPoint)
+end
+
+---@class RadiusJewelResultsListControl: ListControl
+local RadiusJewelResultsListClass = newClass("RadiusJewelResultsListControl", "ListControl")
+
+function RadiusJewelResultsListClass:RadiusJewelResultsListControl(anchor, rect, build, socketViewer)
+ self:ListControl(anchor, rect, 16, "VERTICAL", false)
+ self.build = build
+ self.socketViewer = socketViewer
+ self.colLabels = true
+ self.showRowSeparators = true
+ self.defaultText = "^8Click Find to search"
+ self.mode = "message"
+ self.columnsByMode = {
+ message = {
+ column(rect[3] - 22, "", function(row) return row.text or "" end),
+ },
+ computeSocket = {
+ column(170, "Socket", colorSocketLabel, "socketLabel", false, "socket"),
+ column(50, "Points", function(row) return tostring(row.points) end, "points"),
+ column(75, "Gain", function(row) return formatSignedValue(row.delta) end, "delta", true, "stat"),
+ column(60, "%", function(row) return formatSignedPercent(row.pct) end, "pct", true, "stat"),
+ column(65, "%/Pt", function(row) return formatPerPointDisplay(row.pctPerPoint, row.points) end, "sortValue", true, "stat"),
+ column(140, "Detail", function(row) return row.detailText or "" end, "detailText", false, "detail"),
+ },
+ computeSocketAll = {
+ column(120, "Jewel", function(row) return row.jewelName or "" end, "jewelName"),
+ column(130, "Socket", colorSocketLabel, "socketLabel", false, "socket"),
+ column(50, "Points", function(row) return tostring(row.points) end, "points"),
+ column(75, "Gain", function(row) return formatSignedValue(row.delta) end, "delta", true, "stat"),
+ column(60, "%", function(row) return formatSignedPercent(row.pct) end, "pct", true, "stat"),
+ column(65, "%/Pt", function(row) return formatPerPointDisplay(row.pctPerPoint, row.points) end, "sortValue", true, "stat"),
+ column(60, "Detail", function(row) return row.detailText or "" end, "detailText", false, "detail"),
+ },
+ find = {
+ column(170, "Socket", colorSocketLabel, "socketLabel", false, "socket"),
+ column(50, "Points", function(row) return tostring(row.points) end, "points"),
+ column(60, "Score", function(row) return s_format("^7%d", row.score) end, "score", true),
+ column(70, "/Pt", findPerPoint, "sortValue", true),
+ column(210, "Detail", function(row) return row.detailText or "" end, "detailText", false, "detail"),
+ },
+ findThread = {
+ column(170, "Socket", colorSocketLabel, "socketLabel", false, "socket"),
+ column(50, "Points", function(row) return tostring(row.points) end, "points"),
+ column(60, "Score", function(row) return s_format("^7%d", row.score) end, "score", true),
+ column(70, "/Pt", findPerPoint, "sortValue", true),
+ column(90, "Ring", function(row) return row.variantLabel or "" end, "variantLabel"),
+ column(120, "Detail", function(row) return row.detailText or "" end, "detailText", false, "detail"),
+ },
+ }
+ self.defaultSortByMode = {
+ computeSocket = 5,
+ computeSocketAll = 6,
+ find = 4,
+ findThread = 4,
+ }
+ self.resultTooltip = new("Tooltip"):Tooltip()
+ self.itemTooltip = new("Tooltip"):Tooltip()
+ return self
+end
+
+function RadiusJewelResultsListClass:SetMode(mode, list, defaultText)
+ self.mode = mode or "message"
+ self.list = list or { }
+ self.defaultText = defaultText or ""
+ self.colList = self.columnsByMode[self.mode] or self.columnsByMode.message
+ self.colLabels = self.mode ~= "message" and #self.list > 0
+ local defaultSort = self.defaultSortByMode[self.mode]
+ if defaultSort and #self.list > 0 then
+ self:ReSort(defaultSort)
+ end
+ if self.mode ~= "message" and #self.list > 0 then
+ self:SelectIndex(1)
+ else
+ self.selIndex = nil
+ self.selValue = nil
+ if self.OnSelect then
+ self:OnSelect(nil, nil)
+ end
+ end
+end
+
+function RadiusJewelResultsListClass:GetHoverInfo(hoverColumn, hoverData)
+ local columnInfo = hoverColumn and self.colList[hoverColumn]
+ local hoverRole = columnInfo and columnInfo.hoverRole
+ local detailColumn = hoverRole == "detail"
+ local socketColumn = hoverRole == "socket"
+ local showViewer = socketColumn or (detailColumn and hoverData and hoverData.detailNodeId)
+ local showStatTooltip = hoverData and hoverData.baseOutput and hoverData.compareOutput
+ and hoverRole == "stat"
+ local showItemTooltip = hoverData and hoverData.itemTooltipLines
+ and detailColumn
+ local hoverNodeId = hoverData and hoverData.socketId or nil
+ if hoverData and hoverData.detailNodeId and detailColumn then
+ hoverNodeId = hoverData.detailNodeId
+ end
+ return {
+ detailColumn = detailColumn,
+ socketColumn = socketColumn,
+ showViewer = showViewer,
+ showStatTooltip = showStatTooltip,
+ showItemTooltip = showItemTooltip,
+ hoverNodeId = hoverNodeId,
+ }
+end
+
+function RadiusJewelResultsListClass:ReSort(colIndex)
+ local columnInfo = self.colList[colIndex]
+ if columnInfo and columnInfo.compare then
+ t_sort(self.list, columnInfo.compare)
+ end
+end
+
+function RadiusJewelResultsListClass:GetRowValue(column, index, row)
+ local columnInfo = self.colList[column]
+ return columnInfo and columnInfo.getValue and columnInfo.getValue(row) or ""
+end
+
+function RadiusJewelResultsListClass:Draw(viewPort, noTooltip)
+ self.ListControl.Draw(self, viewPort, true)
+ if self.suppressTooltipFunc and self.suppressTooltipFunc() then
+ return
+ end
+ local hoverData = self.hoverValue
+ if not hoverData or main.popups[2] then
+ return
+ end
+
+ local cursorX, cursorY = GetCursorPos()
+ local x, y = self:GetPos()
+ local relX = cursorX - (x + 2)
+ local hoverColumn
+ if hoverData then
+ for columnIndex, column in ipairs(self.colList) do
+ local colOffset = column._offset or 0
+ local colWidth = column._width or 0
+ if relX >= colOffset and relX < colOffset + colWidth then
+ hoverColumn = columnIndex
+ break
+ end
+ end
+ end
+ local hoverInfo = self:GetHoverInfo(hoverColumn, hoverData)
+ local viewerRect
+ if hoverInfo.showViewer and hoverInfo.hoverNodeId then
+ local node = self.build.spec.nodes[hoverInfo.hoverNodeId] or self.build.spec.tree.nodes[hoverInfo.hoverNodeId]
+ if node then
+ SetDrawLayer(nil, 15)
+ local viewerX = cursorX + 20
+ local viewerY = cursorY - 150
+ if viewerX + 304 > viewPort.x + viewPort.width then viewerX = cursorX - 324 end
+ if viewerY < viewPort.y then viewerY = viewPort.y elseif viewerY + 304 > viewPort.y + viewPort.height then viewerY = viewPort.y + viewPort.height - 304 end
+ viewerRect = { x = viewerX, y = viewerY, width = 304, height = 304 }
+
+ SetDrawColor(1, 1, 1)
+ DrawImage(nil, viewerX, viewerY, 304, 304)
+ self.socketViewer.zoom = 5
+ local scale = self.build.spec.tree.size / 1500
+ self.socketViewer.zoomX = -node.x / scale
+ self.socketViewer.zoomY = -node.y / scale
+ self.socketViewer.searchStrResults[hoverInfo.hoverNodeId] = true
+ SetViewport(viewerX + 2, viewerY + 2, 300, 300)
+ self.socketViewer:Draw(self.build, { x = 0, y = 0, width = 300, height = 300 }, { })
+ self.socketViewer.searchStrResults[hoverInfo.hoverNodeId] = nil
+ SetDrawLayer(nil, 30)
+ SetDrawColor(1, 1, 1, 0.2)
+ DrawImage(nil, 149, 0, 2, 300)
+ DrawImage(nil, 0, 149, 300, 2)
+ SetViewport()
+ SetDrawLayer(nil, 0)
+ end
+ end
+
+ local blockedRectangles = { }
+ if viewerRect then
+ t_insert(blockedRectangles, viewerRect)
+ end
+ if hoverInfo.showStatTooltip then
+ SetDrawLayer(nil, 100)
+ self.resultTooltip:Clear()
+ local count = self.build:AddStatComparesToTooltip(self.resultTooltip, hoverData.baseOutput, hoverData.compareOutput,
+ hoverData.tooltipHeader or "^7Socketing this jewel will give you:")
+ if count == 0 then
+ self.resultTooltip:AddLine(14, "^7No stat changes for this result.")
+ end
+ local ttW, ttH = self.resultTooltip:GetSize()
+ local ttX, ttY = placeTooltip(viewPort, ttW, ttH, cursorX, cursorY, blockedRectangles, true)
+ self.resultTooltip:Draw(ttX, ttY, nil, nil, viewPort)
+ t_insert(blockedRectangles, { x = ttX, y = ttY, width = ttW, height = ttH })
+ SetDrawLayer(nil, 0)
+ end
+ if hoverInfo.showItemTooltip then
+ SetDrawLayer(nil, 100)
+ self.itemTooltip:Clear(true)
+ for _, line in ipairs(hoverData.itemTooltipLines) do
+ self.itemTooltip:AddLine(line.height or 16, line[1], line.font)
+ end
+ local itemTtW, itemTtH = self.itemTooltip:GetSize()
+ local itemTtX, itemTtY = placeTooltip(viewPort, itemTtW, itemTtH, cursorX, cursorY, blockedRectangles, true)
+ self.itemTooltip:Draw(itemTtX, itemTtY, nil, nil, viewPort)
+ SetDrawLayer(nil, 0)
+ end
+end
diff --git a/src/Classes/RadiusJewelTooltipPlacement.lua b/src/Classes/RadiusJewelTooltipPlacement.lua
new file mode 100644
index 0000000000..63c7a3a8cc
--- /dev/null
+++ b/src/Classes/RadiusJewelTooltipPlacement.lua
@@ -0,0 +1,54 @@
+-- Path of Building
+--
+-- Module: Radius Jewel Tooltip Placement
+-- Keeps Radius Jewel Finder tooltips inside the viewport and clear of previews.
+--
+
+local ipairs = ipairs
+local t_insert = table.insert
+local m_max = math.max
+local m_min = math.min
+
+local M = { }
+
+local function clampPosition(viewPort, x, y, width, height)
+ x = m_max(viewPort.x, m_min(x, viewPort.x + viewPort.width - width))
+ y = m_max(viewPort.y, m_min(y, viewPort.y + viewPort.height - height))
+ return x, y
+end
+
+local function rectanglesOverlap(aX, aY, aW, aH, bX, bY, bW, bH)
+ return aX < bX + bW and aX + aW > bX and aY < bY + bH and aY + aH > bY
+end
+
+function M.placeTooltip(viewPort, ttW, ttH, cursorX, cursorY, blockedRectangles, preferPrimaryBlockedRect)
+ local candidates = {
+ { x = cursorX + 20, y = cursorY + 20 },
+ { x = cursorX - ttW - 20, y = cursorY + 20 },
+ { x = cursorX + 20, y = cursorY - ttH - 20 },
+ { x = cursorX - ttW - 20, y = cursorY - ttH - 20 },
+ }
+ local primaryBlockedRect = preferPrimaryBlockedRect and blockedRectangles and blockedRectangles[1] or nil
+ if primaryBlockedRect then
+ t_insert(candidates, 1, { x = primaryBlockedRect.x - ttW - 12, y = cursorY + 20 })
+ t_insert(candidates, 2, { x = primaryBlockedRect.x + primaryBlockedRect.width + 12, y = cursorY + 20 })
+ t_insert(candidates, 3, { x = primaryBlockedRect.x, y = primaryBlockedRect.y - ttH - 12 })
+ t_insert(candidates, 4, { x = primaryBlockedRect.x, y = primaryBlockedRect.y + primaryBlockedRect.height + 12 })
+ end
+ for _, candidate in ipairs(candidates) do
+ local ttX, ttY = clampPosition(viewPort, candidate.x, candidate.y, ttW, ttH)
+ local overlapsBlockedRect = false
+ for _, blockedRect in ipairs(blockedRectangles or { }) do
+ if rectanglesOverlap(ttX, ttY, ttW, ttH, blockedRect.x, blockedRect.y, blockedRect.width, blockedRect.height) then
+ overlapsBlockedRect = true
+ break
+ end
+ end
+ if not overlapsBlockedRect then
+ return ttX, ttY
+ end
+ end
+ return clampPosition(viewPort, cursorX + 20, cursorY + 20, ttW, ttH)
+end
+
+return M
diff --git a/src/Classes/TreeTab.lua b/src/Classes/TreeTab.lua
index c9cb48c211..3e82b819cb 100644
--- a/src/Classes/TreeTab.lua
+++ b/src/Classes/TreeTab.lua
@@ -192,11 +192,16 @@ function TreeTabClass:TreeTab(build)
self:FindTimelessJewel()
end)
+ -- Find Radius Jewel Button
+ self.controls.findRadiusJewel = new("ButtonControl"):ButtonControl({ "LEFT", self.controls.findTimelessJewel, "RIGHT" }, { 8, 0, 160, 20 }, "Find Radius Jewel", function()
+ self:FindRadiusJewel()
+ end)
+
--Default index for Tattoos
self.defaultTattoo = { }
-- Show Node Power Checkbox
- self.controls.treeHeatMap = new("CheckBoxControl"):CheckBoxControl({ "LEFT", self.controls.findTimelessJewel, "RIGHT" }, { 130, 0, 20 }, "Show Node Power:", function(state)
+ self.controls.treeHeatMap = new("CheckBoxControl"):CheckBoxControl({ "LEFT", self.controls.findRadiusJewel, "RIGHT" }, { 130, 0, 20 }, "Show Node Power:", function(state)
self.viewer.showHeatMap = state
self.controls.treeHeatMapStatSelect.shown = state
@@ -402,6 +407,7 @@ function TreeTabClass:Draw(viewPort, inputEvents)
local widthSecondLineControls = self.controls.treeSearch.width + 8
+ self.controls.findTimelessJewel.width + self.controls.findTimelessJewel.x
+ + self.controls.findRadiusJewel.width + self.controls.findRadiusJewel.x
+ self.controls.treeHeatMap.width + 130
+ self.controls.nodePowerMaxDepthSelect.width + self.controls.nodePowerMaxDepthSelect.x
+ (self.isCustomMaxDepth and (self.controls.nodePowerMaxDepthCustom.width + self.controls.nodePowerMaxDepthCustom.x) or 0)
@@ -420,7 +426,7 @@ function TreeTabClass:Draw(viewPort, inputEvents)
-- Check second line
if viewPort.width >= widthSecondLineControls + rightMargin then
- self.controls.treeHeatMap:SetAnchor("LEFT", self.controls.findTimelessJewel, "RIGHT", 130, 0)
+ self.controls.treeHeatMap:SetAnchor("LEFT", self.controls.findRadiusJewel, "RIGHT", 130, 0)
else
linesHeight = linesHeight * 2
self.controls.treeHeatMap:SetAnchor("TOPLEFT", self.controls.treeSearch, "BOTTOMLEFT", 124, 4)
@@ -2866,3 +2872,7 @@ function TreeTabClass:FindTimelessJewel()
local panelHeight = 565
main:OpenPopup(panelWidth, panelHeight, "Find a Timeless Jewel", controls)
end
+
+function TreeTabClass:FindRadiusJewel()
+ new("RadiusJewelFinder"):RadiusJewelFinder(self):Open()
+end