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