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