diff --git a/manifest.xml b/manifest.xml
index 7ca1036c36..aa2414a1c2 100644
--- a/manifest.xml
+++ b/manifest.xml
@@ -166,6 +166,9 @@
+
+
+
@@ -187,7 +190,7 @@
-
+
@@ -1303,4 +1306,4 @@
-
\ No newline at end of file
+
diff --git a/spec/System/TestRadiusJewelFinder_spec.lua b/spec/System/TestRadiusJewelFinder_spec.lua
new file mode 100644
index 0000000000..494ad9e138
--- /dev/null
+++ b/spec/System/TestRadiusJewelFinder_spec.lua
@@ -0,0 +1,1562 @@
+-- Tests for RadiusJewelFinder: buildJewelSockets, computeBestVariantSocketImpact, computeSocketImpact
+--
+-- Uses OccVortex (3.13 Occultist/Vortex) as reference build.
+-- Allocated jewel sockets in that build: 36634, 61419, 41263 (all occupied by jewels).
+-- All other sockets are unallocated and empty.
+
+local occVortex = LoadModule("../spec/TestBuilds/3.13/OccVortex.lua")
+
+local 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]]
+
+local 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]]
+
+local 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]]
+
+local function 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
+
+local function 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
+
+-- ─────────────────────────────────────────────────────────────────────────────
+-- Helpers
+-- ─────────────────────────────────────────────────────────────────────────────
+
+local function makeFinder()
+ return new("RadiusJewelFinder", { build = build })
+end
+
+local function getLargeRadiusIndex()
+ local map = {}
+ for i, r in ipairs(data.jewelRadius) do
+ if r.inner == 0 and not map[r.label] then map[r.label] = i end
+ end
+ return map["Large"]
+end
+
+local function getSmallRadiusIndex()
+ local map = {}
+ for i, r in ipairs(data.jewelRadius) do
+ if r.inner == 0 and not map[r.label] then map[r.label] = i end
+ end
+ return map["Small"]
+end
+
+local function makeImpossibleEscapeTestVariant()
+ local smallRadiusIndex = 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
+ -- Ensure there is at least one unallocated candidate node
+ local hasCandidate = false
+ for nodeId, n in pairs(node.nodesInRadius[smallRadiusIndex]) do
+ if not allocNodes[nodeId] and not n.ascendancyName
+ and n.type ~= "Socket" and n.type ~= "ClassStart"
+ and n.type ~= "AscendClassStart" and n.type ~= "Mastery" then
+ hasCandidate = true
+ break
+ end
+ end
+ if hasCandidate then
+ return {
+ name = keystoneName,
+ keystoneName = keystoneName,
+ rawText = buildImpossibleEscapeRawText(keystoneName),
+ }
+ end
+ end
+ end
+end
+
+local function makeThreadVariants()
+ local names = { "Small", "Medium", "Large", "Very Large", "Massive" }
+ local variants = {}
+ local idx = 1
+ for radiusIndex, radius in ipairs(data.jewelRadius) do
+ if radius.inner > 0 then
+ variants[#variants + 1] = {
+ name = names[idx] or ("Ring " .. idx),
+ radiusIndex = radiusIndex,
+ }
+ idx = idx + 1
+ end
+ end
+ return variants
+end
+
+local function isSorted(results, key)
+ for i = 2, #results do
+ if results[i - 1][key] < results[i][key] then return false end
+ end
+ return true
+end
+
+local function 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
+ for _ in pairs(build.itemsTab.items) do
+ itemCount = itemCount + 1
+ end
+
+ return {
+ socketSelItemIds = socketSelItemIds,
+ itemOrderList = itemOrderList,
+ itemCount = itemCount,
+ jewels = copyTable(build.spec.jewels, true),
+ }
+end
+
+local function assertFinderStateUnchanged(before)
+ local after = snapshotFinderState()
+ assert.are.same(before.socketSelItemIds, after.socketSelItemIds)
+ assert.are.same(before.itemOrderList, after.itemOrderList)
+ assert.are.equal(before.itemCount, after.itemCount)
+ assert.are.same(before.jewels, after.jewels)
+end
+
+-- ─────────────────────────────────────────────────────────────────────────────
+-- Tests
+-- ─────────────────────────────────────────────────────────────────────────────
+
+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("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()
+
+ 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")
+ 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")
+ 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 popup = makeFinder():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",
+ "previewList",
+ "resultDetailList",
+ "findButton",
+ "applyButton",
+ "closeButton",
+ }) do
+ assertControlInsidePopup(controlName)
+ end
+ for _, controlName in ipairs({ "findButton", "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 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_false(popup.controls.findButton:IsShown(), "Find should be hidden for All jewels")
+ 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")
+ local maxPointsTooltipTexts = buttonTooltipTexts(popup.controls.maxPointsEdit)
+ assert.is_true(#maxPointsTooltipTexts > 0, "expected Max pts tooltip content")
+ assert.is_true(maxPointsTooltipTexts[1]:find("total passive points", 1, true) ~= nil,
+ "expected Max pts tooltip to explain total point limit")
+ 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,
+ sortPctPerPoint = 10,
+ detailText = "Test detail",
+ itemTooltipLines = selectedResultPreview,
+ action = "new",
+ },
+ }, "(no compatible sockets)")
+ assert.are.equal("^7Selected Jewel", popup.controls.previewList.list[1][1])
+ assert.are.equal(180, popup.controls.previewList.height())
+ 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")
+ assert.are.equal("^7Evaluate every jewel type.", popup.controls.previewList.list[1][1])
+ assert.are.equal(48, popup.controls.previewList.height())
+
+ -- 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.findButton:IsShown(), "Find should be shown for a single jewel type")
+ 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)
+ 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
+
+ -- 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)
+ local variantTooltipTexts = tooltipTexts(popup.controls.jewelVariantSelect, 1)
+ 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 _, label in ipairs(temperedLabels) do
+ assert.is_truthy(label:find("Tempered") or label:find("Transcendent"),
+ "variant should be Tempered or Transcendent: " .. label)
+ 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")
+ local splitVariantTooltipTexts = tooltipTexts(popup.controls.jewelVariantSelect, 1)
+ 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 _, label in ipairs(splitLabels) do
+ assert.is_string(label)
+ assert.is_true(#label > 0, "variant label should not be empty")
+ assert.is_nil(seenLabels[label], "duplicate Split Personality variant: " .. label)
+ seenLabels[label] = true
+ 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")
+
+ -- 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)
+
+ -- ── buildVariantsFromUniqueItem ──────────────────────────────────────────
+
+ describe("buildVariantsFromUniqueItem", function()
+
+ it("builds Light of Meaning variants with valid name and rawText", function()
+ local variants = makeFinder():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")
+ end
+ end)
+
+ it("builds Split Personality variants with unique names", function()
+ local variants = makeFinder():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 = makeFinder():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)
+
+ -- ── discoverFoulbornVariants ─────────────────────────────────────────────
+
+ describe("discoverFoulbornVariants", function()
+
+ it("returns empty table when no Foulborn data exists", function()
+ local radiusIndexByLabel = {}
+ for i, r in ipairs(data.jewelRadius) do
+ if r.inner == 0 and not radiusIndexByLabel[r.label] then
+ radiusIndexByLabel[r.label] = i
+ end
+ end
+ local variants = makeFinder():discoverFoulbornVariants("Might of the Meek", radiusIndexByLabel)
+ assert.is_table(variants)
+ -- Some data sets include Foulborn items and some do not.
+ local hasFoulborn = false
+ if data.uniques.generated then
+ for _, rawText in ipairs(data.uniques.generated) do
+ if type(rawText) == "string" and rawText:match("^Foulborn ") then
+ hasFoulborn = true
+ break
+ end
+ end
+ end
+ if not hasFoulborn then
+ assert.are.equal(0, #variants, "expected no Foulborn variants when no Foulborn data exists")
+ else
+ assert.is_true(#variants > 0, "expected Foulborn variants when Foulborn data exists")
+ for _, v in ipairs(variants) do
+ assert.is_string(v.name)
+ assert.is_string(v.rawText)
+ assert.is_true(v.isFoulborn)
+ assert.is_number(v.comboIndex)
+ end
+ end
+ end)
+
+ 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 makeFinder():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():computeBestVariantSocketImpact(sockets, variants, "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("results are sorted by delta descending", function()
+ local sockets = getSockets()
+ local results, _ = makeFinder():computeBestVariantSocketImpact(sockets, getLightOfMeaningVariants(), "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():computeBestVariantSocketImpact(sockets, getLightOfMeaningVariants(), "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():computeBestVariantSocketImpact(sockets, getLightOfMeaningVariants(), "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():computeBestVariantSocketImpact(sockets, getLightOfMeaningVariants(), "Life")
+ assertFinderStateUnchanged(before)
+ end)
+
+ it("respects occupiedMode filter", function()
+ local sockets = getSockets()
+ local results, _ = makeFinder():computeBestVariantSocketImpact(sockets, getLightOfMeaningVariants(), "Life", nil, nil, { id = "all" })
+ assert.is_true(#results > 0, "expected results with occupied mode 'all'")
+ end)
+
+ end)
+
+ -- ── computeSocketImpact (MoM / UI / AK) ────────────────────────────────
+
+ describe("computeSocketImpact", function()
+
+ local function getSockets()
+ return makeFinder():buildJewelSockets(getLargeRadiusIndex())
+ end
+
+ it("returns a table (may be empty if all sockets occupied)", function()
+ local results, baseline = makeFinder():computeSocketImpact(
+ getSockets(), MIGHT_OF_MEEK_RAW_TEXT, "Life")
+ 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 = makeFinder():computeSocketImpact(
+ getSockets(), MIGHT_OF_MEEK_RAW_TEXT, "Life")
+ assert.are.equal(expectedBaseline, baseline)
+ end)
+
+ it("returns at least one result for the fixture build", function()
+ local results, _ = makeFinder():computeSocketImpact(
+ getSockets(), MIGHT_OF_MEEK_RAW_TEXT, "Life")
+ 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, _ = makeFinder():computeSocketImpact(
+ getSockets(), MIGHT_OF_MEEK_RAW_TEXT, "Life")
+ 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, _ = makeFinder():computeSocketImpact(
+ getSockets(), MIGHT_OF_MEEK_RAW_TEXT, "Life")
+ 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"]
+ makeFinder():computeSocketImpact(getSockets(), MIGHT_OF_MEEK_RAW_TEXT, "Life")
+ assert.are.equal(before, build.calcsTab.mainOutput["Life"])
+ end)
+
+ it("MoM: restores socket and item state after compute", function()
+ local before = snapshotFinderState()
+ makeFinder():computeSocketImpact(getSockets(), MIGHT_OF_MEEK_RAW_TEXT, "Life")
+ assertFinderStateUnchanged(before)
+ end)
+
+ it("UI: restores TotalLife after compute", function()
+ local before = build.calcsTab.mainOutput["Life"]
+ makeFinder():computeSocketImpact(getSockets(), UNNATURAL_INSTINCT_RAW_TEXT, "Life")
+ assert.are.equal(before, build.calcsTab.mainOutput["Life"])
+ end)
+
+ it("AK: restores TotalLife after compute", function()
+ local before = build.calcsTab.mainOutput["Life"]
+ makeFinder():computeSocketImpact(getSockets(), ANATOMICAL_KNOWLEDGE_RAW_TEXT, "Life")
+ assert.are.equal(before, build.calcsTab.mainOutput["Life"])
+ end)
+
+ it("respects max total points for standard compute", function()
+ local maxPoints = 2
+ local results, _ = makeFinder():computeSocketImpact(
+ getSockets(), MIGHT_OF_MEEK_RAW_TEXT, "Life", false, 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, _ = makeFinder():computeSocketImpact(
+ getSockets(), MIGHT_OF_MEEK_RAW_TEXT, "Life")
+ 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, _ = makeFinder():computeSocketImpact(
+ getSockets(), MIGHT_OF_MEEK_RAW_TEXT, "Life", false, nil, { 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, _ = makeFinder():computeSocketImpact(
+ sockets, MIGHT_OF_MEEK_RAW_TEXT, "Life")
+ local safeResults, _ = makeFinder():computeSocketImpact(
+ sockets, MIGHT_OF_MEEK_RAW_TEXT, "Life", false, nil, { 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, _ = makeFinder():computeSocketImpact(
+ sockets, MIGHT_OF_MEEK_RAW_TEXT, "Life")
+ local allResults, _ = makeFinder():computeSocketImpact(
+ sockets, MIGHT_OF_MEEK_RAW_TEXT, "Life", false, nil, { 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, _ = makeFinder():computeSocketImpact(
+ getSockets(), MIGHT_OF_MEEK_RAW_TEXT, "Life")
+ 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
+
+ it("respects max total points for Intuitive Leap", function()
+ local maxPoints = 4
+ local results, _ = makeFinder():computeIntuitiveLeapSocketImpact(
+ getSockets(), "Life", false, "simulated_greedy", { }, nil, 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 = makeFinder():computeIntuitiveLeapSocketImpact(
+ sockets, "Life", false, "fast", { }, nil, maxPoints)
+ local simulatedResults = makeFinder():computeIntuitiveLeapSocketImpact(
+ sockets, "Life", false, "simulated_greedy", { }, nil, maxPoints)
+ assert.are.equal(0, fastResults[1].addedNodeCount or 0)
+ assert.are.equal(0, simulatedResults[1].addedNodeCount or 0)
+ 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") },
+ }
+
+ 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 = makeFinder():computeSplitPersonalitySocketImpact(sockets, "Life", variants)
+
+ 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, _ = makeFinder():computeSplitPersonalitySocketImpact(
+ getSockets(), "Life", variants, nil, 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)
+
+ end)
+
+ describe("computeImpossibleEscapeSocketImpact", function()
+
+ local function getSockets()
+ return makeFinder():buildJewelSockets(getLargeRadiusIndex())
+ 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 = makeFinder():computeImpossibleEscapeSocketImpact(
+ sockets, "Life", { variant }, "fast", { }, nil)
+ local simulatedResults, simulatedBaseline = makeFinder():computeImpossibleEscapeSocketImpact(
+ sockets, "Life", { variant }, "simulated_greedy", { }, nil)
+
+ 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, _ = makeFinder():computeImpossibleEscapeSocketImpact(
+ getSockets(), "Life", { variant }, "simulated_greedy", { }, nil, 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)
+
+ 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
+
+ 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 = makeFinder():computeThreadOfHopeSocketImpact(
+ sockets, "Life", threadVariants, "fast", { }, nil)
+ local simulatedResults, simulatedBaseline = makeFinder():computeThreadOfHopeSocketImpact(
+ sockets, "Life", threadVariants, "simulated_greedy", { }, nil)
+
+ 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, _ = makeFinder():computeThreadOfHopeSocketImpact(
+ getTestSockets(threadVariants), "Life", threadVariants, "simulated_greedy", { }, nil, 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)
+
+ 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,
+ sortPctPerPoint = score,
+ isSocketIndependent = options.isSocketIndependent,
+ 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", isSocketIndependent = true }),
+ makeRow(2, 5, { name = "independent2", isSocketIndependent = 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", isSocketIndependent = true }),
+ makeRow(2, 15, { name = "independent-2", isSocketIndependent = true }),
+ makeRow(3, 10, { name = "independent-3", isSocketIndependent = 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, { isSocketIndependent = true, points = 5 }),
+ makeRow(2, 20, { isSocketIndependent = 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", isSocketIndependent = true, points = 8 }),
+ makeRow(2, 20, { name = "ie-low-points", isSocketIndependent = 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", isSocketIndependent = 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("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("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:computeSocketImpact({
+ { id = socketId, label = "Test socket", pathDist = 7 },
+ }, MIGHT_OF_MEEK_RAW_TEXT, "Life", nil, nil, { 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/src/Classes/RadiusJewelCompute.lua b/src/Classes/RadiusJewelCompute.lua
new file mode 100644
index 0000000000..a38b2bca78
--- /dev/null
+++ b/src/Classes/RadiusJewelCompute.lua
@@ -0,0 +1,1061 @@
+-- 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 attachCompute = LoadModule("Classes/RadiusJewelCompute")
+-- attachCompute(RadiusJewelFinderClass, { extractTooltipStats, normalizeImpactStat, calculateImpactPercent, mustGetUniqueRawText })
+--
+local ipairs = ipairs
+local pairs = pairs
+local t_insert = table.insert
+local t_sort = table.sort
+local s_format = string.format
+
+return function(Class, helpers)
+
+local extractTooltipStats = helpers.extractTooltipStats
+local normalizeImpactStat = helpers.normalizeImpactStat
+local calculateImpactPercent = helpers.calculateImpactPercent
+local mustGetUniqueRawText = helpers.mustGetUniqueRawText
+
+-- ─────────────────────────────────────────────────────────────────────────────
+-- 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 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 buildNodeLabelList(nodes)
+ local labels = { }
+ for _, node in ipairs(nodes or { }) do
+ if type(node) == "table" then
+ if node.label then
+ t_insert(labels, node.label)
+ else
+ t_insert(labels, getPassiveNodeLabel(node))
+ end
+ else
+ t_insert(labels, tostring(node))
+ end
+ end
+ return labels
+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", "Rarity: Normal\nCobalt Jewel")
+ item:BuildModList()
+ if slot and slot.selItemId == 0 then
+ item.jewelSocketSource = "empty"
+ end
+ return item
+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 Class: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 Class: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 Class: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 Class:computeDisconnectedPassiveSimulatedPlan(calcFunc, baseOutput, baseValue, socketNode, slotName, item, impactStat, candidates, variantLabel, progressLabel, progress, 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({
+ addNodes = nextNodes,
+ repSlotName = slotName,
+ repItem = item,
+ })
+ 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 Class:computeDisconnectedPassiveFastPlan(calcFunc, baseOutput, baseValue, socketNode, slotName, item, impactStat, candidates, variantLabel, deltaCache, progressLabel, progress, maxAdditionalNodes, skipPlanSteps, earlyPruneThreshold)
+ impactStat = normalizeImpactStat(impactStat)
+ local jewelOnlyOutput, jewelOnlyValue
+ local function ensureJewelOnly()
+ if not jewelOnlyOutput then
+ jewelOnlyOutput = calcFunc({
+ addNodes = { [socketNode] = true },
+ repSlotName = slotName,
+ repItem = item,
+ })
+ 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({
+ addNodes = { [socketNode] = true, [node] = true },
+ repSlotName = slotName,
+ repItem = item,
+ })
+ 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 = { }
+ local estimatedDelta = 0
+ for i, entry in ipairs(scoredCandidates) do
+ if maxAdditionalNodes and i > maxAdditionalNodes then
+ break
+ end
+ t_insert(chosenNodes, entry.node)
+ estimatedDelta = estimatedDelta + entry.delta
+ end
+
+ -- Early pruning: if the sum of individual gains can't beat the current best, skip the slower final calcFunc
+ if earlyPruneThreshold and estimatedDelta <= earlyPruneThreshold then
+ return { delta = estimatedDelta, pruned = true }
+ end
+
+ local addNodes = { [socketNode] = true }
+ for _, node in ipairs(chosenNodes) do
+ addNodes[node] = true
+ end
+
+ if skipPlanSteps then
+ local finalOutput = calcFunc({
+ addNodes = addNodes,
+ repSlotName = slotName,
+ repItem = item,
+ })
+ 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({
+ addNodes = prefixAddNodes,
+ repSlotName = slotName,
+ repItem = item,
+ })
+ 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 Class:computeSocketImpact(sockets, rawText, impactStat, progress, maxTotalPoints, 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 slotName = replacementContext.slotName
+ local item = new("Item", "Rarity: Unique\n" .. rawText)
+ item:BuildModList()
+ local output = calcFunc({
+ addNodes = { [replacementContext.socketNode] = true },
+ repSlotName = slotName,
+ repItem = item,
+ })
+ 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 Class:computeBestVariantSocketImpact(sockets, variants, impactStat, progress, maxTotalPoints, 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 slotName = replacementContext.slotName
+ 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", "Rarity: Unique\n" .. variant.rawText)
+ item:BuildModList()
+ local output = calcFunc({
+ addNodes = { [socketNode] = true },
+ repSlotName = slotName,
+ repItem = item,
+ })
+ 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 Class:computeIntuitiveLeapSocketImpact(sockets, impactStat, variant, methodId, planCache, progress, maxTotalPoints, occupiedMode, skipPlanSteps)
+ impactStat = normalizeImpactStat(impactStat)
+ local calcFunc, baseOutput = self.build.calcsTab:GetMiscCalculator()
+ local realBaseline = self:getImpactValue(impactStat, baseOutput)
+ local statField = impactStat.field
+ local radiusLookup = { }
+ for i, radius in ipairs(data.jewelRadius) do
+ if radius.inner == 0 and not radiusLookup[radius.label] then
+ radiusLookup[radius.label] = i
+ end
+ end
+
+ local isMassiveRadius = variant and variant.isMassiveRadius
+ local keystoneOnly = variant and variant.keystoneOnly or false
+ local rawText = (variant and variant.rawText) or mustGetUniqueRawText("Intuitive Leap")
+
+ local function collectMassiveNodes(socketNode)
+ local nodes = { }
+ if not socketNode or not socketNode.nodesInRadius then
+ return nodes
+ end
+ for idx, radius in ipairs(data.jewelRadius) do
+ if radius.outer <= 2400 and socketNode.nodesInRadius[idx] then
+ for nodeId, node in pairs(socketNode.nodesInRadius[idx]) do
+ nodes[nodeId] = node
+ end
+ end
+ end
+ return nodes
+ end
+
+ local candidateOptions = isMassiveRadius and {
+ collectNodes = collectMassiveNodes,
+ keystoneOnly = keystoneOnly,
+ } or {
+ radiusIndex = radiusLookup["Small"],
+ keystoneOnly = false,
+ }
+
+ 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 slotName = replacementContext.slotName
+ local item = new("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 result
+ if methodId == "fast" then
+ local cacheKey = s_format("IL|%s|%s", statField, variantKey)
+ planCache[cacheKey] = planCache[cacheKey] or { }
+ result = self:computeDisconnectedPassiveFastPlan(calcFunc, replacementContext.baselineOutput, socketBaseline, socketNode, slotName, item, impactStat, candidates, nil, planCache[cacheKey], socket.label, socketProgress, maxAdditionalNodes, skipPlanSteps)
+ else
+ result = self:computeDisconnectedPassiveSimulatedPlan(calcFunc, replacementContext.baselineOutput, socketBaseline, socketNode, slotName, item, impactStat, candidates, nil, socket.label, socketProgress, maxAdditionalNodes)
+ end
+ result.socket = socket
+ 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 Class:computeThreadOfHopeSocketImpact(sockets, impactStat, threadVariants, methodId, planCache, progress, maxTotalPoints, occupiedMode, 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 threadRawText = mustGetUniqueRawText("Thread of Hope")
+ local threadItems = { }
+ for variantIndex in ipairs(threadVariants) do
+ local item = new("Item", "Rarity: Unique\n" .. threadRawText)
+ item.variant = variantIndex
+ item:BuildModList()
+ threadItems[variantIndex] = item
+ end
+
+ -- Pass 1: find best ring variant per socket (skip plan steps, with early pruning)
+ local pendingPlanSteps = { }
+ 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 socketBaseline = self:getImpactValue(impactStat, replacementContext.baselineOutput)
+ local bestResult
+ local bestVariantIndex, bestCandidates
+ for variantIndex, threadVariant in ipairs(threadVariants) do
+ local variantProgress = progressChild(socketProgress, (variantIndex - 1) / #threadVariants, 1 / #threadVariants)
+ local item = threadItems[variantIndex]
+ 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 earlyPruneThreshold = bestResult and bestResult.delta or nil
+ local result
+ if methodId == "fast" then
+ local cacheKey = s_format("ThreadOfHope|%s", statField)
+ planCache[cacheKey] = planCache[cacheKey] or { }
+ result = self:computeDisconnectedPassiveFastPlan(calcFunc, replacementContext.baselineOutput, socketBaseline, socketNode, slotName, item, impactStat, candidates, threadVariant.name .. " Ring", planCache[cacheKey], socket.label .. " | " .. threadVariant.name .. " Ring", variantProgress, maxAdditionalNodes, true, earlyPruneThreshold)
+ else
+ result = self:computeDisconnectedPassiveSimulatedPlan(calcFunc, replacementContext.baselineOutput, socketBaseline, socketNode, slotName, item, impactStat, candidates, threadVariant.name .. " Ring", socket.label .. " | " .. threadVariant.name .. " Ring", variantProgress, maxAdditionalNodes)
+ end
+ if not result.pruned then
+ 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
+ bestVariantIndex = variantIndex
+ bestCandidates = candidates
+ end
+ 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)
+ if not skipPlanSteps and methodId == "fast" and bestVariantIndex then
+ t_insert(pendingPlanSteps, {
+ replacementContext = replacementContext,
+ socketBaseline = socketBaseline,
+ bestVariantIndex = bestVariantIndex,
+ bestCandidates = bestCandidates,
+ socketBasePoints = socketBasePoints,
+ resultIndex = #results,
+ })
+ end
+ 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)
+
+ -- Pass 2: compute plan steps only for top results (single-jewel mode)
+ if #pendingPlanSteps > 0 then
+ -- Build lookup: which result indices need plan steps (top 5 by delta)
+ local topResultIndices = { }
+ for i = 1, math.min(5, #results) do
+ topResultIndices[results[i]] = true
+ end
+ for _, pending in ipairs(pendingPlanSteps) do
+ local result = results[pending.resultIndex]
+ -- resultIndex may point to another row after sorting; check by reference
+ if not topResultIndices[result] then
+ goto continuePending
+ end
+ local replacementContext = pending.replacementContext
+ local maxAdditionalNodes = maxTotalPoints and math.max(maxTotalPoints - pending.socketBasePoints, 0) or nil
+ local cacheKey = s_format("ThreadOfHope|%s", statField)
+ local fullResult = self:computeDisconnectedPassiveFastPlan(
+ calcFunc,
+ replacementContext.baselineOutput,
+ pending.socketBaseline,
+ replacementContext.socketNode,
+ replacementContext.slotName,
+ threadItems[pending.bestVariantIndex],
+ impactStat,
+ pending.bestCandidates,
+ threadVariants[pending.bestVariantIndex].name .. " Ring",
+ planCache[cacheKey],
+ nil,
+ nil,
+ maxAdditionalNodes,
+ false,
+ nil
+ )
+ fullResult.variant = threadVariants[pending.bestVariantIndex]
+ fullResult.socket = result.socket
+ fullResult.replacedItemLabel = result.replacedItemLabel
+ fullResult.storedUnallocatedItemLabel = result.storedUnallocatedItemLabel
+ -- Replace in-place in results
+ for i, r in ipairs(results) do
+ if r == result then
+ results[i] = fullResult
+ break
+ end
+ end
+ ::continuePending::
+ end
+ end
+
+ return results, realBaseline
+end
+
+function Class:computeSplitPersonalitySocketImpact(sockets, impactStat, variants, progress, maxTotalPoints, 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 previousDistance = socketNode.distanceToClassStart
+
+ socketNode.distanceToClassStart = splitDistance
+ local baselineOutput = calcFunc({
+ addNodes = { [socketNode] = true },
+ repSlotName = slotName,
+ repItem = replacementContext.baselineItem,
+ })
+
+ local bestResult
+ for variantIdx, variant in ipairs(variants) do
+ progressTick(socketProgress, variantIdx, #variants, socket.label .. " | " .. variant.name)
+ local item = new("Item", "Rarity: Unique\n" .. variant.rawText)
+ item:BuildModList()
+ local output = calcFunc({
+ addNodes = { [socketNode] = true },
+ repSlotName = slotName,
+ repItem = item,
+ })
+ 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
+
+ socketNode.distanceToClassStart = previousDistance
+ 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
+
+function Class:computeImpossibleEscapeSocketImpact(sockets, impactStat, variants, methodId, planCache, progress, maxTotalPoints, occupiedMode, skipPlanSteps)
+ impactStat = normalizeImpactStat(impactStat)
+ local calcFunc, baseOutput = self.build.calcsTab:GetMiscCalculator()
+ local realBaseline = self:getImpactValue(impactStat, baseOutput)
+ local statField = impactStat.field
+ local results = { }
+ local smallRadiusIndex
+ for i, radius in ipairs(data.jewelRadius) do
+ if radius.label == "Small" and radius.inner == 0 then
+ smallRadiusIndex = i
+ break
+ end
+ end
+
+ local notableOrKeystoneOnly = skipPlanSteps or methodId == "fast"
+ 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", "Rarity: Unique\n" .. variant.rawText)
+ item:BuildModList()
+ variantDataByName[variant.name] = {
+ variant = variant,
+ item = item,
+ keystoneNode = keystoneNode,
+ candidates = candidates,
+ }
+ end
+ end
+ end
+
+ -- Free sockets with the same remaining points share a representative socket;
+ -- the computed result is copied back onto every socket in the group below.
+ 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
+ if #groupedOrder == 0 then
+ return results, realBaseline
+ 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)
+ local bestResultByGroupKey = { }
+ local totalPlanCount = #groupedOrder * #variants
+ local currentPlanIndex = 0
+
+ -- Track max candidate count across all variants to detect when remaining points can cover all
+ local maxCandidateCount = 0
+ for _, variantData in pairs(variantDataByName) do
+ if #variantData.candidates > maxCandidateCount then
+ maxCandidateCount = #variantData.candidates
+ end
+ end
+ local previousFreeResult
+ for _, groupEntry in ipairs(groupedOrder) do
+ -- Skip free groups whose remaining points can cover all candidates: reuse the first free group's result
+ local isFreeGroup = not groupEntry.groupKey:match("^occupied:")
+ if isFreeGroup and previousFreeResult and groupEntry.remainingPoints >= maxCandidateCount then
+ bestResultByGroupKey[groupEntry.groupKey] = previousFreeResult
+ currentPlanIndex = currentPlanIndex + #variants
+ goto continueGroup
+ end
+ local representativeSocket = groupEntry.representativeSocket
+ local replacementContext = self:buildSocketReplacementContext(calcFunc, representativeSocket.id)
+ local representativeSocketNode = replacementContext.socketNode
+ local representativeSlotName = replacementContext.slotName
+ 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 earlyPruneThreshold = bestResult and bestResult.delta or nil
+ local result
+ if methodId == "fast" then
+ local cacheKey = s_format("IE|%s|%s", statField, variant.name)
+ planCache[cacheKey] = planCache[cacheKey] or { }
+ result = self:computeDisconnectedPassiveFastPlan(
+ calcFunc,
+ replacementContext.baselineOutput,
+ socketBaseline,
+ representativeSocketNode,
+ representativeSlotName,
+ variantData.item,
+ impactStat,
+ variantData.candidates,
+ variant.name,
+ planCache[cacheKey],
+ variant.name,
+ planProgress,
+ maxAdditionalNodes,
+ true,
+ earlyPruneThreshold
+ )
+ else
+ result = self:computeDisconnectedPassiveSimulatedPlan(
+ calcFunc,
+ replacementContext.baselineOutput,
+ socketBaseline,
+ representativeSocketNode,
+ representativeSlotName,
+ variantData.item,
+ impactStat,
+ variantData.candidates,
+ variant.name,
+ variant.name,
+ planProgress,
+ maxAdditionalNodes
+ )
+ end
+ if not result.pruned then
+ 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
+ end
+ progressTick(planProgress, 1, 1, variant.name)
+ end
+ bestResultByGroupKey[groupEntry.groupKey] = bestResult
+ if isFreeGroup and not previousFreeResult then
+ previousFreeResult = bestResult
+ end
+ ::continueGroup::
+ end
+
+ 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.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)
+
+ -- Pass 2: compute plan steps for the best variant (single-jewel mode only)
+ if not skipPlanSteps and methodId == "fast" and #results > 0 then
+ local topResult = results[1]
+ local variantData = variantDataByName[topResult.variant.name]
+ if variantData then
+ -- Find the group entry for this result to get replacement context
+ for _, groupEntry in ipairs(groupedOrder) do
+ local bestResult = bestResultByGroupKey[groupEntry.groupKey]
+ if bestResult and bestResult.variant.name == topResult.variant.name 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 = s_format("IE|%s|%s", statField, topResult.variant.name)
+ local fullResult = self:computeDisconnectedPassiveFastPlan(
+ calcFunc,
+ replacementContext.baselineOutput,
+ socketBaseline,
+ replacementContext.socketNode,
+ replacementContext.slotName,
+ variantData.item,
+ impactStat,
+ variantData.candidates,
+ topResult.variant.name,
+ planCache[cacheKey],
+ nil,
+ nil,
+ maxAdditionalNodes,
+ false,
+ nil
+ )
+ fullResult.variant = topResult.variant
+ -- Apply plan steps to all copied results for this variant
+ for i, r in ipairs(results) do
+ if r.variant.name == topResult.variant.name then
+ local updated = copyTableSafe(fullResult, false, true)
+ updated.socket = r.socket
+ updated.replacedItemLabel = r.replacedItemLabel
+ updated.storedUnallocatedItemLabel = r.storedUnallocatedItemLabel
+ results[i] = updated
+ end
+ end
+ break
+ end
+ end
+ end
+ end
+
+ return results, realBaseline
+end
+
+-- Return the helper function for use by the UI
+return buildDisplayedDisconnectedPassivePlans
+
+end -- return function(Class, helpers)
diff --git a/src/Classes/RadiusJewelData.lua b/src/Classes/RadiusJewelData.lua
new file mode 100644
index 0000000000..cabf2ac318
--- /dev/null
+++ b/src/Classes/RadiusJewelData.lua
@@ -0,0 +1,1113 @@
+-- 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
+-- ─────────────────────────────────────────────────────────────────────────────
+
+M.COL_UNIQUE = "^xAF6025"
+M.COL_MOD = "^7"
+M.COL_META = "^8"
+M.COL_NEG = "^1"
+
+local COL_UNIQUE = M.COL_UNIQUE
+local COL_MOD = M.COL_MOD
+local COL_META = M.COL_META
+local COL_NEG = M.COL_NEG
+
+-- ─────────────────────────────────────────────────────────────────────────────
+-- 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", "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
+
+-- 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", "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,
+ })
+ end
+ end
+ end
+ return variants
+end
+
+M.buildVariantsFromUniqueItem = buildVariantsFromUniqueItem
+
+local function discoverFoulbornVariants(uniqueName, radiusIndexByLabel)
+ local variants = { }
+ local generated = data.uniques.generated
+ if not generated then return variants end
+ local escapedName = uniqueName:gsub("([%(%)%.%%%+%-%*%?%[%]%^%$])", "%%%1")
+ for _, rawText in ipairs(generated) do
+ local comboIndex = rawText:match("^Foulborn " .. escapedName .. " (%d+)\n")
+ if comboIndex then
+ local radiusLabel = rawText:match("\nRadius: (%a+)")
+ local radiusIndex = radiusLabel and radiusIndexByLabel[radiusLabel]
+ t_insert(variants, {
+ name = "Foulborn " .. comboIndex,
+ rawText = rawText,
+ radiusIndex = radiusIndex,
+ isFoulborn = true,
+ comboIndex = tonumber(comboIndex),
+ })
+ end
+ end
+ t_sort(variants, function(a, b) return a.comboIndex < b.comboIndex end)
+ return variants
+end
+
+M.discoverFoulbornVariants = discoverFoulbornVariants
+
+-- ─────────────────────────────────────────────────────────────────────────────
+-- 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
+
+M.scoreAllocPassives = scoreAllocPassives
+
+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 fields
+-- ─────────────────────────────────────────────────────────────────────────────
+-- Foulborn variants are discovered first, then the finder adds local fields
+-- such as scoreLabel, score, and keystoneOnly.
+
+local function addUnnaturalInstinctFoulbornFields(variant)
+ local typeMap = { Notable = "Notable", Small = "Normal" }
+ local rawText = variant.rawText
+ local gainLabel = rawText:match("Unallocated (%w+) Passive Skills")
+ local loseLabel = rawText:match("Allocated (%w+) Passive Skills.-grant nothing")
+ local gainType = gainLabel and typeMap[gainLabel]
+ local loseType = loseLabel and typeMap[loseLabel]
+ if gainType and loseType then
+ 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
+end
+
+local function addInspiredLearningFoulbornFields(variant)
+ local rawText = variant.rawText
+ if rawText:match("If no Notables Allocated") then
+ variant.scoreLabel = "no alloc notables"
+ variant.score = function(nodes, allocNodes)
+ for nodeId, node in pairs(nodes) do
+ if allocNodes[nodeId] and node.type == "Notable" then
+ return 0
+ end
+ end
+ return 1
+ end
+ elseif rawText:match("Small Passives Allocated") then
+ 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
+end
+
+local function addIntuitiveLeapFoulbornFields(variant)
+ local rawText = variant.rawText
+ if rawText:match("Massive Radius") then
+ variant.isMassiveRadius = true
+ end
+ if rawText:match("Keystone Passive Skills") then
+ variant.keystoneOnly = true
+ 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
+end
+
+local function appendFoulbornVariants(jewelType, foulbornVariants)
+ 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
+ t_insert(variants, {
+ name = name,
+ dropdownLabel = name,
+ keystoneName = name,
+ rawText = mustGetUniqueVariantRawText("Impossible Escape", name),
+ 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,
+ 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", "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 jewelPreviewFn -- set below; group preview functions read it from this outer local
+jewelPreviewFn = {
+ ["The Light of Meaning"] = function(variant)
+ if variant and variant.rawText then
+ return previewFromRawText(variant.rawText, "The Light of Meaning (" .. variant.name .. ")")
+ end
+ local lines = previewHeader("The Light of Meaning", "Prismatic Jewel", "Large",
+ { "Limited to: 1", "Source: King of The Mists" })
+ for _, v in ipairs(getLightOfMeaningVariants()) do
+ t_insert(lines, { height = 14, [1] = COL_META .. " " .. v.name })
+ end
+ return lines
+ end,
+
+ ["Might of the Meek"] = function(variant)
+ if variant and variant.rawText then
+ return previewFromRawText(variant.rawText)
+ end
+ local lines = previewHeader("Might of the Meek", "Crimson Jewel", "Large")
+ t_insert(lines, { height = 16, [1] = COL_MOD .. "50% increased Effect of non-Keystone" })
+ t_insert(lines, { height = 16, [1] = COL_MOD .. "Passive Skills in Radius" })
+ t_insert(lines, { height = 16, [1] = COL_MOD .. "Notable Passive Skills in Radius grant nothing" })
+ return lines
+ end,
+
+ ["Unnatural Instinct"] = function(variant)
+ if variant and variant.rawText then
+ return previewFromRawText(variant.rawText)
+ end
+ local lines = previewHeader("Unnatural Instinct", "Viridian Jewel", "Small",
+ { "Limited to: 1" })
+ t_insert(lines, { height = 16, [1] = COL_MOD .. "Allocated Small Passive Skills in" })
+ t_insert(lines, { height = 16, [1] = COL_MOD .. "Radius grant nothing" })
+ t_insert(lines, { height = 16, [1] = COL_MOD .. "Grants all bonuses of Unallocated" })
+ t_insert(lines, { height = 16, [1] = COL_MOD .. "Small Passive Skills in Radius" })
+ return lines
+ end,
+
+ ["Inspired Learning"] = function(variant)
+ if variant and variant.rawText then
+ return previewFromRawText(variant.rawText)
+ end
+ local lines = previewHeader("Inspired Learning", "Crimson Jewel", "Small")
+ t_insert(lines, { height = 16, [1] = COL_MOD .. "With 4 Notables Allocated in Radius," })
+ t_insert(lines, { height = 16, [1] = COL_MOD .. "When you Kill a Rare monster, you gain" })
+ t_insert(lines, { height = 16, [1] = COL_MOD .. "1 of its Modifiers for 20 seconds" })
+ return lines
+ end,
+
+ ["Anatomical Knowledge"] = function()
+ local lines = previewHeader("Anatomical Knowledge", "Cobalt Jewel", "Large",
+ { "Source: No longer obtainable" })
+ t_insert(lines, { height = 16, [1] = COL_MOD .. "(6-8)% increased maximum Life" })
+ t_insert(lines, { height = 16, [1] = COL_MOD .. "Adds 1 to Maximum Life per 3" })
+ t_insert(lines, { height = 16, [1] = COL_MOD .. "Intelligence Allocated in Radius" })
+ return lines
+ end,
+
+ ["Lioneye's Fall"] = function(variant)
+ if variant and variant.rawText then
+ return previewFromRawText(variant.rawText)
+ end
+ local lines = previewHeader("Lioneye's Fall", "Viridian Jewel", "Medium")
+ t_insert(lines, { height = 16, [1] = COL_MOD .. "Melee and Melee Weapon Type modifiers" })
+ t_insert(lines, { height = 16, [1] = COL_MOD .. "in Radius are Transformed to Bow Modifiers" })
+ return lines
+ end,
+
+ ["Intuitive Leap"] = function(variant)
+ if variant and variant.rawText then
+ return previewFromRawText(variant.rawText)
+ end
+ local lines = previewHeader("Intuitive Leap", "Viridian Jewel", "Small")
+ t_insert(lines, { height = 16, [1] = COL_MOD .. "Passives in Radius can be Allocated" })
+ t_insert(lines, { height = 16, [1] = COL_MOD .. "without being connected to your tree" })
+ return lines
+ end,
+
+ ["Tempered & Transcendent"] = function(variant)
+ if variant and variant.rawText then
+ return previewFromRawText(variant.rawText, variant.name)
+ end
+ local lines = previewHeader("Tempered & Transcendent", "Unique Jewel", "Medium")
+ t_insert(lines, { height = 14, [1] = COL_META .. "Tempered Flesh / Transcendent Flesh" })
+ t_insert(lines, { height = 14, [1] = COL_META .. "Tempered Mind / Transcendent Mind" })
+ t_insert(lines, { height = 14, [1] = COL_META .. "Tempered Spirit / Transcendent Spirit" })
+ return lines
+ end,
+
+ ["Split Personality"] = function(variant)
+ if variant and variant.rawText then
+ return previewFromRawText(variant.rawText, "Split Personality (" .. variant.name .. ")")
+ end
+ local lines = previewHeader("Split Personality", "Crimson Jewel", nil,
+ { "Limited to: 2", "Source: Drops from the Simulacrum Encounter" })
+ t_insert(lines, { height = 16, [1] = COL_MOD .. "Socket effect scales with distance to class start" })
+ t_insert(lines, { height = 14, [1] = COL_META .. "Variants: Strength, Dexterity, Intelligence, Life" })
+ t_insert(lines, { height = 14, [1] = COL_META .. "Mana, Energy Shield, Armour, Evasion, Accuracy" })
+ return lines
+ end,
+
+ ["Impossible Escape"] = function(variant)
+ if variant and variant.rawText then
+ return previewFromRawText(variant.rawText, "Impossible Escape (" .. variant.name .. ")")
+ end
+ local lines = previewHeader("Impossible Escape", "Viridian Jewel", "Small",
+ { "Limited to: 1", "Source: Drops from The Maven (Uber)" })
+ t_insert(lines, { height = 16, [1] = COL_MOD .. "Passive Skills in radius of the chosen" })
+ t_insert(lines, { height = 16, [1] = COL_MOD .. "Keystone can be allocated without connection" })
+ return lines
+ end,
+
+ ["Energy From Within"] = function()
+ local lines = previewHeader("Energy From Within", "Cobalt Jewel", "Large")
+ t_insert(lines, { height = 16, [1] = COL_MOD .. "3% increased maximum Energy Shield" })
+ t_insert(lines, { height = 16, [1] = COL_MOD .. "Life mods in Radius apply to Energy Shield" })
+ return lines
+ end,
+
+ ["Healthy Mind"] = function()
+ local lines = previewHeader("Healthy Mind", "Cobalt Jewel", "Large",
+ { "Limited to: 1" })
+ t_insert(lines, { height = 16, [1] = COL_MOD .. "15% increased maximum Mana" })
+ t_insert(lines, { height = 16, [1] = COL_MOD .. "Life mods in Radius apply to Mana at 200%" })
+ return lines
+ end,
+
+ ["Energised Armour"] = function()
+ local lines = previewHeader("Energised Armour", "Crimson Jewel", "Large")
+ t_insert(lines, { height = 16, [1] = COL_MOD .. "15% increased Armour" })
+ t_insert(lines, { height = 16, [1] = COL_MOD .. "ES mods in Radius apply to Armour at 200%" })
+ return lines
+ end,
+
+ ["Brute Force Solution"] = function()
+ local lines = previewHeader("Brute Force Solution", "Cobalt Jewel", "Large")
+ t_insert(lines, { height = 16, [1] = COL_MOD .. "+16 to Intelligence" })
+ t_insert(lines, { height = 16, [1] = COL_MOD .. "Strength from Passives -> Intelligence" })
+ return lines
+ end,
+
+ ["Careful Planning"] = function()
+ local lines = previewHeader("Careful Planning", "Viridian Jewel", "Large")
+ t_insert(lines, { height = 16, [1] = COL_MOD .. "+16 to Dexterity" })
+ t_insert(lines, { height = 16, [1] = COL_MOD .. "Intelligence from Passives -> Dexterity" })
+ return lines
+ end,
+
+ ["Efficient Training"] = function()
+ local lines = previewHeader("Efficient Training", "Crimson Jewel", "Large")
+ t_insert(lines, { height = 16, [1] = COL_MOD .. "+16 to Strength" })
+ t_insert(lines, { height = 16, [1] = COL_MOD .. "Intelligence from Passives -> Strength" })
+ return lines
+ end,
+
+ ["Fertile Mind"] = function()
+ local lines = previewHeader("Fertile Mind", "Cobalt Jewel", "Large")
+ t_insert(lines, { height = 16, [1] = COL_MOD .. "+16 to Intelligence" })
+ t_insert(lines, { height = 16, [1] = COL_MOD .. "Dexterity from Passives -> Intelligence" })
+ return lines
+ end,
+
+ ["Fluid Motion"] = function()
+ local lines = previewHeader("Fluid Motion", "Viridian Jewel", "Large")
+ t_insert(lines, { height = 16, [1] = COL_MOD .. "+16 to Dexterity" })
+ t_insert(lines, { height = 16, [1] = COL_MOD .. "Strength from Passives -> Dexterity" })
+ return lines
+ end,
+
+ ["Inertia"] = function()
+ local lines = previewHeader("Inertia", "Crimson Jewel", "Large")
+ t_insert(lines, { height = 16, [1] = COL_MOD .. "+16 to Strength" })
+ t_insert(lines, { height = 16, [1] = COL_MOD .. "Dexterity from Passives -> Strength" })
+ return lines
+ end,
+
+ ["Combat Focus (Crimson)"] = function()
+ local lines = previewHeader("Combat Focus", "Crimson Jewel", "Medium",
+ { "Limited to: 2", "Source: Vendor Recipe" })
+ t_insert(lines, { height = 16, [1] = COL_MOD .. "10% increased Elemental Damage" })
+ t_insert(lines, { height = 16, [1] = COL_MOD .. "Prismatic Skills lose Cold" })
+ t_insert(lines, { height = 16, [1] = COL_MOD .. "with 40 total Str+Int in Radius" })
+ return lines
+ end,
+
+ ["Combat Focus (Cobalt)"] = function()
+ local lines = previewHeader("Combat Focus", "Cobalt Jewel", "Medium",
+ { "Limited to: 2", "Source: Vendor Recipe" })
+ t_insert(lines, { height = 16, [1] = COL_MOD .. "10% increased Elemental Damage" })
+ t_insert(lines, { height = 16, [1] = COL_MOD .. "Prismatic Skills lose Fire" })
+ t_insert(lines, { height = 16, [1] = COL_MOD .. "with 40 total Int+Dex in Radius" })
+ return lines
+ end,
+
+ ["Combat Focus (Viridian)"] = function()
+ local lines = previewHeader("Combat Focus", "Viridian Jewel", "Medium",
+ { "Limited to: 2", "Source: Vendor Recipe" })
+ t_insert(lines, { height = 16, [1] = COL_MOD .. "10% increased Elemental Damage" })
+ t_insert(lines, { height = 16, [1] = COL_MOD .. "Prismatic Skills lose Lightning" })
+ t_insert(lines, { height = 16, [1] = COL_MOD .. "with 40 total Dex+Str in Radius" })
+ return lines
+ end,
+
+ ["Attribute Conversion"] = function(variant)
+ if variant and jewelPreviewFn[variant.name] then
+ return jewelPreviewFn[variant.name]()
+ end
+ local lines = previewHeader("Attribute Conversion", "Corrupted Jewel", "Large")
+ t_insert(lines, { height = 14, [1] = COL_META .. "Brute Force Solution: Str -> Int" })
+ t_insert(lines, { height = 14, [1] = COL_META .. "Careful Planning: Int -> Dex" })
+ t_insert(lines, { height = 14, [1] = COL_META .. "Efficient Training: Int -> Str" })
+ t_insert(lines, { height = 14, [1] = COL_META .. "Fertile Mind: Dex -> Int" })
+ t_insert(lines, { height = 14, [1] = COL_META .. "Fluid Motion: Str -> Dex" })
+ t_insert(lines, { height = 14, [1] = COL_META .. "Inertia: Dex -> Str" })
+ return lines
+ end,
+
+ ["Stat Conversion"] = function(variant)
+ if variant and jewelPreviewFn[variant.name] then
+ return jewelPreviewFn[variant.name]()
+ end
+ local lines = previewHeader("Stat Conversion", "Corrupted Jewel", "Large")
+ t_insert(lines, { height = 14, [1] = COL_META .. "Energy From Within: Life -> Energy Shield" })
+ t_insert(lines, { height = 14, [1] = COL_META .. "Healthy Mind: Life -> Mana (200%)" })
+ t_insert(lines, { height = 14, [1] = COL_META .. "Energised Armour: ES -> Armour (200%)" })
+ return lines
+ end,
+
+ ["Combat Focus"] = function(variant)
+ if variant and jewelPreviewFn[variant.name] then
+ return jewelPreviewFn[variant.name]()
+ end
+ local lines = previewHeader("Combat Focus", "Jewel", "Medium",
+ { "Limited to: 2", "Source: Vendor Recipe" })
+ t_insert(lines, { height = 14, [1] = COL_META .. "Crimson: lose Cold (Str+Int >= 40)" })
+ t_insert(lines, { height = 14, [1] = COL_META .. "Cobalt: lose Fire (Int+Dex >= 40)" })
+ t_insert(lines, { height = 14, [1] = COL_META .. "Viridian: lose Lightning (Dex+Str >= 40)" })
+ return lines
+ end,
+
+ ["Dreams & Nightmares"] = function(variant)
+ if variant and variant.rawText then
+ local extraPreviewMeta = nil
+ if variant.family then
+ extraPreviewMeta = { "Family: " .. variant.family:gsub("^The ", "") }
+ end
+ return previewFromRawText(variant.rawText, variant.name, extraPreviewMeta)
+ end
+ local lines = previewHeader("Dreams & Nightmares", "Unique Jewel", "Large")
+ t_insert(lines, { height = 14, [1] = COL_META .. "The Red Dream: Fire Res -> Endurance on Kill" })
+ t_insert(lines, { height = 14, [1] = COL_META .. "The Red Nightmare: Fire Res -> Block" })
+ t_insert(lines, { height = 14, [1] = COL_META .. "The Green Dream: Cold Res -> Frenzy on Kill" })
+ t_insert(lines, { height = 14, [1] = COL_META .. "The Green Nightmare: Cold Res -> Suppress" })
+ t_insert(lines, { height = 14, [1] = COL_META .. "The Blue Dream: Lightning Res -> Power on Kill" })
+ t_insert(lines, { height = 14, [1] = COL_META .. "The Blue Nightmare: Lightning Res -> Spell Block" })
+ return lines
+ end,
+
+ ["The Red Dream"] = function()
+ local lines = previewHeader("The Red Dream", "Crimson Jewel", "Large")
+ t_insert(lines, { height = 16, [1] = COL_MOD .. "Passives granting Fire/All Res in Radius" })
+ t_insert(lines, { height = 16, [1] = COL_MOD .. "also grant Endurance Charge on Kill" })
+ return lines
+ end,
+
+ ["The Red Nightmare"] = function()
+ local lines = previewHeader("The Red Nightmare", "Crimson Jewel", "Large")
+ t_insert(lines, { height = 16, [1] = COL_MOD .. "Passives granting Fire/All Res in Radius" })
+ t_insert(lines, { height = 16, [1] = COL_MOD .. "also grant Chance to Block at 50%" })
+ return lines
+ end,
+
+ ["The Green Dream"] = function()
+ local lines = previewHeader("The Green Dream", "Viridian Jewel", "Large")
+ t_insert(lines, { height = 16, [1] = COL_MOD .. "Passives granting Cold/All Res in Radius" })
+ t_insert(lines, { height = 16, [1] = COL_MOD .. "also grant Frenzy Charge on Kill" })
+ return lines
+ end,
+
+ ["The Green Nightmare"] = function()
+ local lines = previewHeader("The Green Nightmare", "Viridian Jewel", "Large")
+ t_insert(lines, { height = 16, [1] = COL_MOD .. "Passives granting Cold/All Res in Radius" })
+ t_insert(lines, { height = 16, [1] = COL_MOD .. "also grant Chance to Suppress at 70%" })
+ return lines
+ end,
+
+ ["The Blue Dream"] = function()
+ local lines = previewHeader("The Blue Dream", "Cobalt Jewel", "Large")
+ t_insert(lines, { height = 16, [1] = COL_MOD .. "Passives granting Lightning/All Res in Radius" })
+ t_insert(lines, { height = 16, [1] = COL_MOD .. "also grant Power Charge on Kill" })
+ return lines
+ end,
+
+ ["The Blue Nightmare"] = function()
+ local lines = previewHeader("The Blue Nightmare", "Cobalt Jewel", "Large")
+ t_insert(lines, { height = 16, [1] = COL_MOD .. "Passives granting Lightning/All Res in Radius" })
+ t_insert(lines, { height = 16, [1] = COL_MOD .. "also grant Spell Block at 50%" })
+ return lines
+ end,
+
+ ["Thread of Hope"] = function(ringName)
+ local ring = ringName or "?"
+ local lines = previewHeader("Thread of Hope", "Crimson Jewel", "Variable",
+ { "Source: Drops from Sirus, Awakener of Worlds" })
+ t_insert(lines, { height = 16, [1] = COL_MOD .. "Only affects Passives in " .. ring .. " Ring" })
+ t_insert(lines, { height = 16, [1] = COL_MOD .. "Passive Skills in Radius can be Allocated" })
+ t_insert(lines, { height = 16, [1] = COL_MOD .. "without being connected to your tree" })
+ t_insert(lines, { height = 6, [1] = "" })
+ t_insert(lines, { height = 16, [1] = COL_NEG .. "-(20-10)% to all Elemental Resistances" })
+ return lines
+ end,
+}
+
+M.jewelPreviewFn = jewelPreviewFn
+
+-- ─────────────────────────────────────────────────────────────────────────────
+-- Jewel type definitions
+-- ─────────────────────────────────────────────────────────────────────────────
+
+function M.buildJewelTypes(radiusIndexByLabel)
+ local mightOfTheMeek = {
+ name = "Might of the Meek",
+ radiusIndex = radiusIndexByLabel["Large"],
+ scoreLabel = "alloc small passives",
+ hasCompute = true,
+ rawText = mustGetUniqueRawText("Might of the Meek"),
+ 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,
+ }
+ appendFoulbornVariants(mightOfTheMeek, discoverFoulbornVariants("Might of the Meek", radiusIndexByLabel))
+
+ local inspiredLearning = {
+ name = "Inspired Learning",
+ hasCompute = true,
+ radiusIndex = radiusIndexByLabel["Small"],
+ scoreLabel = "alloc notables",
+ rawText = mustGetUniqueRawText("Inspired Learning"),
+ score = function(nodes, allocNodes)
+ local s = 0
+ for nodeId, node in pairs(nodes) do
+ if allocNodes[nodeId] and node.type == "Notable" then
+ s = s + 1
+ end
+ end
+ return s
+ end,
+ }
+ do
+ local foulbornVariants = discoverFoulbornVariants("Inspired Learning", radiusIndexByLabel)
+ for _, variant in ipairs(foulbornVariants) do addInspiredLearningFoulbornFields(variant) end
+ appendFoulbornVariants(inspiredLearning, foulbornVariants)
+ end
+
+ local unnaturalInstinct = {
+ name = "Unnatural Instinct",
+ radiusIndex = radiusIndexByLabel["Small"],
+ scoreLabel = "unalloc small - alloc small",
+ hasCompute = true,
+ rawText = mustGetUniqueRawText("Unnatural Instinct"),
+ score = function(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,
+ }
+ do
+ local foulbornVariants = discoverFoulbornVariants("Unnatural Instinct", radiusIndexByLabel)
+ for _, variant in ipairs(foulbornVariants) do addUnnaturalInstinctFoulbornFields(variant) end
+ appendFoulbornVariants(unnaturalInstinct, foulbornVariants)
+ end
+
+ local lioneyesFall = {
+ name = "Lioneye's Fall",
+ radiusIndex = radiusIndexByLabel["Medium"],
+ scoreLabel = "alloc passives",
+ hasCompute = true,
+ rawText = mustGetUniqueRawText("Lioneye's Fall"),
+ score = scoreAllocPassives,
+ }
+ appendFoulbornVariants(lioneyesFall, discoverFoulbornVariants("Lioneye's Fall", radiusIndexByLabel))
+
+ local intuitiveLeap = {
+ name = "Intuitive Leap",
+ radiusIndex = radiusIndexByLabel["Small"],
+ scoreLabel = "unalloc passives",
+ hasCompute = true,
+ computeMethods = M.DISCONNECTED_PASSIVE_COMPUTE_METHODS,
+ rawText = mustGetUniqueRawText("Intuitive Leap"),
+ score = function(nodes, allocNodes)
+ return scoreUnallocPassives(nodes, allocNodes)
+ end,
+ }
+ do
+ local foulbornVariants = discoverFoulbornVariants("Intuitive Leap", radiusIndexByLabel)
+ for _, variant in ipairs(foulbornVariants) do addIntuitiveLeapFoulbornFields(variant) end
+ appendFoulbornVariants(intuitiveLeap, foulbornVariants)
+ end
+
+ local dreamsNightmaresFamilies = {
+ { name = "The Red Dream", baseName = "Crimson Jewel" },
+ { name = "The Red Nightmare", baseName = "Crimson Jewel" },
+ { name = "The Green Dream", baseName = "Viridian Jewel" },
+ { name = "The Green Nightmare", baseName = "Viridian Jewel" },
+ { name = "The Blue Dream", baseName = "Cobalt Jewel" },
+ { name = "The Blue Nightmare", baseName = "Cobalt Jewel" },
+ }
+ local dreamsVariants = { }
+ for _, familyInfo in ipairs(dreamsNightmaresFamilies) do
+ t_insert(dreamsVariants, {
+ name = familyInfo.name,
+ family = familyInfo.name,
+ rawText = mustGetCurrentUniqueRawText(familyInfo.name),
+ })
+ local foulbornVariants = discoverFoulbornVariants(familyInfo.name, radiusIndexByLabel)
+ for _, variant in ipairs(foulbornVariants) do
+ variant.family = familyInfo.name
+ variant.name = familyInfo.name .. " (" .. variant.name .. ")"
+ t_insert(dreamsVariants, variant)
+ end
+ end
+
+ local jewelTypes = { }
+ t_insert(jewelTypes, {
+ name = "The Light of Meaning",
+ limit = 1,
+ radiusIndex = radiusIndexByLabel["Large"],
+ scoreLabel = "alloc passives",
+ hasCompute = true,
+ score = scoreAllocPassives,
+ variants = getLightOfMeaningVariants(),
+ })
+ t_insert(jewelTypes, mightOfTheMeek)
+ t_insert(jewelTypes, unnaturalInstinct)
+ t_insert(jewelTypes, inspiredLearning)
+ t_insert(jewelTypes, {
+ name = "Anatomical Knowledge",
+ radiusIndex = radiusIndexByLabel["Large"],
+ scoreLabel = "alloc passives",
+ hasCompute = true,
+ isLegacy = true,
+ rawText = mustGetUniqueRawText("Anatomical Knowledge"),
+ score = scoreAllocPassives,
+ })
+ t_insert(jewelTypes, {
+ name = "Tempered & Transcendent",
+ radiusIndex = radiusIndexByLabel["Medium"],
+ scoreLabel = "attr in radius",
+ hasCompute = true,
+ score = function(nodes, allocNodes)
+ return scoreRadiusAttributes(nodes, allocNodes, "Str", true, false)
+ end,
+ variants = M.getTemperedTranscendentVariants(),
+ })
+ t_insert(jewelTypes, lioneyesFall)
+ t_insert(jewelTypes, intuitiveLeap)
+ t_insert(jewelTypes, {
+ name = "Impossible Escape",
+ isImpossibleEscape = true,
+ isSocketIndependent = true,
+ scoreLabel = "unalloc notable/keystone near keystone",
+ hasCompute = true,
+ computeMethods = M.DISCONNECTED_PASSIVE_COMPUTE_METHODS,
+ score = scoreUnallocNotablesAndKeystones,
+ variants = M.getImpossibleEscapeVariants(),
+ })
+ t_insert(jewelTypes, {
+ name = "Split Personality",
+ isSplitPersonality = true,
+ scoreLabel = "dist to start",
+ hasCompute = true,
+ score = function()
+ return 0
+ end,
+ variants = M.getSplitPersonalityVariants(),
+ })
+ t_insert(jewelTypes, {
+ name = "Stat Conversion",
+ radiusIndex = radiusIndexByLabel["Large"],
+ scoreLabel = "alloc passives",
+ hasCompute = true,
+ score = scoreAllocPassives,
+ variants = {
+ { name = "Energy From Within", rawText = mustGetUniqueRawText("Energy From Within") },
+ { name = "Healthy Mind", rawText = mustGetUniqueRawText("Healthy Mind") },
+ { name = "Energised Armour", rawText = mustGetUniqueRawText("Energised Armour") },
+ },
+ })
+ t_insert(jewelTypes, {
+ name = "Attribute Conversion",
+ radiusIndex = radiusIndexByLabel["Large"],
+ scoreLabel = "alloc passives",
+ hasCompute = true,
+ score = scoreAllocPassives,
+ variants = {
+ { name = "Brute Force Solution", rawText = mustGetUniqueRawText("Brute Force Solution") },
+ { name = "Careful Planning", rawText = mustGetUniqueRawText("Careful Planning") },
+ { name = "Efficient Training", rawText = mustGetUniqueRawText("Efficient Training") },
+ { name = "Fertile Mind", rawText = mustGetUniqueRawText("Fertile Mind") },
+ { name = "Fluid Motion", rawText = mustGetUniqueRawText("Fluid Motion") },
+ { name = "Inertia", rawText = mustGetUniqueRawText("Inertia") },
+ },
+ })
+ t_insert(jewelTypes, {
+ name = "Combat Focus",
+ radiusIndex = radiusIndexByLabel["Medium"],
+ scoreLabel = "alloc passives",
+ hasCompute = true,
+ score = scoreAllocPassives,
+ variants = {
+ { name = "Combat Focus (Crimson)", rawText = mustGetUniqueRawText("Combat Focus", "Crimson Jewel") },
+ { name = "Combat Focus (Cobalt)", rawText = mustGetUniqueRawText("Combat Focus", "Cobalt Jewel") },
+ { name = "Combat Focus (Viridian)", rawText = mustGetUniqueRawText("Combat Focus", "Viridian Jewel") },
+ },
+ })
+ t_insert(jewelTypes, {
+ name = "Dreams & Nightmares",
+ radiusIndex = radiusIndexByLabel["Large"],
+ scoreLabel = "alloc passives",
+ hasCompute = true,
+ score = scoreAllocPassives,
+ variants = dreamsVariants,
+ })
+ t_insert(jewelTypes, {
+ name = "Thread of Hope",
+ isThread = true,
+ scoreLabel = "unalloc notable/keystone in ring",
+ hasCompute = true,
+ computeMethods = M.DISCONNECTED_PASSIVE_COMPUTE_METHODS,
+ rawText = nil,
+ score = scoreUnallocNotablesAndKeystones,
+ })
+ return jewelTypes
+end
+
+function M.jewelTypeSortOrder(jt)
+ if jt.name == "The Light of Meaning" then return 10 end
+ if jt.name == "Might of the Meek" then return 20 end
+ if jt.name == "Unnatural Instinct" then return 30 end
+ if jt.name == "Inspired Learning" then return 40 end
+ if jt.name == "Anatomical Knowledge" then return 50 end
+ if jt.name == "Tempered & Transcendent" then return 55 end
+ if jt.name == "Lioneye's Fall" then return 60 end
+ if jt.name == "Intuitive Leap" then return 70 end
+ if jt.isImpossibleEscape then return 75 end
+ if jt.isSplitPersonality then return 80 end
+ if jt.name == "Stat Conversion" then return 90 end
+ if jt.name == "Attribute Conversion" then return 100 end
+ if jt.name == "Combat Focus" then return 110 end
+ if jt.name == "Dreams & Nightmares" then return 120 end
+ if jt.isThread then return 130 end
+ return 1000
+end
+
+return M
diff --git a/src/Classes/RadiusJewelFinder.lua b/src/Classes/RadiusJewelFinder.lua
new file mode 100644
index 0000000000..36d152b00d
--- /dev/null
+++ b/src/Classes/RadiusJewelFinder.lua
@@ -0,0 +1,2618 @@
+-- Path of Building
+--
+-- Class: Radius Jewel Finder
+-- Popup that scores passive tree sockets for radius unique jewels.
+-- Supports: The Light of Meaning, Might of the Meek, Unnatural Instinct,
+-- Inspired Learning, Anatomical Knowledge, Thread of Hope, Lioneye's Fall,
+-- Intuitive Leap, Tempered Flesh, Tempered Mind, Tempered Spirit,
+-- Transcendent Flesh, Transcendent Mind, Transcendent Spirit,
+-- Split Personality, Impossible Escape,
+-- Energy From Within, Healthy Mind, Energised Armour,
+-- Brute Force Solution, Careful Planning, Efficient Training,
+-- Fertile Mind, Fluid Motion, Inertia,
+-- Combat Focus (Crimson/Cobalt/Viridian),
+-- The Red Dream, The Red Nightmare, The Green Dream, The Green Nightmare,
+-- The Blue Dream, The Blue Nightmare.
+--
+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 COL_META = RadiusJewelData.COL_META
+
+-- Small output snapshot for stat-comparison tooltips.
+-- Copies only scalar fields and the small tables needed by
+-- AddStatComparesToTooltip / AddRequirementWarningsToTooltip,
+-- skipping heavy sub-tables (SkillDPS, env, modDB, etc.)
+-- that would otherwise cause multi-GB memory usage.
+local function extractTooltipStats(output)
+ if not output then return nil end
+ local out = {}
+ for k, v in pairs(output) do
+ local t = type(v)
+ if t == "number" or t == "string" or t == "boolean" then
+ out[k] = v
+ end
+ end
+ -- Requirement fail lists (small tables with source references)
+ for _, key in ipairs({"ReqStrFailList", "ReqDexFailList", "ReqIntFailList", "ReqOmniFailList",
+ "ReqStrItem", "ReqDexItem", "ReqIntItem", "ReqOmniItem"}) do
+ if output[key] then
+ out[key] = output[key]
+ end
+ end
+ -- Copy minion stats with the same scalar-only treatment.
+ if output.Minion then
+ out.Minion = extractTooltipStats(output.Minion)
+ end
+ return out
+end
+
+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 = {
+ new = "^2",
+ move = "^x33AAFF",
+ moveReplace = "^xBB88FF",
+ replace = "^xFFAA33",
+ keep = "^8",
+}
+local function colorSocketLabel(row)
+ return (row.action and ACTION_COLORS[row.action] or "") .. row.socketLabel
+end
+
+local RESULT_DETAIL_COLUMN_BY_MODE = {
+ computeSocket = 6,
+ computeSocketAll = 7,
+ find = 5,
+ findThread = 6,
+}
+local RESULT_SOCKET_COLUMN_BY_MODE = {
+ computeSocket = 1,
+ computeSocketAll = 2,
+ find = 1,
+ findThread = 1,
+}
+local RESULT_STAT_COLUMNS_BY_MODE = {
+ computeSocket = { [3] = true, [4] = true, [5] = true },
+ computeSocketAll = { [4] = true, [5] = true, [6] = true },
+}
+local RESULT_ITEM_COLUMNS_BY_MODE = {
+ computeSocket = { [6] = true },
+ computeSocketAll = { [7] = true },
+ find = { [5] = true },
+ findThread = { [6] = true },
+}
+
+local RadiusJewelResultsListClass = newClass("RadiusJewelResultsListControl", "ListControl", function(self, 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 = {
+ { width = rect[3] - 22, label = "" },
+ },
+ computeSocket = {
+ { width = 170, label = "Socket", sortable = true },
+ { width = 40, label = "Pts", sortable = true },
+ { width = 75, label = "Gain", sortable = true },
+ { width = 60, label = "%", sortable = true },
+ { width = 65, label = "%/Pt", sortable = true },
+ { width = 150, label = "Detail", sortable = true },
+ },
+ computeSocketAll = {
+ { width = 120, label = "Jewel", sortable = true },
+ { width = 130, label = "Socket", sortable = true },
+ { width = 40, label = "Pts", sortable = true },
+ { width = 75, label = "Gain", sortable = true },
+ { width = 60, label = "%", sortable = true },
+ { width = 65, label = "%/Pt", sortable = true },
+ { width = 70, label = "Detail", sortable = true },
+ },
+ find = {
+ { width = 170, label = "Socket", sortable = true },
+ { width = 40, label = "Pts", sortable = true },
+ { width = 60, label = "Score", sortable = true },
+ { width = 70, label = "/Pt", sortable = true },
+ { width = 220, label = "Detail", sortable = true },
+ },
+ findThread = {
+ { width = 170, label = "Socket", sortable = true },
+ { width = 40, label = "Pts", sortable = true },
+ { width = 60, label = "Score", sortable = true },
+ { width = 70, label = "/Pt", sortable = true },
+ { width = 90, label = "Ring", sortable = true },
+ { width = 130, label = "Detail", sortable = true },
+ },
+ }
+ self.defaultSortByMode = {
+ computeSocket = 5,
+ computeSocketAll = 6,
+ find = 4,
+ findThread = 4,
+ }
+ self.resultTooltip = new("Tooltip")
+ self.itemTooltip = new("Tooltip")
+end)
+
+local RadiusJewelDetailListClass = newClass("RadiusJewelDetailListControl", "TextListControl", function(self, anchor, rect, columns, list, build, socketViewer)
+ self.TextListControl(anchor, rect, columns, list)
+ self.build = build
+ self.socketViewer = socketViewer
+ self.nodeTooltip = new("Tooltip")
+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 not hoverLine.nodeId or main.popups[2] 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 function clampRectPosition(x, y, width, height)
+ x = math.max(viewPort.x, math.min(x, viewPort.x + viewPort.width - width))
+ y = math.max(viewPort.y, math.min(y, viewPort.y + viewPort.height - height))
+ return x, y
+ end
+ local function rectsOverlap(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
+ local function placeTooltip(ttW, ttH, cursorX, cursorY, blockedRects)
+ 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 },
+ }
+ for _, candidate in ipairs(candidates) do
+ local ttX, ttY = clampRectPosition(candidate.x, candidate.y, ttW, ttH)
+ local overlaps = false
+ for _, blockedRect in ipairs(blockedRects or { }) do
+ if rectsOverlap(ttX, ttY, ttW, ttH, blockedRect.x, blockedRect.y, blockedRect.width, blockedRect.height) then
+ overlaps = true
+ break
+ end
+ end
+ if not overlaps then
+ return ttX, ttY
+ end
+ end
+ return clampRectPosition(cursorX + 20, cursorY + 20, ttW, ttH)
+ end
+
+ local cursorX, cursorY = GetCursorPos()
+ 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)
+ self.nodeTooltip:Clear(true)
+ local prevShowStatDifferences = self.socketViewer.showStatDifferences
+ self.socketViewer.showStatDifferences = true
+ self.socketViewer:AddNodeTooltip(self.nodeTooltip, node, self.build)
+ self.socketViewer.showStatDifferences = prevShowStatDifferences
+ local ttW, ttH = self.nodeTooltip:GetSize()
+ local ttX, ttY = placeTooltip(ttW, ttH, cursorX, cursorY, { viewerRect })
+ self.nodeTooltip:Draw(ttX, ttY, nil, nil, viewPort)
+ SetDrawLayer(nil, 0)
+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 detailColumn = hoverColumn and RESULT_DETAIL_COLUMN_BY_MODE[self.mode] == hoverColumn
+ local socketColumn = hoverColumn and RESULT_SOCKET_COLUMN_BY_MODE[self.mode] == hoverColumn
+ local showViewer = socketColumn or (detailColumn and hoverData and hoverData.detailNodeId)
+ local showStatTooltip = hoverData and hoverData.baseOutput and hoverData.compareOutput
+ and hoverColumn and RESULT_STAT_COLUMNS_BY_MODE[self.mode] and RESULT_STAT_COLUMNS_BY_MODE[self.mode][hoverColumn]
+ local showItemTooltip = hoverData and hoverData.itemTooltipLines
+ and hoverColumn and RESULT_ITEM_COLUMNS_BY_MODE[self.mode] and RESULT_ITEM_COLUMNS_BY_MODE[self.mode][hoverColumn]
+ 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)
+ if self.mode == "computeSocket" then
+ if colIndex == 1 then
+ t_sort(self.list, function(a, b) return a.socketLabel < b.socketLabel end)
+ elseif colIndex == 2 then
+ t_sort(self.list, function(a, b) return a.points < b.points end)
+ elseif colIndex == 3 then
+ t_sort(self.list, function(a, b) return a.delta > b.delta end)
+ elseif colIndex == 4 then
+ t_sort(self.list, function(a, b) return a.pct > b.pct end)
+ elseif colIndex == 5 then
+ t_sort(self.list, function(a, b) return a.sortPctPerPoint > b.sortPctPerPoint end)
+ elseif colIndex == 6 then
+ t_sort(self.list, function(a, b) return a.detailText < b.detailText end)
+ end
+ elseif self.mode == "computeSocketAll" then
+ if colIndex == 1 then
+ t_sort(self.list, function(a, b) return a.jewelName < b.jewelName end)
+ elseif colIndex == 2 then
+ t_sort(self.list, function(a, b) return a.socketLabel < b.socketLabel end)
+ elseif colIndex == 3 then
+ t_sort(self.list, function(a, b) return a.points < b.points end)
+ elseif colIndex == 4 then
+ t_sort(self.list, function(a, b) return a.delta > b.delta end)
+ elseif colIndex == 5 then
+ t_sort(self.list, function(a, b) return a.pct > b.pct end)
+ elseif colIndex == 6 then
+ t_sort(self.list, function(a, b) return a.sortPctPerPoint > b.sortPctPerPoint end)
+ elseif colIndex == 7 then
+ t_sort(self.list, function(a, b) return a.detailText < b.detailText end)
+ end
+ elseif self.mode == "find" or self.mode == "findThread" then
+ if colIndex == 1 then
+ t_sort(self.list, function(a, b) return a.socketLabel < b.socketLabel end)
+ elseif colIndex == 2 then
+ t_sort(self.list, function(a, b) return a.points < b.points end)
+ elseif colIndex == 3 then
+ t_sort(self.list, function(a, b) return a.score > b.score end)
+ elseif colIndex == 4 then
+ t_sort(self.list, function(a, b) return a.scorePerPointSort > b.scorePerPointSort end)
+ elseif colIndex == 5 then
+ if self.mode == "findThread" then
+ t_sort(self.list, function(a, b) return a.variantLabel < b.variantLabel end)
+ else
+ t_sort(self.list, function(a, b) return a.detailText < b.detailText end)
+ end
+ elseif colIndex == 6 and self.mode == "findThread" then
+ t_sort(self.list, function(a, b) return a.detailText < b.detailText end)
+ end
+ end
+end
+
+function RadiusJewelResultsListClass:GetRowValue(column, index, row)
+ if self.mode == "message" then
+ return column == 1 and row.text or ""
+ elseif self.mode == "computeSocket" then
+ return column == 1 and colorSocketLabel(row)
+ or column == 2 and tostring(row.points)
+ or column == 3 and formatSignedValue(row.delta)
+ or column == 4 and formatSignedPercent(row.pct)
+ or column == 5 and formatPerPointDisplay(row.pctPerPoint, row.points)
+ or column == 6 and row.detailText
+ or ""
+ elseif self.mode == "computeSocketAll" then
+ return column == 1 and row.jewelName
+ or column == 2 and colorSocketLabel(row)
+ or column == 3 and tostring(row.points)
+ or column == 4 and formatSignedValue(row.delta)
+ or column == 5 and formatSignedPercent(row.pct)
+ or column == 6 and formatPerPointDisplay(row.pctPerPoint, row.points)
+ or column == 7 and row.detailText
+ or ""
+ elseif self.mode == "find" then
+ return column == 1 and colorSocketLabel(row)
+ or column == 2 and tostring(row.points)
+ or column == 3 and s_format("^7%d", row.score)
+ or column == 4 and (row.points == 0 and (row.score > 0 and "^2Free" or "^8Free") or s_format("^7%.2f", row.scorePerPoint))
+ or column == 5 and row.detailText
+ or ""
+ elseif self.mode == "findThread" then
+ return column == 1 and colorSocketLabel(row)
+ or column == 2 and tostring(row.points)
+ or column == 3 and s_format("^7%d", row.score)
+ or column == 4 and (row.points == 0 and (row.score > 0 and "^2Free" or "^8Free") or s_format("^7%.2f", row.scorePerPoint))
+ or column == 5 and row.variantLabel
+ or column == 6 and row.detailText
+ or ""
+ end
+ return ""
+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 function clampRectPosition(x, y, width, height)
+ x = math.max(viewPort.x, math.min(x, viewPort.x + viewPort.width - width))
+ y = math.max(viewPort.y, math.min(y, viewPort.y + viewPort.height - height))
+ return x, y
+ end
+ local function rectsOverlap(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
+ local function placeResultTooltip(ttW, ttH, cursorX, cursorY, blockedRects)
+ 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 = blockedRects and blockedRects[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 = clampRectPosition(candidate.x, candidate.y, ttW, ttH)
+ local overlapsBlockedRect = false
+ for _, blockedRect in ipairs(blockedRects or { }) do
+ if rectsOverlap(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 clampRectPosition(cursorX + 20, cursorY + 20, ttW, ttH)
+ 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 blockedRects = { }
+ if viewerRect then
+ t_insert(blockedRects, 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 = placeResultTooltip(ttW, ttH, cursorX, cursorY, blockedRects)
+ self.resultTooltip:Draw(ttX, ttY, nil, nil, viewPort)
+ t_insert(blockedRects, { 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 = placeResultTooltip(itemTtW, itemTtH, cursorX, cursorY, blockedRects)
+ self.itemTooltip:Draw(itemTtX, itemTtY, nil, nil, viewPort)
+ SetDrawLayer(nil, 0)
+ end
+end
+
+local RadiusJewelFinderClass = newClass("RadiusJewelFinder", function(self, treeTab)
+ self.treeTab = treeTab
+ self.build = treeTab.build
+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 RadiusJewelFinderClass: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 RadiusJewelFinderClass:calculateImpactDelta(impactStat, baselineOutput, compareOutput)
+ impactStat = normalizeImpactStat(impactStat)
+ local selection = impactStat.selection or impactStat
+ return self.build.calcsTab:CalculatePowerStat(selection, compareOutput, baselineOutput)
+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 jewelPreviewFn = RadiusJewelData.jewelPreviewFn
+local scoreAllocPassives = RadiusJewelData.scoreAllocPassives
+local buildJewelTypes = RadiusJewelData.buildJewelTypes
+local jewelTypeSortOrder = RadiusJewelData.jewelTypeSortOrder
+local makeVariantDropdownEntry = RadiusJewelData.makeVariantDropdownEntry
+local findDisconnectedPassiveComputeMethod = RadiusJewelData.findDisconnectedPassiveComputeMethod
+local getSplitPersonalityVariants = RadiusJewelData.getSplitPersonalityVariants
+local getImpossibleEscapeVariants = RadiusJewelData.getImpossibleEscapeVariants
+local mustGetUniqueRawText = RadiusJewelData.mustGetUniqueRawText
+
+-- Exposed for testing; calls the data module helper.
+function RadiusJewelFinderClass:buildVariantsFromUniqueItem(uniqueName, baseName)
+ return RadiusJewelData.buildVariantsFromUniqueItem(uniqueName, baseName)
+end
+
+function RadiusJewelFinderClass:discoverFoulbornVariants(uniqueName, radiusIndexByLabel)
+ return RadiusJewelData.discoverFoulbornVariants(uniqueName, radiusIndexByLabel)
+end
+
+-- ─────────────────────────────────────────────────────────────────────────────
+-- 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 = "Unknown"
+ local minDist = m_huge
+ local socketNode = treeData.nodes[socketId]
+ if 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: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 itemLabel = item and (item.title or item.name or item.baseName) or "Unknown item"
+ 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
+ isPositionSensitive = item.clusterJewel
+ or item.jewelRadiusIndex ~= nil
+ or (item.jewelData and item.jewelData.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)
+ local equipped = { }
+ local 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]
+ if item and item.title == jewelType.name 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
+ for i, radius in ipairs(data.jewelRadius) do
+ if radius.label == "Small" and radius.inner == 0 then
+ smallRI = i
+ break
+ end
+ end
+ 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
+
+-- Attach compute methods and get the UI helper
+local buildDisplayedDisconnectedPassivePlans = LoadModule("Classes/RadiusJewelCompute")(RadiusJewelFinderClass, {
+ extractTooltipStats = extractTooltipStats,
+ normalizeImpactStat = normalizeImpactStat,
+ calculateImpactPercent = calculateImpactPercent,
+ mustGetUniqueRawText = mustGetUniqueRawText,
+})
+
+-- ─────────────────────────────────────────────────────────────────────────────
+-- Best-per-socket allocation
+-- ─────────────────────────────────────────────────────────────────────────────
+
+--- Filter rows to keep at most one result per socket while applying jewel limits
+--- and use socket-dependent jewels before socket-independent ones.
+---
+--- Each row is expected to carry:
+--- socketId (number) – jewel socket id
+--- sortPctPerPoint / scorePerPointSort (number) – sort key (higher = better)
+--- isSocketIndependent (boolean?) – true for jewels like IE
+--- 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.sortPctPerPoint or a.scorePerPointSort or 0) > (b.sortPctPerPoint or b.scorePerPointSort 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.isSocketIndependent 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 jewels (e.g. IE) to remaining sockets, fewer points first
+ local independentSorted = { }
+ for _, row in ipairs(sorted) do
+ if row.isSocketIndependent then
+ t_insert(independentSorted, row)
+ end
+ end
+ t_sort(independentSorted, function(a, b)
+ local aScore = a.sortPctPerPoint or a.scorePerPointSort or 0
+ local bScore = b.sortPctPerPoint or b.scorePerPointSort 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.sortPctPerPoint or a.scorePerPointSort or 0) > (b.sortPctPerPoint or b.scorePerPointSort or 0)
+ end)
+ return filtered
+end
+
+-- ─────────────────────────────────────────────────────────────────────────────
+-- Open popup
+-- ─────────────────────────────────────────────────────────────────────────────
+
+function RadiusJewelFinderClass:Open()
+ local treeData = self.build.spec.tree
+
+ -- Radius index map
+ local radiusIndexByLabel = { }
+ for i, r in ipairs(data.jewelRadius) do
+ if r.inner == 0 and not radiusIndexByLabel[r.label] then
+ radiusIndexByLabel[r.label] = i
+ end
+ end
+
+ -- Thread of Hope ring variants (inner radius > 0)
+ local threadVariants = { }
+ local threadRawText = mustGetUniqueRawText("Thread of Hope")
+ local threadItem = new("Item", "Rarity: Unique\n" .. threadRawText)
+ local tIdx = 1
+ for i, r in ipairs(data.jewelRadius) do
+ if r.inner > 0 then
+ local ringName = threadItem.variantList and threadItem.variantList[tIdx]
+ if ringName then
+ ringName = ringName:gsub(" Ring$", "")
+ else
+ ringName = "Ring " .. tIdx
+ end
+ t_insert(threadVariants, { name = ringName, radiusIndex = i })
+ tIdx = tIdx + 1
+ end
+ end
+
+ local LARGE_IDX = radiusIndexByLabel["Large"]
+ local jewelTypes
+ local jewelSockets = self:buildJewelSockets(LARGE_IDX)
+
+ -- Mutable state
+ local showLegacy = false
+ local activeJewelTypes = { } -- filtered view of jewelTypes
+ local selectedJewelType = nil -- set after first filter build
+ local selectedThreadVariant = threadVariants[1]
+ local selectedJewelVariant = nil -- set when jewel type has built-in variants
+ local selectedComputeMethod = DISCONNECTED_PASSIVE_COMPUTE_METHODS[1]
+ local selectedMaxPoints = 20
+ local selectedOccupiedMode = OCCUPIED_SOCKET_OPTIONS[1]
+ local dreamFamilyOptions = {
+ { name = "All", value = "ALL" },
+ { name = "Red Dream", value = "The Red Dream" },
+ { name = "Red Nightmare", value = "The Red Nightmare" },
+ { name = "Green Dream", value = "The Green Dream" },
+ { name = "Green Nightmare", value = "The Green Nightmare" },
+ { name = "Blue Dream", value = "The Blue Dream" },
+ { name = "Blue Nightmare", value = "The Blue Nightmare" },
+ }
+ local selectedDreamFamily = dreamFamilyOptions[1]
+
+ local TL = { "TOPLEFT", nil, "TOPLEFT" }
+ local BL = { "BOTTOMLEFT", nil, "BOTTOMLEFT" }
+ local BR = { "BOTTOMRIGHT", nil, "BOTTOMRIGHT" }
+ local edgePadding = 10
+ local buttonHeight = 20
+ local leftPanelWidth = 580
+ local rightPanelWidth = 410
+ local popupWidth = edgePadding * 3 + leftPanelWidth + rightPanelWidth
+ local popupHeight = 474
+ local rightPanelX = edgePadding * 2 + leftPanelWidth
+ local bottomButtonY = -edgePadding
+ local bottomInputY = -(edgePadding + 2)
+ local bottomLabelY = -(edgePadding + 4)
+ local controls = { }
+ local applySelectedResult -- set below; used by OnSelClick + applyButton
+
+ -- ── Dropdown label lists ──────────────────────────────────────────────────
+ -- (jtLabels is built dynamically via rebuildJewelTypeDropdown)
+ local jtLabels = { }
+
+ local tvLabels = { }
+ for _, tv in ipairs(threadVariants) do t_insert(tvLabels, tv.name .. " Ring") end
+
+ local socketViewer = new("PassiveTreeView")
+
+ local impactStatLabels = { }
+ for _, s in ipairs(IMPACT_STATS) do t_insert(impactStatLabels, s.label) end
+ local occupiedModeLabels = { }
+ for _, option in ipairs(OCCUPIED_SOCKET_OPTIONS) do t_insert(occupiedModeLabels, option.label) end
+ local selectedImpactStat = IMPACT_STATS[1]
+ local finderState = self.build.radiusJewelFinderState or { }
+ self.build.radiusJewelFinderState = finderState
+ finderState.findCache = finderState.findCache or { }
+ finderState.computeCache = finderState.computeCache or { }
+ finderState.resultViewByKey = finderState.resultViewByKey or { }
+ finderState.disconnectedPassivePlanCache = finderState.disconnectedPassivePlanCache or { }
+ local ALL_JEWELS_VIEW_OPTIONS = {
+ { id = "all", label = "All results" },
+ { id = "bestPerSocket", label = "Best per socket" },
+ }
+ local allJewelsViewLabels = { }
+ for _, v in ipairs(ALL_JEWELS_VIEW_OPTIONS) do t_insert(allJewelsViewLabels, v.label) end
+ local selectedAllJewelsView = ALL_JEWELS_VIEW_OPTIONS[1]
+ local lastComputeAllRows = nil
+
+ local function filterBestPerSocket(rows)
+ return self:filterBestPerSocket(rows)
+ end
+
+ local suppressFinderStateSave = false
+ local runFind
+ local computeContext
+ local cancelCompute
+ local searchStartTime
+
+ 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.dreamFamilyValue = selectedDreamFamily and selectedDreamFamily.value or 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 getSelectionKey()
+ local supportsComputeMethods = selectedJewelType and selectedJewelType.computeMethods and #selectedJewelType.computeMethods > 0
+ local computeMethodKey = supportsComputeMethods and selectedComputeMethod and selectedComputeMethod.id or ""
+ return table.concat({
+ tostring(showLegacy and 1 or 0),
+ selectedJewelType and selectedJewelType.name or "",
+ selectedJewelVariant and (selectedJewelVariant.dropdownLabel or selectedJewelVariant.name) or "",
+ selectedThreadVariant and selectedThreadVariant.name or "",
+ selectedDreamFamily and selectedDreamFamily.value or "",
+ selectedImpactStat and selectedImpactStat.field or "",
+ computeMethodKey,
+ selectedMaxPoints and tostring(selectedMaxPoints) or "",
+ selectedOccupiedMode and selectedOccupiedMode.id or "",
+ }, "|")
+ end
+
+ local function restoreCachedResults()
+ local key = getSelectionKey()
+ local preferredView = finderState.resultViewByKey[key]
+ local allowFindCache = not (selectedJewelType and selectedJewelType.isAllJewels)
+ local findCache = allowFindCache and finderState.findCache[key] or nil
+ local computeCache = finderState.computeCache[key]
+ local cache = preferredView == "compute" and computeCache or findCache
+ if not cache and preferredView == "compute" then
+ cache = findCache
+ elseif not cache and preferredView == "find" then
+ cache = computeCache
+ end
+ if not cache then
+ cache = findCache or computeCache
+ end
+ if not cache then
+ return false
+ end
+ local rows = copyTableSafe(cache.rows, false, true)
+ if cache.mode == "computeSocketAll" then
+ lastComputeAllRows = rows
+ if selectedAllJewelsView.id == "bestPerSocket" then
+ rows = filterBestPerSocket(rows)
+ end
+ end
+ controls.resultsList:SetMode(cache.mode, rows, cache.defaultText)
+ controls.statusLabel.label = cache.statusLabel or controls.statusLabel.label
+ return true
+ end
+ local function saveResultCache(viewName, mode, rows, defaultText, statusLabel, makePreferred)
+ local key = getSelectionKey()
+ local targetCache = viewName == "compute" and finderState.computeCache or finderState.findCache
+ targetCache[key] = {
+ mode = mode,
+ rows = copyTableSafe(rows, false, true),
+ defaultText = defaultText,
+ statusLabel = statusLabel,
+ }
+ if makePreferred then
+ finderState.resultViewByKey[key] = viewName
+ end
+ 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", {
+ { text = message },
+ }, message)
+ end
+ cancelCompute = function(statusMessage)
+ if not computeContext then
+ return
+ end
+ if computeContext.removedJewels and #computeContext.removedJewels > 0 then
+ self:restoreEquippedJewels(computeContext.removedJewels)
+ end
+ main.onFrameFuncs["RadiusJewelFinderCompute"] = nil
+ computeContext = nil
+ if controls.computeButton then
+ controls.computeButton.label = "Compute"
+ end
+ if statusMessage then
+ controls.statusLabel.label = statusMessage
+ end
+ end
+ local function getSelectedComputeMethods()
+ if selectedJewelType and selectedJewelType.isAllJewels then
+ return DISCONNECTED_PASSIVE_COMPUTE_METHODS
+ end
+ if selectedJewelType and selectedJewelType.computeMethods and #selectedJewelType.computeMethods > 0 then
+ return selectedJewelType.computeMethods
+ end
+ end
+ local function selectedJewelSupportsComputeMethods()
+ local methods = getSelectedComputeMethods()
+ return methods and #methods > 0
+ end
+ local function hasVariantFamilies()
+ if not selectedJewelType or not selectedJewelType.variants then return false end
+ for _, v in ipairs(selectedJewelType.variants) do
+ if v.family then return true end
+ end
+ return false
+ end
+
+ local function getDisplayedVariants()
+ if not selectedJewelType or not selectedJewelType.variants then
+ return nil
+ end
+ if hasVariantFamilies() and selectedDreamFamily and selectedDreamFamily.value ~= "ALL" then
+ local variants = { }
+ for _, variant in ipairs(selectedJewelType.variants) do
+ if variant.family == selectedDreamFamily.value then
+ t_insert(variants, variant)
+ end
+ end
+ return variants
+ end
+ return selectedJewelType.variants
+end
+
+local function buildPreviewLinesForJewelType(jewelType, previewVariantOverride)
+ if not jewelType then
+ return nil
+ end
+ local fn = jewelPreviewFn[jewelType.name]
+ if not fn then
+ return nil
+ end
+ local selectedTypeMatches = selectedJewelType and selectedJewelType.name == jewelType.name
+ if jewelType.isThread then
+ local threadVariant = previewVariantOverride or selectedThreadVariant
+ return fn(threadVariant and threadVariant.name)
+ elseif jewelType.variants then
+ local previewVariant = previewVariantOverride or ((selectedTypeMatches and selectedJewelVariant) or jewelType.variants[1])
+ return fn(previewVariant)
+ end
+ return fn()
+end
+
+local function 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
+
+local function buildGenericTypeTooltipLinesForJewelType(jewelType)
+ if not jewelType then
+ return nil
+ end
+ if not (jewelType.isThread or jewelType.variants) then
+ local lines = buildPreviewLinesForJewelType(jewelType)
+ if type(lines) ~= "table" then
+ return nil
+ end
+ return lines
+ end
+ local fn = jewelPreviewFn[jewelType.name]
+ local lines = fn and fn() or nil
+ if type(lines) ~= "table" then
+ return nil
+ 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
+ local note
+ if jewelType.isThread then
+ note = "Multiple ring sizes available"
+ else
+ note = "Multiple variants available"
+ end
+ t_insert(genericLines, { height = 16, [1] = COL_META .. note })
+ return genericLines
+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.variantFamilySelect and controls.variantFamilySelect.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 = { }
+ for _, v in ipairs(variants) do
+ t_insert(variantNames, makeVariantDropdownEntry(v))
+ end
+ controls.jewelVariantSelect:SetList(variantNames)
+ local varIdx = 1
+ if selectedJewelVariant then
+ for i, variant in ipairs(variants) do
+ if variant == selectedJewelVariant then
+ varIdx = i
+ break
+ end
+ end
+ else
+ varIdx = controls.jewelVariantSelect.selIndex or 1
+ end
+ if varIdx > #variants then
+ varIdx = 1
+ end
+ controls.jewelVariantSelect.selIndex = varIdx
+ selectedJewelVariant = variants[varIdx]
+ saveFinderState()
+ end
+
+ -- ── Preview list (right panel) ────────────────────────────────────────────
+ local previewListData = { }
+ local resultDetailListData = { }
+ local previewListY = 70
+ local previewListHeight = 180
+ local compactPreviewListHeight = 48
+ local resultDetailBottomY = 430
+ local resultDetailGap = 6
+ local resultDetailLabelGap = 18
+ local function getSelectedAllJewelPreviewLines()
+ local mode = controls.resultsList and controls.resultsList.mode
+ if mode ~= "computeSocketAll" then
+ return nil
+ end
+ local row = controls.resultsList.selValue
+ return row and row.itemTooltipLines or nil
+ end
+ local function isCompactPreview()
+ return selectedJewelType and selectedJewelType.isAllJewels and not getSelectedAllJewelPreviewLines()
+ end
+ local function getPreviewListHeight()
+ return isCompactPreview() and compactPreviewListHeight or previewListHeight
+ end
+ local function getResultDetailLabelY()
+ return previewListY + getPreviewListHeight() + resultDetailGap
+ end
+ local function getResultDetailListY()
+ return getResultDetailLabelY() + resultDetailLabelGap
+ end
+ local function updateResultDetails(row)
+ wipeTable(resultDetailListData)
+ if not row then
+ t_insert(resultDetailListData, { height = 16, [1] = COL_META .. "Select a result to view details." })
+ return
+ end
+ t_insert(resultDetailListData, { height = 16, [1] = "^7Socket: " .. (row.socketLabel or "(n/a)") })
+ if row.variantLabel and row.variantLabel ~= "" then
+ t_insert(resultDetailListData, { height = 16, [1] = "^7Variant: " .. row.variantLabel })
+ end
+ if row.action == "keep" then
+ t_insert(resultDetailListData, { height = 16, [1] = "^8Already equipped" })
+ elseif row.action == "moveReplace" then
+ t_insert(resultDetailListData, { height = 16, [1] = "^xBB88FFMove equipped jewel" })
+ t_insert(resultDetailListData, { height = 16, [1] = "^xFFAA33Will replace: ^7" .. (row.replacedItemLabel or "?") })
+ elseif row.action == "move" then
+ t_insert(resultDetailListData, { height = 16, [1] = "^x33AAFFMove equipped jewel" })
+ elseif row.replacedItemLabel then
+ t_insert(resultDetailListData, { height = 16, [1] = "^xFFAA33Use occupied socket" })
+ t_insert(resultDetailListData, { height = 16, [1] = "^xFFAA33Will replace: ^7" .. row.replacedItemLabel })
+ elseif row.storedUnallocatedItemLabel then
+ t_insert(resultDetailListData, { height = 16, [1] = "^2Use unallocated socket" })
+ t_insert(resultDetailListData, { height = 16, [1] = "^8Stored jewel ignored until this socket is allocated." })
+ t_insert(resultDetailListData, { height = 16, [1] = "^xFFAA33Apply will replace the stored jewel: ^7" .. row.storedUnallocatedItemLabel })
+ else
+ t_insert(resultDetailListData, { height = 16, [1] = "^2Use free socket" })
+ end
+ if row.detailText and row.detailText ~= "" then
+ t_insert(resultDetailListData, { height = 16, [1] = "^7" .. row.detailText })
+ end
+ local nodeEntries = row.resultNodes or row.topNodes
+ if nodeEntries and #nodeEntries > 0 then
+ t_insert(resultDetailListData, { height = 6, [1] = "" })
+ t_insert(resultDetailListData, {
+ height = 16,
+ [1] = row.resultNodes and s_format("^7Passives to allocate (%d):", #nodeEntries)
+ or s_format("^7Passives in range (%d):", #nodeEntries),
+ })
+ for _, nodeEntry in ipairs(nodeEntries) do
+ t_insert(resultDetailListData, {
+ height = 16,
+ [1] = "^xC8C8C8- " .. (nodeEntry.label or tostring(nodeEntry)),
+ nodeId = nodeEntry.nodeId,
+ })
+ end
+ else
+ t_insert(resultDetailListData, { height = 6, [1] = "" })
+ t_insert(resultDetailListData, { height = 16, [1] = row.resultNodes and (COL_META .. "No passives to allocate") or (COL_META .. "No passives in range") })
+ end
+ end
+ controls.previewList = new("TextListControl", TL, { rightPanelX, previewListY, rightPanelWidth, previewListHeight },
+ { { x = 0, align = "LEFT" }, { x = 210, align = "LEFT" } }, previewListData)
+ controls.previewList.height = getPreviewListHeight
+ controls.previewList.shown = function()
+ return not (controls.jewelTypeSelect and controls.jewelTypeSelect.dropped)
+ end
+ controls.resultDetailLabel = new("LabelControl", TL, { rightPanelX, 256, 0, 16 }, "^7Details:")
+ controls.resultDetailLabel.y = getResultDetailLabelY
+ controls.resultDetailList = new("RadiusJewelDetailListControl", TL, { rightPanelX, 274, rightPanelWidth, 156 },
+ { { x = 0, align = "LEFT" } }, resultDetailListData, self.build, socketViewer)
+ controls.resultDetailList.y = getResultDetailListY
+ controls.resultDetailList.height = function()
+ return resultDetailBottomY - getResultDetailListY()
+ end
+ updateResultDetails(nil)
+
+ local function addPreviewLines(lines)
+ if type(lines) ~= "table" then
+ return false
+ end
+ for _, line in ipairs(lines) do
+ t_insert(previewListData, line)
+ end
+ return #lines > 0
+ end
+
+ local function updatePreview(row)
+ wipeTable(previewListData)
+ if not selectedJewelType then
+ t_insert(previewListData, { height = 16, [1] = COL_META .. "(no preview)" })
+ return
+ end
+ if selectedJewelType.isAllJewels then
+ local mode = controls.resultsList and controls.resultsList.mode
+ if mode == "computeSocketAll" then
+ local previewRow = row or controls.resultsList.selValue
+ if previewRow and addPreviewLines(previewRow.itemTooltipLines) then
+ return
+ end
+ end
+ t_insert(previewListData, { height = 16, [1] = "^7Evaluate every jewel type." })
+ if selectedAllJewelsView.id == "bestPerSocket" then
+ t_insert(previewListData, { height = 16, [1] = "^7Best jewel per socket." })
+ else
+ t_insert(previewListData, { height = 16, [1] = "^7Sorted globally by %/Pt." })
+ end
+ return
+ end
+ local lines = buildPreviewLinesForJewelType(selectedJewelType)
+ if type(lines) ~= "table" then
+ t_insert(previewListData, { height = 16, [1] = COL_META .. "(no preview)" })
+ return
+ end
+ addPreviewLines(lines)
+ end
+
+ -- ── Results list (left panel) ─────────────────────────────────────────────
+ controls.resultsList = new("RadiusJewelResultsListControl", TL, { edgePadding, 70, leftPanelWidth, 360 }, self.build, socketViewer)
+ controls.resultsList.suppressTooltipFunc = isAnyFinderDropdownDropped
+ controls.resultsList.OnSelect = function(_, _, row)
+ updateResultDetails(row)
+ updatePreview(row)
+ end
+ controls.resultsList.OnSelClick = function(_, index, value, doubleClick)
+ if doubleClick then
+ applySelectedResult()
+ end
+ end
+ controls.resultsList:SetMode("message", { }, COL_META .. "Click Find to search")
+
+ -- ── Helper: rebuild jewel type dropdown after filter change ──────────────
+ local function rebuildJewelTypeDropdown()
+ jewelTypes = buildJewelTypes(radiusIndexByLabel)
+ 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",
+ isAllJewels = true,
+ hasCompute = true,
+ })
+ for _, jt in ipairs(activeJewelTypes) do
+ t_insert(jtLabels, jt.name)
+ end
+ if controls.jewelTypeSelect then
+ controls.jewelTypeSelect:SetList(jtLabels)
+ -- keep current selection if still visible, else reset to first
+ 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
+ -- initial build before controls exist
+ selectedJewelType = activeJewelTypes[1]
+ end
+ end
+ rebuildJewelTypeDropdown() -- initial build (controls.jewelTypeSelect not yet created)
+
+ -- ── Header controls ───────────────────────────────────────────────────────
+ controls.jewelTypeLabel = new("LabelControl", TL, { edgePadding, 10, 0, 16 }, "^7Type:")
+
+ controls.computeMethodLabel = new("LabelControl", TL, { rightPanelX, 10, 0, 16 }, "^7Method:")
+ controls.computeMethodSelect = new("DropDownControl", TL, { rightPanelX, 26, 160, buttonHeight }, { }, function(idx)
+ cancelCompute()
+ local methods = getSelectedComputeMethods()
+ if methods then
+ selectedComputeMethod = methods[idx]
+ end
+ saveFinderState()
+ end)
+ local function addComputeMethodTooltip(tooltip, mode, index)
+ local methods = getSelectedComputeMethods()
+ local method = (index and methods and methods[index]) or selectedComputeMethod
+ tooltip:Clear(true)
+ if selectedJewelType and selectedJewelType.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 (shown when jewel has compute)
+ controls.impactStatLabel = new("LabelControl", TL, { rightPanelX + 180, 10, 0, 16 }, "^7Stat:")
+ controls.impactStatSelect = new("DropDownControl", TL, { rightPanelX + 180, 26, 140, buttonHeight }, impactStatLabels, function(idx)
+ cancelCompute()
+ selectedImpactStat = IMPACT_STATS[idx]
+ saveFinderState()
+ end)
+ controls.impactStatLabel.shown = true
+ controls.impactStatSelect.shown = true
+
+ controls.maxPointsLabel = new("LabelControl", BL, { edgePadding + 110, bottomLabelY, 0, 16 }, "^7Max pts:")
+ controls.maxPointsEdit = new("EditControl", BL, { edgePadding + 172, bottomInputY, 56, buttonHeight }, tostring(selectedMaxPoints), nil, "%D", 3, function(buf)
+ cancelCompute()
+ selectedMaxPoints = buf ~= "" and tonumber(buf) or nil
+ saveFinderState()
+ end)
+ local function addMaxPointsTooltip(tooltip)
+ tooltip:Clear(true)
+ tooltip:AddLine(16, "^7Maximum total passive points for a result.")
+ tooltip:AddLine(16, "^8Includes pathing to the socket and passives to allocate.")
+ end
+ controls.maxPointsLabel.tooltipFunc = addMaxPointsTooltip
+ controls.maxPointsEdit.tooltipFunc = addMaxPointsTooltip
+ controls.maxPointsLabel.shown = true
+ controls.maxPointsEdit.shown = true
+
+ controls.occupiedModeLabel = new("LabelControl", BL, { edgePadding + 240, bottomLabelY, 0, 16 }, "^7Sockets:")
+ controls.occupiedModeSelect = new("DropDownControl", BL, { edgePadding + 298, bottomInputY, 170, buttonHeight }, occupiedModeLabels, function(idx)
+ cancelCompute()
+ selectedOccupiedMode = OCCUPIED_SOCKET_OPTIONS[idx]
+ saveFinderState()
+ runFind(false)
+ 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", TL, { 278, 10, 0, 16 }, "^7View:")
+ controls.allJewelsViewSelect = new("DropDownControl", TL, { 278, 26, 160, 20 }, allJewelsViewLabels, function(idx)
+ selectedAllJewelsView = ALL_JEWELS_VIEW_OPTIONS[idx]
+ if lastComputeAllRows then
+ local displayRows = selectedAllJewelsView.id == "bestPerSocket"
+ and filterBestPerSocket(lastComputeAllRows) or 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", TL, { 278, 10, 0, 16 }, "^7Preview ring:")
+ controls.threadVariantSelect = new("DropDownControl", TL, { 278, 26, 200, 20 }, tvLabels, function(idx)
+ cancelCompute()
+ selectedThreadVariant = threadVariants[idx]
+ saveFinderState()
+ updatePreview()
+ runFind(false)
+ end)
+ controls.threadVariantLabel.shown = false
+ controls.threadVariantSelect.shown = false
+
+ controls.variantFamilyLabel = new("LabelControl", TL, { 550, 10, 0, 16 }, "^7Family:")
+ controls.variantFamilySelect = new("DropDownControl", TL, { 550, 26, 220, 20 }, {
+ "All",
+ "Red Dream",
+ "Red Nightmare",
+ "Green Dream",
+ "Green Nightmare",
+ "Blue Dream",
+ "Blue Nightmare",
+ }, function(idx)
+ cancelCompute()
+ selectedDreamFamily = dreamFamilyOptions[idx]
+ controls.jewelVariantSelect.selIndex = 1
+ selectedJewelVariant = nil
+ syncDisplayedVariants()
+ saveFinderState()
+ updatePreview()
+ runFind(false)
+ end)
+ controls.variantFamilyLabel.shown = false
+ controls.variantFamilySelect.shown = false
+
+ -- Jewel variant selector (shown when jewel type has built-in variants)
+ controls.jewelVariantLabel = new("LabelControl", TL, { 278, 10, 0, 16 }, "^7Variant:")
+ controls.jewelVariantSelect = new("DropDownControl", TL, { 278, 26, 260, 20 }, {}, function(idx)
+ cancelCompute()
+ local variants = getDisplayedVariants()
+ if variants then
+ selectedJewelVariant = variants[idx]
+ saveFinderState()
+ updatePreview()
+ end
+ end)
+ controls.jewelVariantSelect.enableDroppedWidth = true
+ controls.jewelVariantSelect.maxDroppedWidth = 520
+ controls.jewelVariantLabel.shown = false
+ controls.jewelVariantSelect.shown = false
+
+ 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()
+ if selectedJewelType.isAllJewels then
+ controls.allJewelsViewLabel.shown = true
+ controls.allJewelsViewSelect.shown = true
+ controls.threadVariantLabel.shown = false
+ controls.threadVariantSelect.shown = false
+ controls.variantFamilyLabel.shown = false
+ controls.variantFamilySelect.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(DISCONNECTED_PASSIVE_COMPUTE_METHODS)
+ 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 isThread = selectedJewelType.isThread == true
+ local hasVariants = selectedJewelType.variants ~= nil
+ local hasVariantFamilyFilter = hasVariantFamilies()
+ local hasComputeMethods = selectedJewelSupportsComputeMethods()
+
+ controls.threadVariantLabel.shown = isThread
+ controls.threadVariantSelect.shown = isThread
+ controls.variantFamilyLabel.shown = hasVariantFamilyFilter
+ controls.variantFamilySelect.shown = hasVariantFamilyFilter
+ controls.jewelVariantLabel.shown = hasVariants
+ controls.jewelVariantSelect.shown = hasVariants
+ controls.computeMethodLabel.shown = hasComputeMethods
+ controls.computeMethodSelect.shown = hasComputeMethods
+ controls.impactStatLabel.shown = selectedJewelType.hasCompute
+ controls.impactStatSelect.shown = selectedJewelType.hasCompute
+ if controls.findButton then
+ controls.findButton.shown = true
+ end
+ if controls.computeButton then
+ controls.computeButton.shown = selectedJewelType.hasCompute
+ end
+
+ if hasVariants then
+ if not hasVariantFamilyFilter then
+ selectedDreamFamily = dreamFamilyOptions[1]
+ controls.variantFamilySelect.selIndex = 1
+ end
+ syncDisplayedVariants()
+ else
+ selectedJewelVariant = nil
+ end
+ if hasComputeMethods then
+ syncComputeMethodSelect(selectedJewelType.computeMethods)
+ end
+ end
+
+ -- Jewel type dropdown (defined after variant controls so :Click() is safe)
+ controls.jewelTypeSelect = new("DropDownControl", TL, { 10, 26, 260, 20 }, jtLabels, function(idx)
+ cancelCompute()
+ selectedJewelType = activeJewelTypes[idx]
+ controls.jewelVariantSelect.selIndex = 1
+ syncSelectedJewelTypeControls()
+ saveFinderState()
+ updatePreview()
+ runFind(false)
+ end)
+ controls.jewelTypeSelect.tooltipFunc = function(tooltip, mode, index)
+ local jewelType = activeJewelTypes[index]
+ if jewelType and jewelType.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()
+ local variant = variants and variants[index]
+ if not selectedJewelType or not variant then
+ return
+ end
+ addPreviewLinesToTooltip(tooltip, buildPreviewLinesForJewelType(selectedJewelType, variant))
+ end
+ controls.threadVariantSelect.tooltipFunc = function(tooltip, mode, index)
+ local variant = threadVariants[index]
+ if not selectedJewelType or not variant then
+ return
+ end
+ addPreviewLinesToTooltip(tooltip, buildPreviewLinesForJewelType(selectedJewelType, variant))
+ end
+ syncSelectedJewelTypeControls()
+
+ -- Compute button: socket sorting with best variant per socket
+ -- Results go into the left panel (resultListData); jewel preview is unchanged.
+ 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 equippedSocketIds = { }
+ local existingSocketId
+ for _, entry in ipairs(equippedList or { }) do
+ equippedSocketIds[entry.socketId] = true
+ if equippedList.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 _, r in ipairs(socketResults) do
+ if equippedSocketIds[r.socket.id] then
+ keepDelta = r.delta or 0
+ break
+ end
+ end
+ end
+ local rows = { }
+ for _, r in ipairs(socketResults) do
+ 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 (r.variant.dropdownLabel or r.variant.name) or ""
+ local itemTooltipLines = buildPreviewLinesForJewelType(jewelType, r.variant)
+ local applyRawText = r.variant and r.variant.rawText or jewelType.rawText
+ local jewelLimitKey = applyRawText and applyRawText:match("^([^\n]+)") or jewelType.name
+ local jewelLimit = jewelType.limit or (applyRawText and tonumber(applyRawText:match("Limited to: (%d+)"))) or nil
+ local displayedPlans = (jewelType.name == "Intuitive Leap" or jewelType.isThread or jewelType.isImpossibleEscape)
+ 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 = nil
+ if jewelType.isImpossibleEscape and r.variant and r.variant.keystoneName then
+ local keystoneNode = treeData.keystoneMap[r.variant.keystoneName]
+ detailNodeId = keystoneNode and keystoneNode.id or nil
+ end
+ local action
+ if isEquippedSocket then
+ action = "keep"
+ elseif existingSocketId and r.replacedItemLabel then
+ action = "moveReplace"
+ elseif existingSocketId then
+ action = "move"
+ elseif r.replacedItemLabel then
+ action = "replace"
+ else
+ action = "new"
+ end
+ 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,
+ sortPctPerPoint = totalPoints > 0 and (pct / totalPoints) or pct,
+ 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,
+ isSocketIndependent = jewelType.isSocketIndependent,
+ applyRawText = applyRawText,
+ action = action,
+ tooltipHeader = jewelType.isThread and "^7Socketing this jewel and allocating the best ring plan here will give you:"
+ or jewelType.name == "Intuitive Leap" and "^7Socketing this jewel and allocating the best nodes here will give you:"
+ or jewelType.isImpossibleEscape and "^7Socketing this jewel and allocating the best keystone plan here will give you:"
+ 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", TL, { popupWidth - edgePadding * 2 - 72, 26, 72, buttonHeight }, "Compute", function()
+ if computeContext then
+ cancelCompute("^8Compute stopped")
+ restoreCachedResults()
+ return
+ end
+
+ controls.computeButton.label = "Cancel"
+ searchStartTime = GetTime()
+ setComputeProgress("^7Computing...")
+ local progress = makeComputeProgressTracker()
+ computeContext = {
+ co = coroutine.create(function()
+ local ok, err = pcall(function()
+ local statLabel = selectedImpactStat.label
+ local computeMethod = selectedComputeMethod or findDisconnectedPassiveComputeMethod(nil)
+ local computeMethodLabel = selectedJewelSupportsComputeMethods() and computeMethod.label or nil
+
+ if selectedJewelType.isAllJewels then
+ local allRows = { }
+ local globalBaseline
+
+ local computeJewelTypes = { }
+ for _, jt in ipairs(activeJewelTypes) do
+ if not jt.isAllJewels and jt.hasCompute 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 equippedList = self:findEquippedJewelSockets(jt)
+ local removedJewels = equippedList.atLimit and self:removeEquippedJewels(equippedList) or { }
+ computeContext.removedJewels = removedJewels
+ local socketResults, baseline
+
+ if jt.name == "Intuitive Leap" then
+ socketResults, baseline =
+ self:computeIntuitiveLeapSocketImpact(jewelSockets, selectedImpactStat, nil,
+ computeMethod.id, finderState.disconnectedPassivePlanCache, typeProgress, selectedMaxPoints, selectedOccupiedMode, true)
+ elseif jt.isThread then
+ socketResults, baseline =
+ self:computeThreadOfHopeSocketImpact(jewelSockets, selectedImpactStat, threadVariants,
+ computeMethod.id, finderState.disconnectedPassivePlanCache, typeProgress, selectedMaxPoints, selectedOccupiedMode, true)
+ elseif jt.isImpossibleEscape then
+ socketResults, baseline =
+ self:computeImpossibleEscapeSocketImpact(jewelSockets, selectedImpactStat,
+ jt.variants or getImpossibleEscapeVariants(),
+ computeMethod.id, finderState.disconnectedPassivePlanCache, typeProgress, selectedMaxPoints, selectedOccupiedMode, true)
+ elseif jt.isSplitPersonality then
+ socketResults, baseline =
+ self:computeSplitPersonalitySocketImpact(jewelSockets, selectedImpactStat,
+ jt.variants or getSplitPersonalityVariants(),
+ typeProgress, selectedMaxPoints, selectedOccupiedMode)
+ elseif jt.variants and #jt.variants > 0 then
+ socketResults, baseline =
+ self:computeBestVariantSocketImpact(jewelSockets, jt.variants, selectedImpactStat,
+ typeProgress, selectedMaxPoints, selectedOccupiedMode)
+ else
+ socketResults, baseline =
+ self:computeSocketImpact(jewelSockets, jt.rawText, selectedImpactStat,
+ typeProgress, selectedMaxPoints, selectedOccupiedMode)
+ end
+
+ globalBaseline = globalBaseline or baseline
+ self:restoreEquippedJewels(removedJewels)
+ computeContext.removedJewels = nil
+
+ local typeRows = buildComputeRows(jt, socketResults, baseline, equippedList)
+
+ -- For disconnected-passive types: keep only the best row per socket
+ if jt.name == "Intuitive Leap" or jt.isThread or jt.isImpossibleEscape then
+ local bestBySocket = { }
+ for _, row in ipairs(typeRows) do
+ local ex = bestBySocket[row.socketId]
+ if not ex or row.sortPctPerPoint > ex.sortPctPerPoint 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
+ lastComputeAllRows = allRows
+ local displayRows = selectedAllJewelsView.id == "bestPerSocket"
+ and 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)
+ saveResultCache("compute", "computeSocketAll", allRows, COL_META .. "(no compatible sockets)", controls.statusLabel.label, true)
+ else
+ local displayedVariants = getDisplayedVariants()
+ local itemLabel = selectedJewelType.name
+ local equippedList = self:findEquippedJewelSockets(selectedJewelType)
+ local removedJewels = equippedList.atLimit and self:removeEquippedJewels(equippedList) or { }
+ computeContext.removedJewels = removedJewels
+ local socketResults, baseline
+ if selectedJewelType.name == "Intuitive Leap" then
+ socketResults, baseline =
+ self:computeIntuitiveLeapSocketImpact(jewelSockets, selectedImpactStat, selectedJewelVariant, computeMethod.id, finderState.disconnectedPassivePlanCache, progress, selectedMaxPoints, selectedOccupiedMode)
+ elseif selectedJewelType.isThread then
+ socketResults, baseline =
+ self:computeThreadOfHopeSocketImpact(jewelSockets, selectedImpactStat, threadVariants, computeMethod.id, finderState.disconnectedPassivePlanCache, progress, selectedMaxPoints, selectedOccupiedMode)
+ elseif selectedJewelType.isImpossibleEscape then
+ socketResults, baseline =
+ self:computeImpossibleEscapeSocketImpact(jewelSockets, selectedImpactStat, displayedVariants or getImpossibleEscapeVariants(), computeMethod.id, finderState.disconnectedPassivePlanCache, progress, selectedMaxPoints, selectedOccupiedMode)
+ elseif selectedJewelType.isSplitPersonality then
+ socketResults, baseline =
+ self:computeSplitPersonalitySocketImpact(jewelSockets, selectedImpactStat, displayedVariants or getSplitPersonalityVariants(), progress, selectedMaxPoints, selectedOccupiedMode)
+ elseif displayedVariants and #displayedVariants > 0 then
+ if hasVariantFamilies() and selectedDreamFamily and selectedDreamFamily.value ~= "ALL" then
+ itemLabel = selectedDreamFamily.name
+ end
+ socketResults, baseline =
+ self:computeBestVariantSocketImpact(jewelSockets, displayedVariants, selectedImpactStat, progress, selectedMaxPoints, selectedOccupiedMode)
+ else
+ local rawText = selectedJewelType.rawText
+ socketResults, baseline =
+ self:computeSocketImpact(jewelSockets, rawText, selectedImpactStat, progress, selectedMaxPoints, selectedOccupiedMode)
+ end
+ self:restoreEquippedJewels(removedJewels)
+ computeContext.removedJewels = nil
+ local rows = buildComputeRows(selectedJewelType, socketResults, baseline, equippedList)
+ controls.resultsList:SetMode("computeSocket", rows, COL_META .. "(no compatible sockets)")
+ controls.statusLabel.label = formatComputeStatus(itemLabel, statLabel, baseline, computeMethodLabel) .. formatElapsed(searchStartTime)
+ saveResultCache("compute", "computeSocket", rows, COL_META .. "(no compatible sockets)", controls.statusLabel.label, true)
+ end
+ end)
+ if not ok then
+ error(err)
+ end
+ end),
+ }
+ main.onFrameFuncs["RadiusJewelFinderCompute"] = function()
+ if not computeContext then
+ main.onFrameFuncs["RadiusJewelFinderCompute"] = nil
+ return
+ end
+ local res, errMsg = coroutine.resume(computeContext.co)
+ if not res then
+ cancelCompute()
+ controls.statusLabel.label = "^1Error: " .. tostring(errMsg)
+ controls.resultsList:SetMode("message", {
+ { text = "^1" .. tostring(errMsg) },
+ }, "^1Error")
+ return
+ end
+ if coroutine.status(computeContext.co) == "dead" then
+ cancelCompute()
+ end
+ end
+ end)
+ controls.computeButton.tooltipFunc = function(tooltip)
+ tooltip:Clear(true)
+ if computeContext then
+ tooltip:AddLine(16, "^7Stop the current compute.")
+ tooltip:AddLine(16, "^8Restores the previous results.")
+ return
+ end
+ if selectedJewelType and selectedJewelType.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 pts, and Sockets filters.")
+ end
+ controls.computeButton.shown = true
+
+ -- Status label
+ controls.statusLabel = new("LabelControl", TL, { 10, 54, 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", { }, COL_META .. "Click Compute to rank all jewels")
+ end
+ controls.showLegacyCheck = new("CheckBoxControl", TL, { 700, 54, 18 }, "Show legacy", function(state)
+ cancelCompute()
+ showLegacy = state
+ saveFinderState()
+ rebuildJewelTypeDropdown()
+ syncSelectedJewelTypeControls()
+ updatePreview()
+ runFind(false)
+ end)
+
+ -- ── Find button ───────────────────────────────────────────────────────────
+ runFind = function(makePreferred)
+ searchStartTime = GetTime()
+ if selectedJewelType and selectedJewelType.isAllJewels then
+ if not restoreCachedResults() then
+ showAllJewelsComputePrompt()
+ end
+ return
+ end
+ controls.statusLabel.label = "^7Searching..."
+ local ok, err = pcall(function()
+ local allocNodes = self.build.spec.allocNodes
+ local isThreadBestVariantSearch = selectedJewelType.isThread == true
+ local isImpossibleEscapeBestVariantSearch = selectedJewelType.isImpossibleEscape == true
+ local isSplitPersonalitySearch = selectedJewelType.isSplitPersonality == true
+ local isMassiveRadiusVariant = selectedJewelVariant and selectedJewelVariant.isMassiveRadius
+ local radiusIndex
+ local smallRadiusIndex = isImpossibleEscapeBestVariantSearch and radiusIndexByLabel["Small"] or nil
+ if isThreadBestVariantSearch then
+ if selectedThreadVariant then
+ radiusIndex = selectedThreadVariant.radiusIndex
+ end
+ elseif isImpossibleEscapeBestVariantSearch or isSplitPersonalitySearch then
+ radiusIndex = nil
+ elseif isMassiveRadiusVariant then
+ -- data.jewelRadius has no full Massive radius; we handle it below.
+ elseif selectedJewelType.variants and selectedJewelVariant and selectedJewelVariant.radiusIndex then
+ radiusIndex = selectedJewelVariant.radiusIndex
+ else
+ radiusIndex = selectedJewelType.radiusIndex
+ end
+
+ if not isThreadBestVariantSearch and not isImpossibleEscapeBestVariantSearch and not isSplitPersonalitySearch
+ and not radiusIndex and not isMassiveRadiusVariant then
+ return
+ end
+
+ local results = { }
+ local impossibleEscapeBestResult
+ if isImpossibleEscapeBestVariantSearch then
+ local variants = getDisplayedVariants() or selectedJewelType.variants or { }
+ for _, variant in ipairs(variants) do
+ local keystoneNode = treeData.keystoneMap[variant.keystoneName]
+ local nodes = keystoneNode and keystoneNode.nodesInRadius and smallRadiusIndex and keystoneNode.nodesInRadius[smallRadiusIndex]
+ if nodes then
+ local score = selectedJewelType.score(nodes, allocNodes) or 0
+ local topNodes = { }
+ for _, n in pairs(nodes) do
+ if not n.ascendancyName and (n.type == "Notable" or n.type == "Keystone") then
+ t_insert(topNodes, {
+ label = n.dn or n.name or "Unknown",
+ nodeId = n.id,
+ })
+ end
+ end
+ t_sort(topNodes, function(a, b) return a.label < b.label end)
+ local candidate = {
+ score = score,
+ topNodes = topNodes,
+ variant = variant,
+ detailText = variant.name,
+ }
+ if not impossibleEscapeBestResult
+ or candidate.score > impossibleEscapeBestResult.score
+ or (candidate.score == impossibleEscapeBestResult.score and candidate.variant.name < impossibleEscapeBestResult.variant.name) then
+ impossibleEscapeBestResult = candidate
+ end
+ end
+ end
+ end
+ for _, socket in ipairs(jewelSockets) do
+ local socketAllowed, occupancy = self:socketMatchesOccupiedMode(socket.id, selectedOccupiedMode)
+ local socketNode = treeData.nodes[socket.id]
+ if socketAllowed and socketNode and (socketNode.nodesInRadius or isSplitPersonalitySearch) then
+ if isThreadBestVariantSearch then
+ local bestThreadResult
+ for _, threadVariant in ipairs(threadVariants) do
+ local nodes = socketNode.nodesInRadius[threadVariant.radiusIndex]
+ if nodes then
+ local score = selectedJewelType.score(nodes, allocNodes) or 0
+ local topNodes = { }
+ for _, n in pairs(nodes) do
+ if not n.ascendancyName and (n.type == "Notable" or n.type == "Keystone") then
+ t_insert(topNodes, {
+ label = n.dn or n.name or "Unknown",
+ nodeId = n.id,
+ })
+ end
+ end
+ t_sort(topNodes, function(a, b) return a.label < b.label end)
+ local candidate = {
+ socket = socket,
+ score = score,
+ topNodes = topNodes,
+ variant = threadVariant,
+ replacedItemLabel = occupancy and occupancy.replacedItemLabel or nil,
+ storedUnallocatedItemLabel = occupancy and occupancy.storedUnallocatedItemLabel or nil,
+ }
+ if not bestThreadResult
+ or candidate.score > bestThreadResult.score
+ or (candidate.score == bestThreadResult.score and candidate.variant.radiusIndex < bestThreadResult.variant.radiusIndex) then
+ bestThreadResult = candidate
+ end
+ end
+ end
+ if bestThreadResult then
+ t_insert(results, bestThreadResult)
+ end
+ elseif isImpossibleEscapeBestVariantSearch and impossibleEscapeBestResult then
+ t_insert(results, {
+ socket = socket,
+ score = impossibleEscapeBestResult.score,
+ topNodes = impossibleEscapeBestResult.topNodes,
+ variant = impossibleEscapeBestResult.variant,
+ detailText = impossibleEscapeBestResult.detailText,
+ replacedItemLabel = occupancy and occupancy.replacedItemLabel or nil,
+ storedUnallocatedItemLabel = occupancy and occupancy.storedUnallocatedItemLabel or nil,
+ })
+ elseif isSplitPersonalitySearch then
+ local score = socket.classStartDist or self:getSocketDistanceToClassStart(socket.id)
+ t_insert(results, {
+ socket = socket,
+ score = score,
+ topNodes = { },
+ detailText = s_format("dist to start %d", score),
+ replacedItemLabel = occupancy and occupancy.replacedItemLabel or nil,
+ storedUnallocatedItemLabel = occupancy and occupancy.storedUnallocatedItemLabel or nil,
+ })
+ else
+ local nodes
+ if isMassiveRadiusVariant then
+ -- Build a temporary full Massive radius (2400).
+ nodes = { }
+ for idx, r in ipairs(data.jewelRadius) do
+ if r.outer <= 2400 and socketNode.nodesInRadius[idx] then
+ for nodeId, node in pairs(socketNode.nodesInRadius[idx]) do
+ nodes[nodeId] = node
+ end
+ end
+ end
+ else
+ nodes = socketNode.nodesInRadius[radiusIndex]
+ end
+
+ if nodes then
+ local scoreFn = (selectedJewelType.variants and selectedJewelVariant and selectedJewelVariant.score)
+ or selectedJewelType.score
+ local score = scoreFn(nodes, allocNodes)
+ local detailBuilder = (selectedJewelType.variants and selectedJewelVariant and selectedJewelVariant.detailBuilder)
+ or selectedJewelType.detailBuilder
+ local topNodes = { }
+ for _, n in pairs(nodes) do
+ if not n.ascendancyName and (n.type == "Notable" or n.type == "Keystone") then
+ t_insert(topNodes, {
+ label = n.dn or n.name or "Unknown",
+ nodeId = n.id,
+ })
+ end
+ end
+ t_sort(topNodes, function(a, b) return a.label < b.label end)
+ t_insert(results, {
+ socket = socket,
+ score = score or 0,
+ topNodes = topNodes,
+ detailText = detailBuilder and detailBuilder(nodes, allocNodes) or nil,
+ replacedItemLabel = occupancy and occupancy.replacedItemLabel or nil,
+ storedUnallocatedItemLabel = occupancy and occupancy.storedUnallocatedItemLabel or nil,
+ })
+ end
+ end
+ end
+ end
+
+ t_sort(results, function(a, b) return (a.score or 0) > (b.score or 0) end)
+
+ local equippedList = self:findEquippedJewelSockets(selectedJewelType)
+ local equippedSocketIds = { }
+ local existingSocketId
+ for _, entry in ipairs(equippedList) do
+ equippedSocketIds[entry.socketId] = true
+ if equippedList.atLimit then
+ existingSocketId = existingSocketId or entry.socketId
+ end
+ end
+ local rows = { }
+ for _, r in ipairs(results) do
+ local topLabels = buildNodeLabelList(r.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 scorePerPointSort = points > 0 and scorePerPoint or r.score
+ local detailText = r.detailText
+ if not detailText or detailText == "" then
+ detailText = #r.topNodes > 0 and s_format("%d match%s", #r.topNodes, #r.topNodes == 1 and "" or "es") or scoreLabel
+ elseif #topStr > 0 and (isThreadBestVariantSearch or isImpossibleEscapeBestVariantSearch) then
+ detailText = detailText .. s_format(" | %d match%s", #r.topNodes, #r.topNodes == 1 and "" or "es")
+ end
+ local detailNodeId = nil
+ if isImpossibleEscapeBestVariantSearch and r.variant and r.variant.keystoneName then
+ local keystoneNode = treeData.keystoneMap[r.variant.keystoneName]
+ detailNodeId = keystoneNode and keystoneNode.id or nil
+ end
+ local action
+ if isEquippedSocket then
+ action = "keep"
+ elseif existingSocketId and r.replacedItemLabel then
+ action = "moveReplace"
+ elseif existingSocketId then
+ action = "move"
+ elseif r.replacedItemLabel then
+ action = "replace"
+ else
+ action = "new"
+ end
+ t_insert(rows, {
+ socketLabel = r.socket.label,
+ socketId = r.socket.id,
+ points = points,
+ score = r.score or 0,
+ scorePerPoint = scorePerPoint,
+ scorePerPointSort = scorePerPointSort,
+ variantLabel = r.variant and (r.variant.name .. " Ring") or "",
+ detailText = detailText,
+ detailNodeId = detailNodeId,
+ topNodes = copyTableSafe(r.topNodes, false, true),
+ replacedItemLabel = r.replacedItemLabel,
+ storedUnallocatedItemLabel = r.storedUnallocatedItemLabel,
+ action = action,
+ applyRawText = (r.variant and r.variant.rawText)
+ or (selectedJewelVariant and selectedJewelVariant.rawText)
+ or selectedJewelType.rawText,
+ })
+ end
+ controls.resultsList:SetMode(isThreadBestVariantSearch and "findThread" or "find", rows, COL_META .. "(no results)")
+ local elapsed = formatElapsed(searchStartTime)
+ controls.statusLabel.label = (isThreadBestVariantSearch
+ and s_format("^7Thread of Hope | %d | score/pt", #results)
+ or isImpossibleEscapeBestVariantSearch
+ and s_format("^7Impossible Escape | %d | score/pt", #results)
+ or isSplitPersonalitySearch
+ and s_format("^7Split Personality | %d | score/pt", #results)
+ or s_format("^7%d results | score/pt", #results)) .. elapsed
+ saveResultCache("find", isThreadBestVariantSearch and "findThread" or "find", rows, COL_META .. "(no results)", controls.statusLabel.label, makePreferred)
+ if not makePreferred then
+ restoreCachedResults()
+ end
+ end)
+ if not ok then
+ controls.statusLabel.label = "^1Error: " .. tostring(err)
+ controls.resultsList:SetMode("message", {
+ { text = "^1" .. tostring(err) },
+ }, "^1Error")
+ end
+ end
+ controls.findButton = new("ButtonControl", BL, { edgePadding, bottomButtonY, 100, buttonHeight }, "Find", function()
+ cancelCompute()
+ runFind(true)
+ end)
+ controls.findButton.shown = not (selectedJewelType and selectedJewelType.isAllJewels)
+ controls.findButton.tooltipFunc = function(tooltip)
+ tooltip:Clear(true)
+ tooltip:AddLine(16, "^7Find sockets with matching passives for this jewel.")
+ tooltip:AddLine(16, "^8Use Compute to rank by the selected stat.")
+ end
+
+ applySelectedResult = function()
+ local idx = controls.resultsList.selIndex
+ local row = idx and controls.resultsList.list[idx]
+ if not row or not row.applyRawText then return end
+
+ local item = new("Item", "Rarity: Unique\n" .. row.applyRawText)
+ item:BuildModList()
+ self.build.itemsTab:AddItem(item, true)
+
+ local slot = self.build.itemsTab.sockets[row.socketId]
+ if slot then
+ slot:SetSelItemId(item.id)
+ end
+ self.build.itemsTab:PopulateSlots()
+ self.build.buildFlag = true
+ end
+ controls.applyButton = new("ButtonControl", BL, { edgePadding + 480, bottomButtonY, 80, buttonHeight }, "Apply", applySelectedResult)
+ controls.applyButton.enabled = function()
+ local idx = controls.resultsList.selIndex
+ return idx and controls.resultsList.list[idx] and controls.resultsList.list[idx].applyRawText ~= nil
+ end
+ controls.applyButton.tooltipFunc = function(tooltip)
+ local idx = controls.resultsList.selIndex
+ local row = idx and controls.resultsList.list[idx]
+ if not row or not row.applyRawText then
+ tooltip:Clear(true)
+ tooltip:AddLine(16, "^7Select a result to apply.")
+ return
+ end
+ tooltip:Clear(true)
+ tooltip:AddLine(16, "^7Equip ^x33FF77" .. (row.jewelName or "jewel") .. " ^7in ^x33FF77" .. (row.socketLabel or "socket"))
+ tooltip:AddLine(16, "^8Adds the jewel to this build.")
+ if row.storedUnallocatedItemLabel then
+ tooltip:AddLine(16, "^xFFAA33Replaces the stored jewel ignored by the current tree.")
+ end
+ tooltip:AddLine(16, "^8Double-click a result to apply it.")
+ end
+
+ local function restoreFinderState()
+ if not finderState.jewelTypeName then
+ updatePreview()
+ if selectedJewelType and selectedJewelType.isAllJewels then
+ showAllJewelsComputePrompt()
+ end
+ 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.dreamFamilyValue then
+ for i, option in ipairs(dreamFamilyOptions) do
+ if option.value == finderState.dreamFamilyValue then
+ selectedDreamFamily = option
+ controls.variantFamilySelect.selIndex = i
+ break
+ end
+ end
+ 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
+ if selectedJewelType and selectedJewelType.isThread and finderState.threadVariantName then
+ for i, variant in ipairs(threadVariants) do
+ if variant.name == finderState.threadVariantName then
+ selectedThreadVariant = variant
+ controls.threadVariantSelect.selIndex = i
+ 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
+ break
+ end
+ end
+ end
+
+ suppressFinderStateSave = false
+ saveFinderState()
+ updatePreview()
+ runFind(false)
+ end
+
+ -- Close button
+ controls.closeButton = new("ButtonControl", BR, { -edgePadding, bottomButtonY, 100, buttonHeight }, "Close", function()
+ cancelCompute()
+ main:ClosePopup()
+ end)
+
+ -- Initialise preview and open popup
+ restoreFinderState()
+ local popup = main:OpenPopup(popupWidth, popupHeight, "Find Radius Jewel", 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
+ controls.computeButton:Click()
+ return
+ end
+ end
+ baseProcessInput(self, inputEvents, viewPort)
+ end
+ return popup
+end
diff --git a/src/Classes/TreeTab.lua b/src/Classes/TreeTab.lua
index 77bfe3ffdb..3c5b99104b 100644
--- a/src/Classes/TreeTab.lua
+++ b/src/Classes/TreeTab.lua
@@ -188,11 +188,16 @@ local TreeTabClass = newClass("TreeTab", "ControlHost", function(self, build)
self:FindTimelessJewel()
end)
+ -- Find Radius Jewel Button
+ self.controls.findRadiusJewel = new("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", { "LEFT", self.controls.findTimelessJewel, "RIGHT" }, { 130, 0, 20 }, "Show Node Power:", function(state)
+ self.controls.treeHeatMap = new("CheckBoxControl", { "LEFT", self.controls.findRadiusJewel, "RIGHT" }, { 130, 0, 20 }, "Show Node Power:", function(state)
self.viewer.showHeatMap = state
self.controls.treeHeatMapStatSelect.shown = state
@@ -397,6 +402,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)
@@ -415,7 +421,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)
@@ -2706,3 +2712,7 @@ function TreeTabClass:FindTimelessJewel()
local panelHeight = 565
main:OpenPopup(panelWidth, panelHeight, "Find a Timeless Jewel", controls)
end
+
+function TreeTabClass:FindRadiusJewel()
+ new("RadiusJewelFinder", self):Open()
+end